From 1db9dcec81e7212e792399ddee5d5109bf6f226f Mon Sep 17 00:00:00 2001 From: v1rusnl <18641204+v1rusnl@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:21:12 +0200 Subject: [PATCH 001/494] Update Traefik image version to v3.7 --- install/config/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index 96b15ce47..05b2fbe8c 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -50,7 +50,7 @@ services: - 80:80{{end}} traefik: - image: docker.io/traefik:v3.6 + image: docker.io/traefik:v3.7 container_name: traefik restart: unless-stopped {{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}} From ab199555023eac5a1d6cc196023e44ea76728796 Mon Sep 17 00:00:00 2001 From: v1rusnl <18641204+v1rusnl@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:22:13 +0200 Subject: [PATCH 002/494] Upgrade Traefik image to version 3.7 --- compose.example.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose.example.yaml b/compose.example.yaml index 50cb1bcc1..5a35e20cf 100644 --- a/compose.example.yaml +++ b/compose.example.yaml @@ -41,7 +41,7 @@ services: - 80:80 # Port for traefik because of the network_mode traefik: - image: traefik:v3.6 + image: traefik:v3.7 container_name: traefik restart: unless-stopped network_mode: service:gerbil # Ports appear on the gerbil service From bb4deb1ae9346254c19bfd46208fe336a2370b91 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 3 Jul 2026 21:05:16 +0200 Subject: [PATCH 003/494] =?UTF-8?q?=E2=9C=A8=20remember=20last=20used=20id?= =?UTF-8?q?p=20in=20dashboard=20login=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LoginForm.tsx | 55 +++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index fe3c80667..40685f846 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -30,10 +30,7 @@ import Link from "next/link"; import { GenerateOidcUrlResponse } from "@server/routers/idp"; import { Separator } from "./ui/separator"; import { useTranslations } from "next-intl"; -import { - generateOidcUrlProxy, - loginProxy -} from "@app/actions/server"; +import { generateOidcUrlProxy, loginProxy } from "@app/actions/server"; import { redirect as redirectTo } from "next/navigation"; import { useEnvContext } from "@app/hooks/useEnvContext"; import IdpTypeIcon from "@app/components/IdpTypeIcon"; @@ -41,6 +38,7 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon"; import { loadReoScript } from "reodotdev"; import { build } from "@server/build"; import MfaInputForm from "@app/components/MfaInputForm"; +import { useLocalStorage } from "@app/hooks/useLocalStorage"; export type LoginFormIDP = { idpId: number; @@ -105,7 +103,6 @@ export default function LoginForm({ } }, []); - const formSchema = z.object({ email: z.string().email({ message: t("emailInvalid") }), password: z.string().min(8, { message: t("passwordRequirementsChars") }) @@ -130,6 +127,10 @@ export default function LoginForm({ } }); + const [lastUsedIdpId, setLastUsedIdpId] = useLocalStorage( + "login:last-used-idp", + null + ); async function onSubmit(values: any) { const { email, password } = form.getValues(); @@ -179,8 +180,7 @@ export default function LoginForm({ if (data.useSecurityKey) { setError( t("securityKeyRequired", { - defaultValue: - "Please use your security key to sign in." + defaultValue: "Please use your security key to sign in." }) ); return; @@ -242,6 +242,8 @@ export default function LoginForm({ async function loginWithIdp(idpId: number) { let redirectUrl: string | undefined; + + setLastUsedIdpId(idpId.toString()); try { const data = await generateOidcUrlProxy( idpId, @@ -356,7 +358,6 @@ export default function LoginForm({ )}
- {!mfaRequested && ( <> { - loginWithIdp(idp.idpId); - }} > - - {idp.name} - + + + {lastUsedIdpId === + idp.idpId.toString() && ( +
+ + {t("idpLastUsed")} + +
+ )} +
); })} )} )} - ); From 05bf77da295aaf2a93c3cfb4dbd5f4161898e949 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 3 Jul 2026 21:05:47 +0200 Subject: [PATCH 004/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20remove=20`cache`?= =?UTF-8?q?=20on=20`verifySession`=20calls=20as=20it's=20already=20wrapped?= =?UTF-8?q?=20in=20`cache`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/auth/login/page.tsx | 3 +-- src/app/page.tsx | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 274cab561..cdb05c71d 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -29,8 +29,7 @@ export default async function Page(props: { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { const searchParams = await props.searchParams; - const getUser = cache(verifySession); - const user = await getUser({ skipCheckVerifyEmail: true }); + const user = await verifySession({ skipCheckVerifyEmail: true }); const isInvite = searchParams?.redirect?.includes("/invite"); const forceLoginParam = searchParams?.forceLogin; diff --git a/src/app/page.tsx b/src/app/page.tsx index 7f0f05b57..84a14cb5c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,7 +5,6 @@ import UserProvider from "@app/providers/UserProvider"; import { ListUserOrgsResponse } from "@server/routers/org"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; -import { cache } from "react"; import OrganizationLanding from "@app/components/OrganizationLanding"; import { pullEnv } from "@app/lib/pullEnv"; import { cleanRedirect } from "@app/lib/cleanRedirect"; @@ -13,7 +12,6 @@ import { Layout } from "@app/components/Layout"; import RedirectToOrg from "@app/components/RedirectToOrg"; import { InitialSetupCompleteResponse } from "@server/routers/auth"; import { cookies } from "next/headers"; -import { build } from "@server/build"; export const dynamic = "force-dynamic"; @@ -27,8 +25,7 @@ export default async function Page(props: { const env = pullEnv(); - const getUser = cache(verifySession); - const user = await getUser({ skipCheckVerifyEmail: true }); + const user = await verifySession({ skipCheckVerifyEmail: true }); let complete = false; try { From a74c0c227c1df7d3909fe48223f82a05053a85f3 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 3 Jul 2026 21:05:53 +0200 Subject: [PATCH 005/494] =?UTF-8?q?=F0=9F=8C=90=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/en-US.json b/messages/en-US.json index 4701d4da1..5d774c50b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1503,6 +1503,7 @@ "otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.", "otpAuthSubmit": "Submit Code", "idpContinue": "Or continue with", + "idpLastUsed": "Last used", "otpAuthBack": "Back to Password", "navbar": "Navigation Menu", "navbarDescription": "Main navigation menu for the application", From 289be30e6b17a28f4dd216889e1e39508cae3c50 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 3 Jul 2026 22:33:51 +0200 Subject: [PATCH 006/494] =?UTF-8?q?=E2=9C=A8=20show=20last=20used=20login?= =?UTF-8?q?=20idp=20in=20smart=20login=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/auth/login/page.tsx | 44 ++++++++++++++-- src/components/IdpLoginButtons.tsx | 80 ++++++++++++++++++++---------- src/components/SmartLoginForm.tsx | 15 +++++- src/lib/consts.ts | 1 + src/lib/setClientCookie.ts | 32 ++++++++++++ 5 files changed, 139 insertions(+), 33 deletions(-) create mode 100644 src/lib/consts.ts create mode 100644 src/lib/setClientCookie.ts diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index cdb05c71d..92097512c 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -16,8 +16,11 @@ import LoginCardHeader from "@app/components/LoginCardHeader"; import { priv } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { LoginFormIDP } from "@app/components/LoginForm"; -import { ListIdpsResponse } from "@server/routers/idp"; +import { ListIdpsResponse, type GetIdpResponse } from "@server/routers/idp"; import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts"; +import z from "zod"; export const metadata: Metadata = { title: "Log In" @@ -31,6 +34,8 @@ export default async function Page(props: { const searchParams = await props.searchParams; const user = await verifySession({ skipCheckVerifyEmail: true }); + const lastUsedIdpCookie = (await cookies()).get(LAST_USED_IDP_COOKIE_NAME); + const isInvite = searchParams?.redirect?.includes("/invite"); const forceLoginParam = searchParams?.forceLogin; const forceLogin = forceLoginParam === "true"; @@ -84,19 +89,47 @@ export default async function Page(props: { (build === "enterprise" && env.app.identityProviderMode === "org"); let loginIdps: LoginFormIDP[] = []; + let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null = + null; if (!useSmartLogin) { // Load IdPs for DashboardLoginForm (OSS or org-only IdP mode) if (build === "oss" || env.app.identityProviderMode !== "org") { - const idpsRes = await cache( - async () => - await priv.get>("/idp") - )(); + const idpsRes = + await priv.get>("/idp"); loginIdps = idpsRes.data.data.idps.map((idp) => ({ idpId: idp.idpId, name: idp.name, variant: idp.type })) as LoginFormIDP[]; } + } else { + if (lastUsedIdpCookie) { + const lastUsedIdpSchema = z.object({ + orgId: z.string().optional(), + idpId: z.number() + }); + try { + const persistedData = lastUsedIdpSchema.parse( + JSON.parse(lastUsedIdpCookie.value) + ); + + const idpRes = await priv.get>( + `/idp/${persistedData.idpId}` + ); + + const idp = idpRes.data.data.idp; + + lastUsedIdpForSmartLogin = { + idpId: idp.idpId, + name: idp.name, + variant: idp.type, + orgId: persistedData.orgId, + lastUsed: true + }; + } catch (error) { + // the idp might not exist or the data is malformatted, skip this + } + } } const t = await getTranslations(); @@ -159,6 +192,7 @@ export default async function Page(props: { redirect={redirectUrl} forceLogin={forceLogin} defaultUser={defaultUser} + lastUsedIdp={lastUsedIdpForSmartLogin} orgSignIn={ !isInvite && (build === "saas" || diff --git a/src/components/IdpLoginButtons.tsx b/src/components/IdpLoginButtons.tsx index d015dce52..0851a0dd0 100644 --- a/src/components/IdpLoginButtons.tsx +++ b/src/components/IdpLoginButtons.tsx @@ -1,26 +1,25 @@ "use client"; -import { useEffect, useState } from "react"; -import { Button } from "@app/components/ui/button"; -import { Alert, AlertDescription } from "@app/components/ui/alert"; -import { useTranslations } from "next-intl"; +import { generateOidcUrlProxy } from "@app/actions/server"; import IdpTypeIcon from "@app/components/IdpTypeIcon"; -import { - generateOidcUrlProxy, - type GenerateOidcUrlResponse -} from "@app/actions/server"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { Button } from "@app/components/ui/button"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; +import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts"; +import { setClientCookie } from "@app/lib/setClientCookie"; +import { useTranslations } from "next-intl"; import { redirect as redirectTo, - useParams, + useRouter, useSearchParams } from "next/navigation"; -import { useRouter } from "next/navigation"; -import { cleanRedirect } from "@app/lib/cleanRedirect"; +import { useEffect, useState, useTransition } from "react"; export type LoginFormIDP = { idpId: number; name: string; variant?: string; + lastUsed?: boolean; }; type IdpLoginButtonsProps = { @@ -35,7 +34,6 @@ export default function IdpLoginButtons({ orgId }: IdpLoginButtonsProps) { const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); const t = useTranslations(); const params = useSearchParams(); @@ -52,10 +50,22 @@ export default function IdpLoginButtons({ } }, []); + const [loading, startTransition] = useTransition(); + async function loginWithIdp(idpId: number) { - setLoading(true); setError(null); + setClientCookie( + LAST_USED_IDP_COOKIE_NAME, + JSON.stringify({ + orgId, + idpId + }), + { + sameSite: "Lax" + } + ); + let redirectToUrl: string | undefined; try { console.log("generating", idpId, redirect || "/", orgId); @@ -68,7 +78,6 @@ export default function IdpLoginButtons({ if (response.error) { setError(response.message); - setLoading(false); return; } @@ -84,7 +93,6 @@ export default function IdpLoginButtons({ "An unexpected error occurred. Please try again." }) ); - setLoading(false); } if (redirectToUrl) { @@ -124,20 +132,38 @@ export default function IdpLoginButtons({ idp.variant || idp.name.toLowerCase(); return ( - + + + {idp.lastUsed && ( +
+ + {t("idpLastUsed")} + +
+ )} + ); })} diff --git a/src/components/SmartLoginForm.tsx b/src/components/SmartLoginForm.tsx index dd0e131df..de1955771 100644 --- a/src/components/SmartLoginForm.tsx +++ b/src/components/SmartLoginForm.tsx @@ -27,6 +27,8 @@ import UserProfileCard from "@app/components/UserProfileCard"; import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton"; import { Separator } from "@app/components/ui/separator"; import OrgSignInLink from "@app/components/OrgSignInLink"; +import type { LoginFormIDP } from "./LoginForm"; +import IdpLoginButtons from "./IdpLoginButtons"; const identifierSchema = z.object({ identifier: z.string().min(1, "Username or email is required") @@ -53,6 +55,7 @@ type SmartLoginFormProps = { forceLogin?: boolean; defaultUser?: string; orgSignIn?: OrgSignInConfig; + lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null; }; type ViewState = @@ -89,7 +92,8 @@ export default function SmartLoginForm({ redirect, forceLogin, defaultUser, - orgSignIn + orgSignIn, + lastUsedIdp }: SmartLoginFormProps) { const router = useRouter(); const { env } = useEnvContext(); @@ -294,6 +298,15 @@ export default function SmartLoginForm({ + + {lastUsedIdp && ( + + )} + Date: Fri, 3 Jul 2026 22:33:58 +0200 Subject: [PATCH 007/494] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LoginForm.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 40685f846..eef7406a7 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -44,6 +44,7 @@ export type LoginFormIDP = { idpId: number; name: string; variant?: string; + lastUsed?: boolean; }; type LoginFormProps = { From 2fe85ebda2d43c543d13b9c5e6730e7649e05bfb Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 8 Jul 2026 03:11:12 +0200 Subject: [PATCH 008/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20set=20last=20IDP?= =?UTF-8?q?=20to=20null=20if=20login=20with=20email=20+=20password?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LoginForm.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index eef7406a7..82f948ca9 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -137,6 +137,7 @@ export default function LoginForm({ const { email, password } = form.getValues(); const { code } = mfaForm.getValues(); + setLastUsedIdpId(null); setLoading(true); setError(null); From bb5547a157bda0cbc806fc8233a309ff635d0930 Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:33:57 +0530 Subject: [PATCH 009/494] fix: redirect to /auth/initial-setup after hitting auth rate limit (#3408) --- server/routers/external.ts | 7 ++++++- src/app/page.tsx | 11 ++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/server/routers/external.ts b/server/routers/external.ts index 8a22393ca..3151602aa 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1338,6 +1338,12 @@ authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage); // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); + +// Register setup-check BEFORE the global auth rate limiter. +// This endpoint is called on every dashboard root page load (pure boolean +// read, no secrets) and must not consume the auth rate-limit budget. +authRouter.get("/initial-setup-complete", auth.initialSetupComplete); + authRouter.use( rateLimit({ windowMs: @@ -1642,7 +1648,6 @@ authRouter.post("/idp/:idpId/oidc/generate-url", idp.generateOidcUrl); authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback); authRouter.put("/set-server-admin", auth.setServerAdmin); -authRouter.get("/initial-setup-complete", auth.initialSetupComplete); authRouter.post("/validate-setup-token", auth.validateSetupToken); // Security Key routes diff --git a/src/app/page.tsx b/src/app/page.tsx index 7f0f05b57..c07475eb5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -30,14 +30,19 @@ export default async function Page(props: { const getUser = cache(verifySession); const user = await getUser({ skipCheckVerifyEmail: true }); - let complete = false; + let complete: boolean | null = null; // null means "unknown" (request errored) try { const setupRes = await internal.get< AxiosResponse >(`/auth/initial-setup-complete`, await authCookieHeader()); complete = setupRes.data.data.complete; - } catch (e) {} - if (!complete) { + } catch (e) { + // Swallow errors (e.g. 429 rate limit, 500, network failure). + // Only redirect to initial-setup when the server *confirms* setup + // is incomplete (complete === false). If the request itself failed we + // cannot tell, so fall through to the login redirect instead. + } + if (complete === false) { redirect("/auth/initial-setup"); } From 34d5c9535d3a9c4f029088cf2a4db905bd3ab2ab Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 10 Jul 2026 03:22:28 +0200 Subject: [PATCH 010/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20delete=20last=20us?= =?UTF-8?q?ed=20IDP=20coookie=20when=20logging=20in=20with=20email/passwor?= =?UTF-8?q?d=20on=20smart=20login=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LoginPasswordForm.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/LoginPasswordForm.tsx b/src/components/LoginPasswordForm.tsx index d6e0c0b79..efbc8d727 100644 --- a/src/components/LoginPasswordForm.tsx +++ b/src/components/LoginPasswordForm.tsx @@ -22,6 +22,8 @@ import Link from "next/link"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { cleanRedirect } from "@app/lib/cleanRedirect"; import MfaInputForm from "@app/components/MfaInputForm"; +import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts"; +import { setClientCookie } from "@app/lib/setClientCookie"; type LoginPasswordFormProps = { identifier: string; @@ -82,6 +84,12 @@ export default function LoginPasswordForm({ const { password } = values; const { code } = mfaForm.getValues(); + // delete last used auth cookie by setting it in the past + setClientCookie(LAST_USED_IDP_COOKIE_NAME, JSON.stringify(null), { + sameSite: "Lax", + days: -1 + }); + setLoading(true); setError(null); From 8d018fe47da9ea50d5ab44e28d2679dfb74524bb Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Jul 2026 21:28:48 -0400 Subject: [PATCH 011/494] Clean up --- server/lib/blueprints/applyBlueprint.ts | 6 ------ server/lib/blueprints/privateResources.ts | 3 --- server/routers/siteResource/createSiteResource.ts | 3 --- 3 files changed, 12 deletions(-) diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index c62dd4735..44646e0bf 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -34,12 +34,6 @@ import { rebuildClientAssociationsFromSiteResource, waitForSiteResourceRebuildIdle } from "../rebuildClientAssociations"; -import { build } from "@server/build"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import next from "next"; -import { LimitId } from "../billing"; -import { usageService } from "../billing/usageService"; type ApplyBlueprintArgs = { orgId: string; diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 74a1f618f..aa146679f 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -26,9 +26,6 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "../billing/tierMatrix"; import { build } from "@server/build"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import next from "next"; import { LimitId } from "../billing"; import { usageService } from "../billing/usageService"; diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index eaf4cf130..365053cf1 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -56,7 +56,6 @@ const createSiteResourceSchema = z siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided destinationPort: z.int().positive().optional(), destination: z.string().min(1).nullish(), - enabled: z.boolean().default(true), alias: z .string() .regex( @@ -275,7 +274,6 @@ export async function createSiteResource( scheme, destinationPort, destination, - enabled, ssl, alias, userIds, @@ -539,7 +537,6 @@ export async function createSiteResource( destination: destination, // the ssh can be null scheme, destinationPort, - enabled, alias: alias ? alias.trim() : null, aliasAddress, tcpPortRangeString: tcpPortRangeStringAdjusted, From d38f9ac2bb46ceaf720d97a059bf7d91098f5237 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Jul 2026 21:32:35 -0400 Subject: [PATCH 012/494] update readme --- README.md | 42 +++++++++++++++++++++-- public/screenshots/resource-launcher.png | Bin 0 -> 568945 bytes 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 public/screenshots/resource-launcher.png diff --git a/README.md b/README.md index ab857350e..1827f2570 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@

-Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls. +Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control. ## Installation @@ -63,11 +63,26 @@ Pangolin is an open-source, identity-based remote access platform built on WireG Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform. +* Lightweight user-space connector runs anywhere +* Punches through any firewall +* Doesn't require open ports or a public IP +* Strict network segmentation +* WireGuard-based +* Get alerts when a device or network resource goes down + Sites ### Browser-based reverse proxy access -Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. +Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the browser through identity and context-aware tunneled reverse proxies. Users access resources with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. + +* Expose a web panel anywhere +* Access via any web browser +* Single sign-on across all resources +* HTTPS resources +* Remote desktop in the browser with VNC and RDP +* In-browser SSH terminal with privileged access management (PAM) +* PIN codes, passcodes, email OTP, geoblocking, allow-lists, and more Reverse proxy access @@ -75,14 +90,35 @@ Expose web applications through identity and context-aware tunneled reverse prox Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network. +* Peer-to-peer with intelligent NAT traversal +* Hosts/IPs and port ranges +* Network ranges/CIDRs +* Friendly DNS aliases for network addresses +* Privileged access management (PAM) with SSH resources +* Private HTTPS resources only accessible on the private network + Private resources ### Give users and roles access to resources -Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define. +Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define. + +* Bring your existing identity provider (IdP) or use Pangolin identities +* Sync users and roles from your IdP +* User- and role-based access control +* Full network audit and access logs Users from identity provider with roles +### Find and launch resources from a personalized home page + +Give users a landing page to quickly find and open the resources they can access. Resources are grouped by site or label, searchable, and filterable, with grid or list views. Saved views capture filters, grouping, and layout as personal or organization-wide defaults. + +* Single place for admins and non-admins to see accessible resources +* Create reusable views for common access patterns + +Resource Launcher + ## Download Clients Download the Pangolin client for your platform: diff --git a/public/screenshots/resource-launcher.png b/public/screenshots/resource-launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..93b803ee66d236eba80036a4120024b1cd2fe767 GIT binary patch literal 568945 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%0D(Fn|6li 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
nUjW-p!+Uh+;iDe3xA*V&_|O^{h`f9J@7|Er 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 z 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�$Yv)~9S;1)ih`ZR21P&;!Ghy7MP0z~dZbL!~P_#V# zAH&19=afJbO{bNX(zx&J`EXq-=ZS{feEj?reZv#sc?X2X?!cGba9-11tI8BnF}c3* z>ldrC9J;=~ZR{vxzxLjj&_l@j7a9E}{NWYT_sb5R(DDBs+w~|p@>0Gl{%yn{91g*X z=S26QhVGb1gV>FauOA?wMqoUc=z|BrwStMp93Ahg>5PZAg!x0nGUn zMx*o;RTS{D8CXv=IEPHxYiO{71W5eQ;$UCx{E|8um!Yoiap=-;ad8c{PnM;o`V$SW zEH4+2i&}uJbIU0daEYKCa`Ey<(wtb8w7Mk8Y4gwXPgDyyzswhwNS-d7aOWR_DLNk< z*`j7rfx(D5>rNd&sCiz%WzDic|A3W+rK;9%8bGB*7#H4dGLA7bsc`m)J~nptQ!k3t zf`2@{dlz9J9Kqfqx6qiYoc1&*WHmx>@QGtI>6u;fZ!G~Vv?L^x+XaNQF1XN-?KHzV zVTFY{anjmwv*abW_?`=JFNoF$AVQyb0kvVRfp`Fr`7Jq|IkOs3Nv$hZ`+BVL4&=lL zs@t!C?e&-=AbEjeBKP@GaG0Twa0kpi73c&ciLqo#tj)RlSy~85SWhQ$^XDsa9{S(@^?d*5%;duf+ zWV%kZ?o`yogx*C)Mn6K%SbzdU2Cct&7N53HM?Pb;TEap%^3x~3mz%ULCg2kiv$GRg z(0)nV=2E6alFYn)^gPLtdgzbPK~|M-4t-&}B`W1_^_Jb~@Kk(R!Xq0KZWmG2P^5v_ z0=uqXwt6?#pVGn3Zu!u!x!EP`mbFx~m^ccq@msfI)y}04Z1+%HzG^T@jC0daLj&)U z)aQ1i=i{FGZ1<3)6>u-Aj+$2%nQg+ZXO2!z>OMz$-RTGT02N0pS+S?w)td818!jp7 zivOp;l9HZ^gtYU_iHnOd{<1>^fq~KMp`@?q&;+*!4?*9yav+ifSJ-~_Drb6Uy}B*# zXh*u5PlxW=;&B+kLr7iWv{GAtfsrRNDykvr^vP1(i<`Jf0H83BW#$PqKMixu%ag`o zX!9$#<2$xdp`pHjjnVWYeUSR)izpO~IC;#46>o^H-DGU3SJm&MOnbr^Mc<=pjfOU$?egHxG(T>GpeYrQNHTB z4R78CFJ&oZ3~x__yl>WAbFG8bZvd*nw28mk$7&@bEJc7?cHwbFV6Is(y`go?^FL)-j!x7|JO z(_pT>rygBs@jVV@)du=uWItW6Vqj%u9Ye2GY6l0AV!KM_C4sHKyh8!;P*a#o?e@73 zds44AA0*y;Dcd+5E3oA}H3(da!LjdWv?z{fhzj)UBGAJpf1lxwlQUV8pML=`LYOb* zOcyW45Q(0~TWsA>zSC|uYExi#QBA$6t&Ovv?b&IkTgE}v=3eDrzI+L0#yO7TDJkp% zZwTHb>gNRU(${HecRCJ&sBYH%c4XgbabDB2dK#h2h1MLU&-y*#A47AB-xD4N)FXHg ztq!S!=wLb|`;I{BFoVC?_bpK?(ULXXJBEeV^ly!OfH(YXhwzK|COpL+0CT!d3Cc*(2j{A<{`ERc`7J3mw@bA6#@Gu*s z^g~bpaL02YjA(aYpKujIgq%~z72nVcJVy>95&%l+Kj)roZa!z$H4A7lm36^O;+bxW zP&)kd<$SD(F_gcuZ>-%9ncfMY#N=*MqbeoY%CHO#!wlNOUjc-S`xgyBNT62~j`|Y8 zxdimdrThHOFq-rfeg#uQ_m3a007Y2Izq$q!T7Wl^K16dvj|>Q6RVVHI0A5QSByva< z0xIORVBSFmYi*=T$Zo_t|yPcB;AyVN*BPUAw<(^Pj2kCg{i5kK_!KV z8xjYbD=XDdV62hVDgr30uup={-i+hOGf2lNtnjVgHc+#C zUE$)&lcp=tM0_m3ujJL?0nUxBn_880YK70cN3uY-!C;pG1w@^lp1w1HRD;^N)QA{N zjvoPt>AS}ll`_OR7fgY;TU3ee?QOb%fl&vJ)RC|P5vaX;J!qL9Hz<)+7$CK`cVu51 zvX!lAPxLB8UAs75Cq4En*lysV`|9eAWFeT+CI(!d#hurnmb+}K?FHht->Rz>N=$*- zeDSX5I1ssB2Ib`ES3#!T(A|!Z7n-B&X*(4GCrk+_A$Ge|Xuj3?M^t|N-1SVR)YXU_ z?X-0T6cP6^PIID{8HSOtm`V54w{LIyHGy?&IDZQjMX+AuqrmZU-7e7y8=zb4w|gGK z#82oz#6P-rCCJ4KA6zgm7Z)A>Zm?J>6752h9l{x4tQ_|kOD0p;qPYkVb-lWM9f&$~ zP1tqo-hhr6r19VijNI!zy@Do=bw>tWtet-!!8tt&YzQ7ZGkAi+PQ7s(`}=AL&}Z9m zpg4`4TjwyvVX=tRxO@NdXX>>L{ra{$x2s%^Lz4-5lKwNI_wKQ6?_#qHUyHwGWMV>` zEE}6PwAjym3P^nsPEc#sS!pu|f4$J5^jJ~xhLZ!djb{e?`n1Z7QYAZ1r~`6~@Itq2 z2}@WLP?)*g4_`Hg7*^KR)zPnhPTO1E-ZqvhnVz42H8Qe@fS;D10DJL@WGMMi&lBG= zhW!{g7?M~s4x>a=C4v&og#}9yM=(K2?=9e8SV-ni)woLwpwvJB8l9sV$T2iu6qw(b zD<&gm{!#1i7ng_-%Tg$3cb!*gqE z_n;tJs25n%q&%{jxF9J1wJTwx6PoW+pJLy>#UUiO|1mQT_u5^%+ANR_SR%_^O0jaf z(!XIp;pbNI0m|_=4AASHHwqWvH@SE$gq3US>(AQrzEyi!Vm-eD`X^*rL~#}!Pa&xY zQGECfJLjIVv@*o%>H>@I6oHT3y_KzBoVsP5K!L2xSLC-HI>7X7WL^sX_t5 z-Zw67m;?s1MzwgLS|AWHZ#;z_T<}SY_4lV02!Kfg@%;4Ch@#}O zSKQXJvgDg>fGz^HyS6B$>)Ul5P`Y>x1y=?ZzCwYX?o+eCI`%Wo3DtVGmyn!{4Y=o5 zyQY36OEFrRtRTy*xN3>e;UyhF5vCoMZgf7xKdd?@F81GuJ=d4&g|{%-!5|g>EK~Xe zr=i=&{2~_9==|g_U(P6{i&{Fx$(>LVbeXo?p(MBW#!gCWKtNGY@`r}6Wo|a_?c4;H z+x`H_dR~sl1bFV|7$MCiVMyY`x&6?LH&#(O(H}lEQ-f0Gb?y0%w>ctK0WIqreuRX& zH!<&f?Y{T`;_u}6>{aLm`7s?29jV|*kxpGY063VhB;=`H>w!0+JJ}q$^@AngBCt`s zfUrF$)Y{dBJ6Xq3b6mXx#^|U!8>*nREOG2cZ`%iAPFkVj0Nry+H6cPCaM; zw53 zY9HCwZy<1{`L;4%imR-y9`OBvVyZOogg>Wf1g{G@)cF@Bpv`r4SSj@*xhwL2u$@GK zF(C&;vC0+A4E2(4eMZjCSm7MHfvvhoF`%!AA^1$nsXfs^WVRtsnIy4b_qAO#5etfZ#;JsQ!jOL?q^S}JbIJ7_i zr;Ot`(nG(U`{vg(9=Z1J?h8Q9n-LP{>tnxef-*5R4Jgl-Vn6_oKgt|r+tEPA7)68) zI%-d-04^=u6#B^sxp!#;_|@N~Uj_tKw3^^k5C?)~A9cr9)auJ-Ye(S2g4JF*$MmeN zH=?!)N<>P&y;M6pln3&i4iVPv6wWOLo z%7;)YS^>S3%Jz17xtJI7(60;K9s2R3^5h^sHUBZlSJv~FzTKb5%gY0o(`lwz;p`zW z1sYF3_MAbHp~z?3$7e-6?24tFqGH_!6XXYgU3HVuc7uRv3}$9GN6xjrjbgXY0)S}WgCfx%e_luyT(2i*Q{(YkVf#gTv zpXhoxT%<3yUIYb%OTZ1A=z!qGe{a!2E;rTFbCWQSa@}HQ^(2hcwlLiXnyZ@^t@C;4 zyLyzAFhWsMdw2b&-QGfyn!QPr;|;wW5W#$L3=cWe(iK+HWn8W^7X6byPfA@3u!k(z>8n|J&}p4nR~M-Cqp#@7+2F zsH@VIx{?GPPDp)n3|gvRojhBF2I!9mwirAtTRI36K}}?`gBGM1kRAWi`78vXJ(`-w zC@F0Oi9kaF0O9-@SJu}coj{6{X{%HZ5i<#KD$t%@IL8zWztl{5ez?67veF^x$2YJI z=vxcF6Fe6g2&Tuzd~5x}e(l@LPy8fDz)HXrt~&^Y5=|e=!-^0GK#Xq1eE8rGqa_tt z9AF?-ZIYK)Pz|u3i{@au9;M~_vc8Ip z#5s1X*cbc$`>-${>?~k{;U4S`)k>WQ3Gr)SKA`uA2XLP|iS4%-LaK&_h`?cpK|H@0 z1C_|U0VEW-xhA~u(GaRd*Pmn(KsY1yBZK|cRi04Iw?(G`#%yP2cjGDGQJ<~sef|8| zGkU0HU=J;0|46T|8qpSQz;8 z=LDvW5&sE2*qB0~+vkcso2>d`3n_ka^Tx08EFq}#{2LVd`bn7uwm*_j zp0KI~_X~_{8_*B|q@gEf$#9;Kkb`D)w7O^y(4E}H*CYI335|+i>>~*q^YYES5cC5O zLmC?H-F^J{%KQTL$ihPPgcuQkD*5N6jo*Y+4`4xYtQs2eZ(?I{ps?7lLINYWa(JHd zBF(1tnXH70uI}z%=eQogO#{wjJKE~xtSUWZEJ!lT(C`47&_H!1<2CbD6zy$nJPm~z zDF<*J%oMIVA71FG?9nqbABSsMUOTIWaOS*0XP-_;hy<(za&I!sQB+iB-B+@l?~=M4 zg@InA^yB+3YXZSFadN5;WSoxRG{gce>$JRfz>Q{s z{z6#~aHCwIehRy8d9kDk5ue#$v|gY%0r7Z(X>#uv0eHAV#~u1hTE-v@1YR5q1U7CiEoo_J-qTt2@3GjYXm=6M3=KIq+G!TK<#_rBZsuf5%*-SpCw~e?$qn#8+dC(pR}G-#fZGcE7Vmy$(3h!&`Bf3?&{j&2 z2#SHKjVNplFnTbk1u9PJxr=Ea+Ox4?ZNSW&yu6dIud2-o+Btzx_Ixq!b3Y^*o=`g+ zbqjJ{6O&YE0Qw@A!1`hp|D_DR*m%H|U zd1VEO;%#R>tgs zuhD3mcg{egVxnNC9#z)CorR*(XZK{G;U1nm+6q>VW{%d)$G)M^o;wLLDF*H)W+ z*p{|v_3rr(^GmxIE(thnzRTAP-Nl_y+&yxsNTQ+zO_R=+MU%6>{u z{{~7cO-YgmGOYPOVq-N?Fl#&HLRp6Oob%`fYvY$ez*L%?AA=iy2}tVDDysVag_;VG z1J3Rc`Sg~ne{k9ad=K5#8K?9pK-^gZGyCq-r%V(Szu@Ys=Q|zGU1gx3nVW0t9Rw4M zFTBksd|<_77=|pAAi-5GL=#9;I35Ok~m>GKw>4bpBa3A4%+Jjn&2&~ ztBE2a$Wg8!y%#|zhT?Oyt7tVO@U70()1R6GfvW`70-8Y~9v4!i67GzdO?hkwNj&)q zd7ONlC>b4dU1Lc7^O+yzs@H6Rz~Gjbhr3k{b?=ecS^ed0fOkL-0b^FO3&n3Y5OO~w zE4U)4kpJ$%6~I~P$_ajgRRR*K7Xf(yb98cffI3kKs9H1b4G>}iJhkOs6A-lB62S!y z>aB0gxm4%09ePE+OOv}=IL9pyVu+w%rv*&3%R?BT8ayf4Bo$vy-*NICGPZ<)H;7tw z^g=J@uz(7NQe5C|SO5Gd!C{NQYKbOcYabpgH&LNk;4U?_(`i^&kay83q(gxidS6~- zCc04D&opR}Roevy%gAhsrVhg!Ae4`QNLg15jAojR(kge@vSDCv56{-ad+@+_rIwU- z{Jqk!&M`Ht%ErdVxKpBHVkdie^LW5us>lyd0gUJAo_>IMr@?{&C~7GZi_i0_gB46t zK(X`hc9?dWLqAxnGub*^1f!i!!^g)r<5&mEdK_O-aX07v0w`VTp=>ht{R?!Q!2pW| zeTJ1DBQzi;c3_os{;f`O%{6pV`dyn5farC-kr6f%N|aE!8vtWFaF zK^y7khFwM(;i=X?>??QTOifEG+6Ui>0~{N9dChL&z|tfOh~;Y1==HDnL8029JpOXp zOlYRLS$1RXu{w|Pa42ACfe-B0P4f2vrp|N7o!Zg)W<#!vLk{fa6soGKfHfik;ij(s z{Ng$?h~=UPOrJavF{{AnF11#Ve`#}YtDv}lNmW-h+oZ3~fWs~G3X`nSsS^+GbThPc z&p%ySP=7fvFulnm+ds%)+WGF|X-TSM9v)}OR$6WU zb>-l^wm}hZ551>)0oMaUcN}B9yl&QWeu%Jbjam~x-<%ezr*Rm}oV9GV@41~}v3`I) zuDjrl9#0HPkA2%zE7f40{8CCVrj3_=tW{vio40KCCm|ym=5N%uOVAQY&^k4LYTC~0 zIb;{!DypFFaTqEom@L?PgW;p!0+E#a*OjyNn(MdL*K2IcEmyQS58OdU9Jj*Mh_A2aD2O**8ccmaNj5SFc3N)K7+G_^>&SHeD(|e)hukh7a+$ zPW$j}SE1g-yD$**xs{{7^|DRC?$F}GLI5bCr|!M)^Y50@VQ8v%-Ztq~9CE=#Tl7CQ zCtp#2aQVE N8lw-Pk(^$#$K+xycMTiXDW8wGIno3n>3GHpxW-5S^19P z_%jsWV`~cY3z0#N##&l*tmWHtE4(giliE9C^l!a6TkQtCP0>b&<06OLHuHA&-2vkT zW={Oht0A=K41V41Nofkp;M2R?s-$6m)2wLH(8e1zl^GKgvptGmLF;k|bAolyOc7EB z)Sc(32OKebVdB6(?%wn7mSd$Cr;_BlJ}!qlLXmp(iX+Ei0gvv|6`ZY67Ea)Hod zy}e%M*R*vrM}0s)%zqdYK<_9z?lhsv-V%W+3fR%;oV9Y?DnZk%2-j06XgY07u7tVl zco6EgdYmNQbG>DvdcY>2V~>*W>4~Zbv>E&q)czwS(&xsPcPps$+8&Ny(z>R(pUph zS+oViyKDEAHy6vclq?Q5L>hN2VD_4hn)A+wY7Oj^CO@`v0?aBK+JAU>IEtnRs$d{{ z!LYe#8nLdgb8Wt#JDa2DIR0R?b;SJaEI9|JXj+`+vW7wq0IYUYa`W;6=B%wid-nr3 zM)24Bj{0o-HS@6)kpqkMjg9H`LNF7x@0AXl!eei{xM-@qob|XFE7jZAM=rA4{#Z91 zCuMcdsq1lAkbT07d}|ZH--J)yqCm3msgy~PsCZ%q%& zxp;YN^~0PvRa8|iUk--I3KLGpVuIR#$cap5tW1kk7>H-^N~p$)iVE(TY))Ftb*G3& zqkDQprfgm^^ykj{6pr~y)|CzQ<}Va&_qdeN@9yob?eeF*bZqbKB_LH_?@Gm$lhDMt z2m#CT#Y>@qd?8ZwZg&rUQJE=7&bsUsEY(AWh#)Dc@o8G8U0o{9pjD@}MH5R;(4IXE zN9XR&sNjWGuR-$@Lxz&sQ=9D!k3-7`m%!d{~H z^|LwAvnomx1C^Dx)NJ~AEz9Y^`D|E>dQ8`bvdFd;n#z{zH&xXY)7=Kihwh~%1=*IE zv*#x)MI4jOzhYx!j|EGzdP_&vZS5p=&K9nIZ_?RXjK0Z&cG$|V5hHct*03Ahxx085 z1}YlwZ`WgNe{TGIvZS9q_fGQ2ETPMtR$9!RO$VFiu>jj)(ZVg_pAtKK!?=D`HD8ci zWTz4T#dnw^AvI_-5MWVD66U{q^B8-93A&txwQM~hs%M9qn%ZJ#(ipGL$b9m9V)A_6 z^eSb;Xm8<45AtEnJKL^^{bDT~iLL8ec7X4*INZVTjh^jU=+EP1Eiq~NR6#p^gGb*F4ziemf=YyEj3olQjt!(G*Zi3xdjS5|Z;UTriUI%V~a%jZ|`K`!$ z3QNowDeq)18xRzN0$*~}dJhKOZ`#@-`fWB)D7>rk$F$O$lI$P5xVQj#XZy_V-H%!ASU!|Z3& zEMX3D#^-a|0kgX};Z=uD4PQIWbW$Gv_iY7?4H9HyveLM~Sw3)~$lr zLQmYF+G6X1+QWmr`u#|75WQ=*Hi2b>pr1MiHOJ}k)SOlyF!r2@pocbx9x8WIMV_vg zV7PTQ1giw1)K>;s~ z+XT^vZaGt-OO<*owHCiTd=6c?Gcxh zTq_LASaMnl20b|M?FI>9LdK})54-n((NqVMHgC|_>m3y06UTm8+6r>EdYr1^Wa7A< z7#ld*6y)8}F<-q59Q4AubWNniM+^pi?f_gaQ~7QeTixF9Ry^OqI_IVL4+3`h^T7q1 zRU9OzGQJ2jRHj(in8=T&CR;0`P0h->2+J6RI($-UyhQd_IAp&xObWyt}2M+eL3b*4=0OJ(yh)i8QgAuZ)iSt6>FoA+l!s3wz-)M7Ak9 z2W$PinU^4-pj`PZdteb|)32J7j-j<1i7DI~7D%k9V7talI=xTxAu@98+u1fh7-my{ zpRw$sjPMS?zO(rHp<&h*v)h|*YCPY+@qGkFgr(7NIJX!^<8d1Z^}-|!vb?;K@gU50 zMg3Uq68`W9Nrri@%Huo zo{Ia8p8nUbXXAkaW`U*yF_ar<#8({t$~bHr*@H8ro$En!ZZA#8>M9x@I93VrxX($q7I{s*a7bl1fZ=*Qn8DieIMtb($X`MxecUreGZp?YVZ7R+j@(fHz!`jaeO*9MZGT>YzeDK_lBVx^hUJ}k>EVG+jW8_*-BK~2T!!)$p zl@j6y($~Ss!Po@kHu?bJDWLd}w#|0)*VhV4m=$9&Xps>VBI~RGc;RcCOH2MxGVINp zLkPVo*qmeSo3r>`rwkFNk{%o6_%$U(pnM}OqrQ=`f}>SO!RVD z`EhqEC_+=#fX3nzx*4w?{9Ik9ta)Hg>r02yX(8H%4>^W6BJ3vLQ$t{dd25*?iZTfF z;p!O9NnwQ)!f&O!pqyoREk>k)AJ5w^@8${^ygH$+{b$V!6UMsV7MUC}ijbIhk2#VT z!w>@s+}l6AglSS@cBfM7an6B-94=!)w0vKf#SDYT0so{-qS3$;pJbaG{ z=L7XHL?2$I4V8BF21Oc`Pj74v_vp zCbvg$L@uO6+q~@FkApB9#*itk^y=aV?QHRw*j*o93EVp8tO<@=XQ+6f&aoLnNz$W5 zzg->c9U{YjWzNoOx|KT1W+6?P^3K#tseUU*5lic3XS4D!u|FOyS*E=ssf%3S^_$gq zri|#^eCs*IlM(TUv$3q+VtyI|u675zdt;je<`g!umja)VNYL0nf*So*VDRefZB8eC z{W_N!Q&3pQ2NTK2zbaCbKYrZR-kr}&LAfP*_wK>S{U=^8Y6!n3e0g@ibbBmcn^8Fi zvCF!&d+Q@s3?V*it$RA%L>!p;wnN(}yD?)NLl8PUPpV-z?7!n5DE!j;x@I~3_wQ3; z5S-5UDbRvD@j4)%0Abo+i2riOdC1GN=mX(47(#0q7ZIQejBk(2`iuEpqla}^?vw`N zPLwPxE#v%g&dJKK$Q@VwFkB|X$gbJw(d3*Ohd52Q>q}w+j5LLyn!LyI%6T8Vp}D{y z=n{+D@4PZHGxOiw?FXL~wcZZ{KCXtcs1icHk@#t*%TtV-yCg9uXFL;QbN~L+>*^}h zFkCMPzC8i5ho3*^13f)=@7vQVY#gvMN0K5p2>y_)O@~QHN-A)1&HH$I3K5xUflwMT z3@ZqD{58;*ggMX~<|Z1M3=#zd;Eo^4Na-nmPwD8;19euxP&Q4%$;r<%3kyEo-DOA; z5+ys8vi$CSiyycnR#5w(fL#WWHZ54)dZF|P=5aq!dcv_ArHS+`j-?bpU;5|tn; z+43ez0cn$9ZAPM?JWPq5#bd}SHEDU4>P5<5HcxePwp&f}h6pjx%&k#3xgB)FgGwwb zV?i#dyxdH!>5*B6`QeDLo^oHQk*TTAhYv#`*%guZE+6?ijap9UeMDh} z)Pd|ZgVltMz>koib>i z`1BTJaDz%+-B6rB&x(!pMm9vm+QcMR62OgtYkfhn#Bkn+Z?kut{_VXQNNnG9KKX(e zk2L;kusmr5S;5fVgc;@b4U>F5x*aH#t4|2NG+y61#9rOfW|jweGX0s;(l~Jc0q@@O zQMe6)V%TTCA3mrP9a8pb^xT{-wd`GQVyg(>xYg9r(ZM(RI>fWZVdCuu=P%Ain~fz$ zsAl>@di82gcphCLGR<7wrWdo9U$Sp`TYH@khIMeGvD4D4`nUaz+IZ1|y zToMU+6=Y3sbVuAtOLO2>h!}llY>UscohI8- z-1fA1EwYh@$Z*dQ|BB|G_0W*I0)M)hnVBz?u$1#srNm)2rFE^;TbUFbwn0>9(a}l{ z*-`CA@3){O=--k>wN8GMH+NoBm6&&sm562tuAO2=1A4)GI)=G9CZh_Kpt0q&ARrXRsDAO~%O3yhWp>8OF){t@x=yISA&5 zB@(q*y|~GMi$d`Uu3kKHTzrlcsAF%G9zT{u9D(*4jorv%>Xyj3e~uToP_y0*Xi(!b zUAAmh9eyERx@cXGAKCNsCtth)XwTwe011h~TM+pb5k8p32=Y1Y&KlN13LjB0jcrh1+2*vAIU5=p$|2k9+@#t{##8pR zlL#(>_&n;&&vf;S8jgA@0lqS@KBbc^hBeTzqgk^6k1^I_emJDP_92pzgjnmC-d$8P zR}oDJHVQ=*Gu!ONW7r`IS)YKH1 zKV)H%Ztoob-rQ`aufH}1F3;QB8}S|xIQ#@5YHz}=l7r1ilC!SlrchoP`>B8y|pJsGPy67TFp=cL?&8Iqj|K4!b}B#Y-Bq+k?+ck0Q>Lpive{#nKY3 zF%({7v}oqY0IZ<~DeS9DG?v}U(=}eg>pp~Q(0#-{c`_W;M}Xp#POtw6{webHnR=kK>X1SJQmi;!WyA1KwXxA|A|i?qB9eoOnC@Xf zOJpokR!InFKg$hOwu*y+t+DmaHh(C6@+zha-VmQ%v43(S!n?nJXl1#23$*2fnE3Z? zD9@tgW}KJZmufZlYp2H>E(KAnm_r%c8x&6ccea7`Rk%_&M|^&Sy}=W?`DY>>f6o)g z3&M6qR`>i-=1K^T$K^p$OS|QP0zTB zipm(2JbeHK#YEryf|5sY3Z>exR(=U}D}HWqF|dhElW6H>&r*HSbh$%~$LZ`@D4ORP zn+L$s$?!&ni1M6(vykhZxQm<(ik$;CeDm`j^{u714_LgR+Oh1s*_T3c(x8Y%HO4<-LBk3!t6f`v{A+SAzRT&imUZ*peQ3w7IA4f6VhdMl@ij*EG6j?P=2nLh* z>dv1}fxp?AFTeYjAOGv0$uaQQ{`q=?1NZTtzrIC-EX04l?BlTL{`p(fgxmjo`2UOB zB3B7Q=k&-oOXbWq8~xrJosxfT6+Nz_bMm-<{MJ>iSt}JnbYEKrvUt$STM`U*oMan9&A7Fy(78brYMBY|CH z>u+=8I-7``6tt%P_emvJ{qQ&7k5v^$zVnSsHWC83e&dNg`c4$8Qv$hO2zPwp_b7H; zm`R3XE^8=$>EFJA>2L1`Nj-4bFmoJ)lirsi)_4`_7NSZ@J-I>^N9Uro`AgkwI0AhwniL_rcaR<~$3?jr z0htkbPM47*8G3$z#5Duw(C;r)ko{|w89X~Wb66ZtQKv+KIR{mcb22FpnZ%Hr7_N60 ze}o!hKafo!U+e$t*gq@O;KjclD=I1}*)(FRv~2UpHnA zdEW4NB#+;sqo;>5Bu>VJx`xK$Ee-gs>wT+bl2S0yhJ(iMj-09#?w=)v`_}{C0licL zLPDt2!ok=nAoB$Q027i?R9+@~Nnj2`-61R|-=dg2x)%y}|6J+azs}YfuJjxv4S_)E zt$OLTX_bKE3IWFMOaq-A;VEc1(bI-{@yGG*L`-f!6^-?80{H%?$oS&O3>fo^F?=p0> zZd|Jw|5|-H+(-8hMJK|AIwAfkBO_>WF}-S5H}IyoJRFaD!NsL8ySV5J-7n>}{@+*Z zCGqEqEq`Be<u1Cz0QGVkHC@ zS%}2>MSp@UYhOq$Tdy8x4@ELqo!V9HH5hpJerDck13*+LS8s0^!ctFV*_>&=v9SU9 z$PLEf*x#oj@aN*@{<^r3jK40fuA{4qd;Zxl5>UP;B+PQbIG;qG?im$O%7EJDi{8@W z;^JONHlplrc+Ek0*d*&c- z`?V?jNZ==u1q1|~LF^FmYgmE+!=mBIC@EonW;~ZrQsN*}VS99uax;2&kDfqWCdps> zwQ>UTk?=QAUJ!-yp7##k6moJ?*>K;$ydY1}#2L!#5bKaWVmaFLx8mR8mWdxND`NUT z_fG8iEv;`CSk=fN719|l%_hW5X(5|fD;>-t@+qL@K}a|>B!qN%7Zw(v^L}(FdrzZh z_V35)aqF)Y`UuX5&jb{)eUK`hg~f`u%Rq&TiMLWMsDq?l;UC#Cs7Z!)k) zkjz2>Pcik)(T=n9Gr7upbdg%=Z~uzLyI{jGAZQBsK;$w%;jGcHuWJn-$Js_6tW?Tz zoOzI~$1f5305Aumr%!#Rrl#(tbo%oCewyI{dUr#9+m2?%qbCmD*B#rn^5a*~kAON# z6>S-O4$7dUwY3!$N1e%?PYu+_-iJgB@}Lo5RI`=cluJsG@?7Dag4Z~Kdq;s)zp8X@B1 z+#Jcv_Wk>J`Uoj;C|819kSP7z=dNtD8t)QbI0a-eXA{2*aKV&8nN=jkJ zq_KW~xI%;H>TgfbeEH8chG4tG@&EpMgLC@IzyA(?cKr-X{y+Twe?rRtxh?-;S^VEk zK;#lmRO(4yJ}8@bL$fP z(TY@B4jG&-2W0}8BTLX;*>>b`l9oI%{|-(I(>BpI6M;k%%=4GoIN1X^n6AE5kU z*7}1rk-xnY`GKziK15z4=HV$VwFmD|WRmjo^kCwklc8`_?fu&UWVQP{lW*@`PRPjt z3Q+DVlrr3Nz4uU<2R@y=%7;h);gGvf#D^P>lznD%8C)x8kdsONbnic$OM}?cr#CKK ze?lfErs9Z|m}3J9sSlF4F#o(SRPRF>knr&Y`!%qsgx{MK7A{A=?eB%gvbF3y!qu#@duG-*%G*mA_AK|&S$DM!uw4;AlUVc)T zNq_$ILb+?tlS?RoLZ3WJdH(*!kbCB|T36xRyuGioNQ>W4PDV1UzrWahf*Z5pf3KY& zAcT{vc&wq3{?GKE{p9*K79RLnKt1Te_{D2T={b zfB!!eYQjB6#1&Gf24+`LdSn|M`^S4$L1cmpZeK;*8|CA-us~E_{-3APaH-5CE)y2l z$<&V@A04I8fBPW1p!GwyZ^@9PuG7Um`S1RJ^$eXh$={AW`eEne7C!Md|E<$;Z*Z`R zQU6$Bw}fX8j-M>YlRI{G;2QLYqpsDJJd6zc!QQ%q3AvX-zp^~ZWXBU+9)UWkhOvX@z#NkP{_E@0*@r5q|Ha;$ht;_D?c)m@dmA@N zl*$}MiDnIKDN}_sPZXtAgJx8#HrtRUQ$-^c%`KJY$Pd)@bSU)N_m&-1g-;7)LG{2vs?&71*l9st~YFuQpN)AJ^j{Qsb` znlKseY7E4U?5Kn9AG@{KmRM082dOTt|MNoR&yM@uc2cy5NBgoMomjO=216P#$;k(i z`{d*#rpzrDJ@@YZ1KG(@M6$I(m?X4%XwY3D_=J1MH7-af4Qn-KWmq+D1x0*Iw(}sL zG$ut|9O?w-ifKIZHCSI;Gbi1uSrLrIIJtom{^3mu3I$DFQ{!v{KoFmKPsN<5FJ_G^ ze7urioRyUY#DiH;db2eLp2rK%1KsV4lz!({M>T+ZzTUjqKx6islsyRE^W}lm(C0N` z=bxD-8H3_`OGTn_b0n?F+)NR@OaCuO7m`bbd^yTDmhncQ(+3WlNpTs(gf35aD})!0^i-$%CYC$?d|qo{Xy()%2Nzj;onuXS z1hqct1l=%i(fCdg5x>EFrm%I^xnr_ucxnVG_{VWA<&h|hvB~LAk9Xk3bDLQ{uTm}U7eNC9^(~mB1Dw9*jVT8^ z?Q1dQa-1pX^nU-s;w^#ttCn($y~(Z=&gokx*)~yK5G-MJ_o7vuk-J;Dei6jEIp-}{ z7kVOmS4Zr{?oekTUiXnI<)RhJ>-Y?^E!V}KihJc2C~Uw@&y$c&${U-S==2g1Y;h(V z7b}|6RNtOzHVC?ZdRar=yu`Lz!B znlLJz_u+|PBue@hULNH1-TTpl1#QbfauUF_N%FtWKB!m|uO;Ht+@C?PI3@O#ay&Qq zbO9qm=fE0NV$)nbCBOW~6(cmSo#{6{IgAJtC;3RF1^V)xQTJ5658pHNwMuWyR!!jf z>99T$UP1Z&*@CRzI3bh5oAHr2j6$*{ml=s%-Jz1(fZKn{8S>cGKQa2!u&GW)nsw{D z`DQ+M@7?2Qw#u*qxxKn7$zbo&!@GonYK!-AgazWv9=Q|-0zc1y%^l!jT6utrYWJ%R@;STe zbbrkTk$`m#67qZLCaz8<;}e$pe&IChKECkKdQtKZ5eM41)1nsg=PJ6;)HMZtDVr3AUtgI4c6+&78FwtI8bdYeYKmT~%f%xD9C|)%NjB8fT+Qz; zyCv+>mqzrd%(`%(>l3)0zQ1*U8?#da^XML-5oxcxO!Hg2GGPOM$53SxJtfY3-vKmOB^T*P9q{k0ugKlCn%M!U>r8 zhnLP-*vB8K+uH-bNbw`c;W+pTI#N#SJBPYh1bwr)|LXo@mJ$+gSS?wI4#5e8fg%Tg;6s^&-OujS% zXpBU&Wp#pmU*5Q3Rf0|`;3$u;*FA#!la_wSe+8wULZGf6?@#**Bz9R02<#_$jJ2YjY6nP+@F}X%AYjFH1qlixHAif zUr*W{%D&Ty&py3K7ajfBy_iU*>blzemRe2#^} zAHABWbeBKeGB#AA-U(!c=gphAo;>0NX>JM22gyb#T!QzpUCBFB=>YY(OsMLN>JFU< z>9iOO_NvNvV|q`K#hgFii=zaH^hr({+d9wj7`{DmZSz(&njHC!Z<7Fu^faK{4?Q0{ ztXN?dcBwdp@&X*7Vh|1EKct1&y?g69#{<;G{r<%QlQYc0u4=XD;xEBA>H2x74w9Yn zrm~V=)wY~Zb{oT0%W!f$6Ttg`cywxuppC*^9j)ev-no!%%l?JDt0I*Q^h|y|+VSLQ z=VF5=otkK`H05jCoxO91*AxdswV{lE=7OS_`G?K6da#k}+(ty9=3wSuF${cZF+cm_ zDT3IqdbIzlcYk@1xEFRy(Wkd``uj^yPsTPlRP$9umTVn@o=7nwmIvbhP#w*io*#A? zXpttH7`GNU zns5U=g3VQBqQ}jm8XpAZZg8 z{3sxdetjuVaWM~jq=MR#dBSz5C~#=9+bAKxSzS@l(9qDKC&hNvsvW+NDNS$c!u-IG zg;T(U2WcyOf1{w(RqT^WWBV%16k^D`pP<}8LvmNAk7S#7&Or7a(U?@&`sDF8m!jdy zg5!+wianLG_g4G^B-5J>3riH((oWa96Rii;V^3LTeGc$!Mu$G%`&)t0xXZ^E3d)SS z#69W#*;KkW(%6WXG1Hm$4#1Qf2ez?Yu83C7j z&216Lu7tqCxBVK6C>W(}G&UIO+_wGt5@`{M#ypSKCl)Zbb z&4ungYzI#kJ5OH&EhO?{h0L zqm7h?m6ij#GMxD};%FDn9gbsk*yY`IMg^^QOqEa>@dZoPXm{9>7!Y7ICroe>-4qfB z@EG5vPgQHgd$YmOzI7AB-DsGwZWquz2rQ6~mvDB}2XS6ZK z4u7#rJ0+Obmsvooh>zbIMu+Jq8HL+e50@2ATFdMWUS{-XPJb7QUsW?scbDTx)XUIK zlCb*}Lrg^k2?AcuKhTv?(4X2zWH^dc_x7Lcw{N$~Xy00Abg`qTaLQ=rd*Gr1ca1;T z%*N<&RUe$e-NFUaHlEE3m&#@T4RMKCJWRre&2eW{W==7%-9!BLrFj$zR`_+GFh+oO2tneX)fHN?9~u`KD(3 z68~f4zTiGaS^rEa*zI`m%@7T#GZkv~PMPO46X}VVFI<&wW%r``)wRW4z>&v}g+La` zl$0A0riUh5AsvXT(f ze(TUW_sIam}2F4gP9i(6xiilO6W({hUbou$I>Dx4?bMi+c zjJL%_i#7FFPhYXH=Rp*LWAw(!OXxMeI~5(Iuk3fG#2tc51qp#Z{6+a)$w7xs#;xl0*y6AyewkK zKSYfS-yq#}aUWgsJa$B9W^&p+7J+$fuNn+YyhBJ_pSpqgs4H>I2)Yyf$DZO_k{L;P zT@TH+7LvViPi=+@scf@Wch+e0xqtGko}VabKvZwsl9_y>U?y&|{Te3QpRIvpXbFk} zs$b3TCA1gd+$X*tx0#+0=mgH}&=S=?_#u?tVgBdyNl|q+wEi&r>;z zgz*(CSX2F>+H3@gEH4m*k8Mf7JwREFwMK60dc&peP_3yd039HrQMirl1Hm2xyNi>{hX>iYEfVgt@pCw9)P!}qx zotg3BcI;miUINyV#!Kk|ZGnN%FRFuDk_mvmRv5OR|2*K zKdK#$rmf;T5e%z^w??Xk%V}P$uL){E%_r=YL3SXZt7d#^13%!@o_fo zavThVnaEb`u65^O^RY$ln@)!3jWtCd=|WP^?LP5(MXih}K*=TrtTBTvZfoVI;~HR( zlDp9@qwC+JRpvj;tUEWeNTPv z(=fV;@7h1-Eu6_}8hjECcXYXS*_^(5n?a4$1ao4T-5yJ7j8|l-*)Luv_~Bl5xBne z=V~x@_3Vm&Fd@x-46f@nMnDI-ySWxI*kC^1hjW>I-cw2W$)L4zlpoR zgy}Wut*JB~XyFIw$WjzJNpVGbM=oLDHA>U#o-+}MnQfG7-)8_J^ChIwR<;oTRd^*<-ZS5z_y!70u-gwVZ z7_o%+uC@Jcii(Kq?y)6;CRM%%}lTr_<%INAiTajo? z@Q*pGF!kx*e^CpjbSVkRGG7uO+G|z8Xa;JBOWGKQp2s}TOH24^Do9=ON-ap@;=R`X zC`tvUeeIxwAdu)eGGEHT-EE8#Pa=)$%L`0(q;#;q)z##-sLJ$+c%IRW4yWn1xQ(;j z2S{=9+slRLLfIWR_&pJN=~=VEQ51J-IK#9^Ji}(W^Up>Ln&4nWhuB$=bhFd~w$~Sj zz8IB6-NY_ZzamDVGaZ;(0s-eiZegU!KwQ+~GT2%banQ?~#UpO9N||pm8j%Hiv~AKK z@=e7J$xL7!I+ty6frAd){){^+2~o@1mou$&t|}W7?RmXT6r!ZEEE0npx~M zP^8aabzdB<-PDQl^bRHq#^OywcL~3{cdK(H-}>xk>jIx(>+EDzIU{H`=yWp0I-@TX zp^PmLXmeog)M)cwtfrvoeTQJHblxpL%G<~<<*G``|O`FL}p(u2?|NEAqJhMJzKId7nMS$*} z?xcufA0C0a0+y3zVz?gm3`x*Ruc3-Z-k|8|7vZVC%yJ})9VUFFuz+fEq1Ef_2CKCU z#9ehJj0)A}n4?az&YjKF@HFAZ%Y$#x8Dst-ur7Q}5a$X}i<-3A%b`D3h-wo*duH6e zR<|U%WZAI<5rb^8sZmCOKB;4#Idf*6Z6SXb>|;`$Evh$H3a7@~D}9g(@8nWly#i^7 zB&-Xw(7AofjjOCtdi2Ha>2QQY4b~6=|N8!=sQHId&ojmf3IZGg`MIx3q~XX?E5K`+ z7qFP^brX|}okke-3Jh`m75BF0tyW$)RPy=CUg&5nS$9#tEXaRa_}yyVOo>>}?s!DB z$?&i%$K8>xbpi`+C_F8)u`{bX%x~T@DJcL%b{Btw!ufoz5TiuLFR&~K&bFgk6Hcf* zUC2Lt%)(h(Y0TQ(OR0sd9=l~e@P{;8;H`nQkNX?23T`%>$!X;bEHvjYNT-!L+aNzI`Aju_v6aq z=ijeSo`JAK%~Vz%!gVNRsjticH=_m*uiBZ8FSoqrcL^oo&2?s#VH~J}9jdd%rrTE+ z)}J#t7{GEV-I;Ny#S9g6l#VhJhL`hR-VYFr<|STqB#tI1F#8HNDkvy0GVLpQpXMA@qOWlohGZs^@~k7PP7Jc;${1Sb;;Df8lqM-V+t!>= zRzl4%r2q3x5NRq6X)l4^J@Ca=uRGAnbxq2|s^$rWH!^T8t@hwD&{A$|ljF^6p#~ z07lf$%yHIuyFi`2y*;B$E?`6jwFGmYk^F;RaSJz)*XXAZwh}>a%(uM|5oAGMqM-I` zHdW8@0OcC{r}`Ye%IiE%ht|2&8#rr8IioxxJ6cn%VTh9UmK$pgE`8SE z^ewFn=SOY8$=^2_#jnkE1#Fr%s`t9rOxlq(KG-85{Q}27!_dr7gw*(<=H(vO8Bc#& zvNNM4`(*d+e#0#61i^Kv5)780z^9qLke)BIebJ)TX02g6&kE~FwKYW`L`h6<60|Kq z<#7WtZz0)=S}ngrOO`+nj0R;i4fD{97CUNLc)@A^ksUn`u3huOTGl^4@I5C#At9ln z#RPG{+Zb_+8pI~y{dvPj3AFC@GUkE4^kzQ1?Rmy**794t&V583)Ur-g&uTO{VNPbe zNvq?2jsLi~(WGRWU~_9zkfg4geSHeHj*YRlDszn2VlwIQUC;^3y+y#*q@K7_+b+;~Zw-u=g&LpLld+96Fv}FgyZd4%q4b zzHC(LJfWAZnQfHrkz!sgAAgdrz;du4eU-@kw_97|-CBL+oKA;a`m#2jl`qSlUL5Zr zQuELB^d#Y1C$;dOA7z;3X_;0`Km9ON{nU*Q0e;7k(AW33s|&V-p&o!uO1fU&_C~?A zHN&-eF5ibG-~s&kB4c8l0rA$ zi|2vma+_`YjM$g$mhd>~_$1^cO-R2Ote@jvFl{iw|C4h_a6xl5Ay!6Zr|zciBuuz4 zoAo~$@!e^-_>u8Rl$GO09-vfrG$GL6|J*-+CdR-=Z>=jpjB^A+P8~NiWQfBL(iYjK zt^Z^woFI7r_qKWRr5EMDBdtPM)BF#Nhr2&3_z$QD{#9*pL`BYs$NIs#v8hHgjm6v{d&h5B zgtF=FiRk;X1Q|N;WJ-wSJUL2@8u!o3A>Gbv z?X8Gsyin@iY#qNt(McmINBE(bxiRUHDdizK`Ss|_#F5<@r!=_3TPpW+Z*WckrV<5u zj>4DL_g;bp)XY{pbEZR3&J-4FCF9`1cwtxM%w=lEGNDN)Uwm|D>!m$P!03T3b%DY= z<3CKKq{Q{t!_yT`CFp1@HZ5EI5qc4NB4I{g;rWngiL`Ft-QNy(>KMP{Pn#~Ey#jS; zU7UI{B*e3C&55*q&(k)9#AjRdPU<4bUpdZ1{adq_?`wl?q+o9MQOzOe$jz|T0rBS+ z8~EIum>w2Mab};DMe^%m-2V$xqdvT!fFlucwcGO+M^DruXbL%{b4h*-3-DJ;}iC zeLEc7vx;0ps3%@UeeFs@b2m~Ju*Ks0C)(WtT`MKcPo`1co0iut*7OUyoDUa)>g_OZ zMwJo|O5d(0dTepR!n7+_@5_|oTrK?e_s3KO=s1qJ6NJcxnea*p?=P*p^eKGhYr{<& z5m?sZXl^kWzjEau&Sdyi8x&t($?2w@u7ZG{peO|yS7)!bv%x8y-yePZ8S|y>ObQM6 z9M`Z9C{7B7Jey{JNth=;{q6NO5jDdW5DmpeL!t$j;i1mGbSjvR)uIp3R>EA96f(v@X7xfe;8*C4KkBXTCh#Nj=yg;)^>ddk_MwL zIX%#qm3Hd=X{1<)!dM8B*T901%vXaf_GEZ-@;6*Ggu)70T^k(fJeY=?wT zw@afu@;9S+twL-_Z9_IA?(n^>#UvH2`SrAAN0HO^ys@ats;_0@`$T&D*=%c*ZlyBe zB+NV8roGMvRiD0;QsK4DXsxU<3$fMJcWzx;8)Ofq_gi?LE!M#@qp31(v5OZiia=hv zc9i4-ZI`-R7nPuI?$#;dE-e}5c5OizDp$*Q(X3keA!-D&jGx8@JF<4GFKqj@qRiYEFOaUU_!$Kl-(Kem5t2<9n*$JiRZY_Z~NvaVjH8hn*A~ z>sWtIVIj-0_xVstyII9_Q|Zqzt-1{WB-A;K3VczHzA>zIIZSnVw=&Cf($T^SnTbxF z6*MqN`gy)LhL(V|5w83(SPa;_zRID^?(HJy}B!e>j|X_U&E3eZ<>RXQK``ly74`h6b{j&S>!~v1bb6 zzYj6TTKAyLU6as^NxfQT19StYo`>3)XX4~DI#a79u!WtcQ9xU==7K|!`%%Z<2jU(h zI6bk+j`fcBwyHq3KW7iJS=C=d`DH87SmETB6P3(n*;^}4-trk7tIVqU>Y3$eZGbG| z7WDGB+3F#B?j)r|xUN`-FaQoD2l8F?3G~92iE7)LdjbW9(expdi|%W;Tc)a*AQuIO zf!UwoRW8viX4{NJ_*q{&>Q0wBE_gyB8Hq}^o89`MS(jFf) zQ$*O0Ri7Atdb47P**>WC*@Oh@1nX32g)I2eHOMWD3$0{8qgvgfA?8A(&s`Y-B+ar)hWre52+*$Wn^p* zM!@hdyCP{^SgXUFJc`_NC8TYwjMp0%s%=0()TV>Q&l_(ON}lOUWydAAwYv0I3i zrI)gV!EjP7YL}Hk(`IFc!sMrW;_1J(=Sf$fE=ECAL4sFiQ=N;Ot$#@Z99DzF8*luy zpdtxY|JJ-G%j8CGLnW)bi-16`E|b}X)&MUGBgqgRDu%irmq^}-tG?T4dn6(<&n^U+ zNJ|&@PLJlHOJWRo0y=Qnj0&ld_Xl?Ox-kdVlA^7s_xYLWS)f@djU+i|m&$TW+bJRq zKe2W+o<8F;Jy|&~F3lHJ$I@(Hbj#7}V+#=}vdo^RJm^)F6fx*(q+O%cPdm5xIo6m8 z9cuX?aVk2z2!GLWH_>^$5;?>&k%!z)%~tF~3H8_}z<|V}U)g?E|Medn#meIYt&F~) z0AUqcv9DC;xAToDMcnpQ7M+t*+bIq^?wkpy$IrG-PH+L2b3@~P^^bc%TULSw;itm+JaO9Q;UQQZ_3LJG-2;TJ5g`G z1rZJbUmBQYRQZg;Og^XPdp#-Z_){<~Wu_X7VDLc?P zdl&DJimgMC?kb-)oi(C4nH3@=BNVm!lt!R<=USuTqey|a1b7gm{z;V7x1#Uv=98I= ze98#4|3B7_Vuz7Ud38Z352nI6-uL%ESmMib5Z#Z|iqS$2jsT_`vJCW8I85kyQ>J~v zX!ejs5;4^%c4mPbg${w~QjLT|h_na}EX20B=Pm!2sZo>Ea*1;8Rhk;;J1T>>Zxpcj z{FDwUN5jGtqZc#F_Ij#P@3mAyTj6BvC;g8kCxOYmTu}3jlpsj65KnY38E`uh7@V*P zY*dmvi6K{eMb*|S3lbMfZbRrv%8f*22X#)Um_#&C>2AG!G6*QMkw136T*#KR)r0Bt z%6A+0M%zN!+}s?FNf2E`|1}r6S73Syy|5-Wy!J=|N!lwy1|v`iEv?1qmFWDli$9_& zuM6or1p@IZlW~T_!I)>cNc=YW_Hlo$3>{T`c@*?hDq6G=ckTH zU=w2uiB6#nE%lsbPQL`_b+$_Al#cfrTNS0e6h28H9Ejs?xY*?rxR|X6N1s-QF1K@G zOP0bZ7ZyGDyM_O_N_6#5Mu9;9YqI&o8jCQo2s6>{UbGLA-D=D(jA8E}76?4kfn z(HhBtfg{}&rKRy=(}PHyy|s)&Pj&rg$Vb8k6b0|B+5Kr+W93|J)FnxE&lagr(g`^@ zP*jX>6k+S;yt0QR*7^lYChR-Y;guujUV?JX4=lh$w?c`gO9k#Ox@m(4FnS6EJRvy9 zp6Kf8a%YeKZNY1T9$(k1#Uh3sE>D30v(D+~C*@PV7rNg+-Q(y4@F{656B&21YEm}} zsLr8F1rm}F&ZDoTd*=7k=a9+F>M&lpQk2$07g)()4zihoxh8`?j}M4wpWBexRidy$ znOB@pS3Ko}E$g(W7zWj|YR2^uJ*R_N>8 zcFFnLRWEd7UN_hj%zxpRX`lRce8u&XOm@Fv9=F;o2 z4G`X`*I|$DFn@fmP5QTNY_UazulfRQITlVuTguCBxL{tDBFurdF=Xdl)ZX|!Zlu&?wO9xBYxtSN6InxZSZStyc)n*2MLM| z(t68FG_;obkuujve~z;^KU+~z5iNJCYf~(2OyllQm!fYS^@_BaX~3P4rvTk^VWhuV z1ZE_?PC>2lN;4klnXa$ai3XE{(0H{0B;<9(qxDR=QGyy%bC)4e*uyuKYWV_RD#Hto zguV19by?taGYRAgc;PMb1&1rEdjqWU{PRrEz0K}R5i+hfaOWZ4b?tHZ3WfPMmZ9o{ zcp_XV*Nir!RC6EAkg^z3D<(B9|6+)Fy9|{m@Nwq3uq7~_LCd^g(?-%kLm|Nl$TDe^ z!kNWXx{6_s<5`BgW4>Df0HXo~^SQeh4V0&KE(&m%RT(w~@F7|z2y}GDh0p;cb&I1S zD&F#LPJXss1979j0-Fy?qieLYeu1_m(7Ku&bt~a-J(CP_lz_7)AoDv%J{LYW4=-U3 zx*vb?A}4J>e4h!)D10QEcJ2e;wEbjk$OO#h5Twrm zd{5OnpN~F{F!K3;6Yd}Xz9ASo3={ym3*%)m?GDvDYZo20fTOfbF8Y!TumE~d0YFZ! zYNzcLBG>0MMiEb^b2qM_D zt4O#G1H1>>NAFo1q;k2eF&^+1a`ah0+DU^suLi%8=f!hHg2#+g{d!^`hh2rii_vLnAFB*U)iG+*26Ij0s4JBUkw#q^#RYLV7|1Agt zQ$oU~O<%>s@jhjr_KiO%{{+ETl~$n%=sgAGGY_(xzv^sv>WcXRjO~pxcg}Jz%mIPP zeb9eY!2^6VfdH=^B{+>^VbfDGjrT|pK;pVc8eT>*`U=@~WBE^i4#19U;oBUwjx44; z`hvm2_Y0@ck^7S%kbrU$GihF}XSo8|%pv0mqZ23Yd{a2>L+V?}z)yHj-QLihm?|ed zn7e;%8X6^;qjk-7y>0b1**}%xDZSitJSfMnLIpk`p?R#u-VbDr+KWTb@WGqEB@lLT zhoMWqKSt~_BQRCK(HoBzSkQZ@DmU9%c{r-$w2RNNcnRW)ak$%5;)pCi0(WxQ#8+u|dGop` z_hoi1E3GA@$iV`mvWLU1GF4Z}H%<)sDS_Q#H;P5~tyW`)r=^dlrTvtfcx0QlN7^NHifA3c1*Edbb|;pLvm^K}W0cBIf+jB)9i zsv{q-am|qCjb73i8bgIx3jlO4C-%Ep{TwVkN&>o`k#v7GWncMTW}m&u`$sQ_SmU+K z1GxY?Vtwlx9P8T`vFFukgK;5x;p5QSzimSII^ROZO-nRlla}9#D;aouYj&OfXtDfd ze0f{_mtWu4tq=%#1yJWD1S3$1|Ks>V2juR{q&|)l&ryXD;sjj_I5pZko@lE~%rLV= z5tlsJeeQ@ik8{TD&8HL5=Gf@3D}OIHA;B_K-x)>Ks&N;qk$mBwee{}txb{}!$43~zvHwD`~aA55&hv%FA?9$3`{~xLlt1gvoI7{Y}vHa~}`K z2l$>6|NE<`|KT!}hYH}dX_s(vF-`vc0_1NNSIYSJ;YD|S=y|Sq`j>y#F2|6QO`)pP z-x9j86_D&i5%HLR|MiUf?bMu&_~#YCfvm(BFYaHKgA%_VH;cCD`QK3Ko;ZBw%;T;9 zei8ibg~j@=g%b}cM3V*Ws)(p4P*Q*Xb(#}eO)o=YM_dguhbU zJd6-z?jY({RT1<5(>=Y2IJg)@0qSqhyn%6-CZwpdhaW5b{FRg!N3>I-b|G*So2%`M z+dF^=2dKIazw%qP=M)tkJfWSby~$(AT*}{y!2kGuIQjPFFPA*d3-#UPi^<;<){_T5 zap{r_`ipx5wC1`c>jB1J9}gKrAb4+ec5W>H<-#6|pdpX>X=?wmydiCIvTJ_=}d>Q4@J=b!`w%LixzX0U}?mD zT~^u|EafYb!!?CTPwn9qQ%pduugZ& zmLhD^%@fPDx8qa(DP-kO@>pI`>; zy;X8znd-jZ{$9UA@wa5xWTDlko{bysx?*ro+t{-3NFhB>v4}5u^Y5O;HxEtd-sCR2 zRY-b?8&2jPkjiATI@Dfex%lVgb&XBB%DTRBGj%c@O9j=O{_s-eEzVMt$?uvLNHIzaXM?(t?1;Gioe}CH2v*oL<)s+-V&dQeD0uuH7b7R zA8-8m-CgZ8izgB8`$E+zf5t@fKIA-cGUYF`Ulxh-f*tsN{;xl#9A~fkk9=0K85KS)E&2k+xW0!YsgogZ zEOKt=EJ7PRGV1I#)!6y7;x*5N?=G$Vp$_&e>F)B{ANpYAr*nEDaICx0DI^S1a5uzIzrf35fowq zBSMWjz%B%VcI|s{JlgOXJpsTNGo}U+`z8cxDaI+Uqz`6#84*aAsM@?+Bto` zHckV)qQwx%uyHu`<_7-F(X$( zH?T+fhyNvHb-U+ZLRRZj-`@D4O7-R4CtOeu7px5ktlx@Uhc3Ca#mo^f=7Z7I`vC3 zC=Gvvp`PB@{ZN7NTt=Mo?vtqGDk`FutE8xiS+~zYr^iT&#!q1lKQ6UR4bt4R|1;S2 z(5$@G*`#Cdu6s;9#-@BSJ-w{z$iNA9$X6K@^YeNL^nb_7Y>FhMs> z5NEG$b6eyO>9QZMWS{B1*`0$czQGkEam83rbOQJ8R|k@njZ&2CLlPvOUW$N<&vrQ0L8*uF+szK_W1WQKf&ygl*QPW^Z-E!ekYFBF ziQ;k;P8^vNS@*PC)LYEpFTGvgEU$5}ZQKC4be-U|7zyS3CyacgjL`vkG`yq8Kk zTv>phKY9uP#66;tz#0dP%m+`GWIy;rlkd3w>@M~w+dBI)-sr19+w#MKIs4W>St{{$ zC0^lYr(NXYF=67V_faV(TZhr&^5YoQ`2KtYSep^J%|jou?7Pt#s_iX6!6JS()Uh~U ze(cG)Qq3y-lcQp?ZBz!gqV{@f-2i-hg2#WuiV!hFcgKFW0>qyFsK zh0%7XE`X?B|26Q3oaM8zvj@n1VqcMH@^)5Tt>y2BMCMcewp+BQ&WJVm>eVZ|y|eR& zYL4NQlUN;(=swnO8gcTKG);La-OS-8)Y26KCoLzcBV%Yx=@0FX(;83uJkPC zDt|Zo>UzWeeIe_!aY~lHuA@w__x{cc;G}6h+v)D?3Jjo?51BR`1Tkve4#-frs!{*- z2+L;=`i`9=AJCxIQ_sjXdM=acq-!=XOy|_bmOFKIs4ZV{m&rGa|{y z_OWz_w>SQe56S=hT|58lyFk!UAJ{zmUF^CtQ|{NhXN@oA!%zD;UKBpzHO}{LZFFjg z`&K3&io!I86U5*AFk}|BaW4b@b)$wqf9H05XBeLEB{p5wRD0NavcKQ&sw+FsZP)hW zrH%a1^gAmt>sa@Fv3O(o`}vEDD1R-WRz+M>CEs|SLf}m7*4t}+ewa1ZPv?ajFH=l* zK4rsIaZ~{X$Y;bnh|6VLzwuW3vG%l|?+wP{$GxSb+MgUC29%!|Gx2sCzqU^kl4qy?+ZGNA7)ecr3oAUOI!E#4+r$$i*>hhpD8j2 za_>L=`oBp3|CZ&aErGv!{4eE&i1hz;aZYIvj=RwB-cdhBL=wiD2OrtT+yMghfoRuf zxY5tO>16)K$otowsG-*F$LID%`oCS<2&tNtglC$SUT^5K(^sbVyby5`Dt`q_ToMIn z3FQLTThXB>qGw+H#;fS_<|J;v_sCaxqM!)$wOk7DpFcLNOcdj~g^*Q{J2+08ub3P(i=&THBqaioAa# zka=fZS)*EDZv>M&Co%j9O6_~}H&7)`C4_kZ{P#ncB?pyS!?G~eAjoQe?Eo`mXDKqH zPIL@zo_Rgy-PhN*ZToi5KqHzo*5m8e92I`K%crJ$wF+)&Ch9_-&Ya+pNXNNwdUBXy z$%i(>0q-@UvaxQT-rO@mYbT#PgJ7skA`y6sAMO#>Nr_S&O9hI8jh=UQ)EGuLZKg4s zz1%#a47Tzet=B}FV%%(XqwwR)s~{-K6Lf@qH;r5U=`l|XvO2g>r#m-#nG zs8OR!p;wv)Eceb>kLGv&LYT}eA6J_jEKXgmz-+9%+e9Q`2n9%!-I_+NkBX+(rvaEF z0SxD+jS={Y(Y8S&*P(+*&=PC&%hi~%Nzcz$Z3H*{&v^@$+yv`ZiBKBBofCDRnSQXR z`!!K95*_ex%pKJ?%FE4Xm|7bn1&1lkD~7`~zMKDGC0!#x~WT0-??ROIhsD zb3Jgs!JGp<7g=Qy+S*^P#+Z}`OZtJvAq7NTDNu&?1fPf=wB~a5YyN>-RC=DKvb6jS z8R>ympm^fS7Ft9;dbGYN!zR3Drh0fe0ymw6wA+OLU^OmReSz2=ctxdAf2r$ErEEQa#(Tysfd{vYbDLv{6YX#_y`|*sYJv2 zMT=yTDjs%Mur`}w1XeD9b5vFv;{&o{6M$!inyB=mfBx_*1}#m~dIG>8E|J`Cf8`Dp zALIFxayqm+wt5OuSUF0Nz>1{SFC|Syq9#!&5MJg3oSYQU2L0ThWqVo_;*XLbXlMGG zRV?PXALsxF55K*IN!Ib8<9CWbgo&lPa}qZ!>C^kOlQc{9LCez zL$%`R2g}HmvN&OuRHXy^DMoXV2B=STvfbx*=zpp)w(QYsZdd+sOm%qouD;*1;LS1C z)Tkb75b@`^LKpd|uaRB25 zMjcLRpjftDG%*DbAbE&bF2;9sW(+{zT5-6ajp;iC24P{xQgHa1G=zTp)-(}J1p_bc zdBvCEuaO+aH+Y>6S@4TnI2;R;ZRDCmc@)5XK%#op@MCDm6b0y6>vUI#ic>W~9vPs@ zg`#gOkC~+JqQ(I862x3ZuKYDo22Xj9))#xsnMY=?k+*e9sseOZE?^N4%6@xsRi;d) zJ?dEaj2{8yl%^3@5-#lT@R}KHQ{fGBtYFUF9b&I4JN4B>Jj^*<-mR0LeTX)p2o?@R z$J`72Kt{TZrz6X{`Z{D~uf1-2P?wV5XYnW(meBZe4U^A+_IDtaOATSOA z(OS}wTtKg#-;LA`vPoKn*kD>o=Zy{yhrqxA0@ZH|T!p4o8+66)q-^`1X4QoL7ZGh^tsT;{-~?w@}*4jum}cPC3Ow@IxwTp7d&Yp z^W5#~PYZ2Jc*6r|6AU9Zx1QyR$-zc64gUmE-NGkY&Zdovb6K#ItuaPk_J{|yO$?0} zqEvmi2@UBcXdMOjMk!d_y~8?%auykl>nk_jvIm^nljo8~2#zA0Eh`E*f!?{QJMauU z@tO)Q8*rgA!mYrbTt5F#4AKH$u`FsX31QJrd{n#qIfW9ZhH@56lNZ|0JD^#x62>~w z4ko?31X??sAa)yQtd3V5pJ{9&=g_uRL+c>Uc6^Jn0bo%?F$u`M?j*i=eC~8p~31pHc^c z@1erm17m*8)<rY%MS+@B(1b>n`gGX(f?f^~BdmKL{e&LOf^Kr*r&7z+B3yEgaMt%64B~hdHL(Bz7tJEMPY2(ywK z;iNGlRHzfARBzz(qSg)KI3Au98-^N`fQ+MV`;CQ^s59>4HQWWJkYampYhIVZn67QnH5x=M4{ZFbX2MCgohi&_pDyEDvG=#r~!r3g=e%LHdW+2 zgb~ypc-p9uNxNg4OwU4mSvc#{T{u(s5)1(RlgIMe{xm*8 z(liJ0)T9#@V|mI@hUbPG=0t{(|E<{~>cY@0*z(WTL7vLo-W4pq8W zAiBD)5kJxHS272b7hB*ol!so-TTW`Ubv-oD3PL->*&!>I%YNHu3BW{cld=^mr+_v1 z4$v2!Imr>R9LpIuF-ln=j$7T>WQ813KcF`m7BnTYGIIk7bHV6(k`^gSaF_i06UJfR)^ZvBxhT$T3-%fEmd%J$qOv zrx*{ExF{;E;6<*3qS!}Wl}G~ag%nB=R5Dg6^Ex`*8=!;JzSF7Iu|8_-<<*5H&{HzG z7E!lM=IbL8BHr(>l#@)W+R;5+DIjtb73QwE_@uE7NgIH@fKHH~xXgZ18+4x;4mT#^ zhM<(ymD;7j<-fWldC;oqlxxR+kli|ohQqzd=@G>PPrn8KPTA)MY+&slr@m%&KizEb zNx!}1>_ev=1{{PFaZNH(>nx77ALbORVC=Dmik+MJqJ-hvz}_!6h|CvY{4w@*7=NXc zOpcxqeR>b>|9n}vLcuFr4o^i$_Ep#QZVf^;{UQ)@P{H%{?WvBA)-I{=CiAq-*TUTQ zl7PeYG-Y2N2sGLu&TW&md|4KyVvyF_b28^Jn%s@1yi>f^nPS-h?=vFZ0; zEBUTl3u0p8keIS>!i3Z~5QNlItjnM7C~H!ia{%P4naoX1N$JdI4)}m(nq4)bY_&T@ z!s7v^$c|JDxQ&E3wX5>a%u_q3`1Z&R#G^mZ6LrD>hv(!EwmaWX9lapN8bo-s?-_ zXVO-Ixo-|?-zQzp5|j~cS!J|J7`hH!lLF+aGp*h*zSbeme(539p&^|XGXWq_`{{5E zjK1#68N3Mqj6o+hryuY&H5M`?zDpOZHT(4D4ykt4$eWfuu#kK9 zcQMJafZ9u|N^HJvJ6c;W_H{6F2O5?!NB+WoYy@JpOqYwTR}EFtm6Zw#X{uq5<~PEWj1_57O73IFN?|@d~9z( zQWEF~#X4r8I`Q>kcJH?DKFgSv8N9j4_a-@k?ak0Y zB&xEY-$y~F;v~oi`$;?^V-Mt|hEzhtD-VrEL`v%Epn4U-hquPGu_6ATmpG*R{QL^? zZbK^zAPZ|d)e|jwD?dg>31pLuiTZ*W4S}p9h07b?5xbA6%!eX%l#9LB-m3|KTPt9; zu0e$VsHzb}v@YtUMUOvY0rOAn-mDwE+v>L=2W9QYui)1~6o#ezqWdTuv~J&-=C0Cp*SzFItz*?eu7lXZ?@T_C zC=XN#PBqyf#drGB7<2^UqM}fsBQ4YkS<4m@8P_x(q%(pLWIV61flTPAydP5PA{>Tb zy9VNBKmv;YmPVp9pfxe|+@%Xqq$jR?N;hf~h6t)iwmz_HNUQf%k)DAl(HF1ZDK$Ak zEtDMmSH`mi@9|RZ_qz;8?*MH7qBozL1^F!tUZYIukaW#MunEY~R$s=bGKoZKBe7}( zb=XMA_-(&Y{&0@~LdK@^Z7#%jlfXu92B7)gbJ8>x!$@Q5;}wTkbEN~y;8br6udBoxd{ z9qxB$Ht$JBO_)atl zO)#)n<_!);e8Kc*I_ANbY)vym91XV$nJ=1hTC^i}UQ|oi!Rgx~5?f)O{<>Bg^bR_% z>LR5i{upoDD?irG_E^C1YC3xsYy@S0<`Qtz>@XFC+n8}85~IeuzDd>?NDHnz`#hQV-Q-DXN^=t1xRlRuZ$SU?_OW; zUkl|5&Vf`goex0u)il1D+m4;E8++mWwfX6UvJKXvJCrlI4!_2^F@E*WADpZIz?QI0 z?hfNrIMoO1uL}vH1~$A^AsBjGU6b9>MKCEaPKp?c+V$=Aax(op{Z&0#NR;c=AaFU5 z#ys;iDW@)`UCED+7rRx5;g}pToE>cWeHcXpd5GV5d~Ic#`G*awqWl4%b0OGsV1YS- zO7L#@E7bzg=Ez{XLL0(ToCFt)C~=1LttYps87f18_KA^2(?@{bQ`V3C1Mt^-0nYt zkLN@a?)+VdLIy3mfw5XJ(Y{29XuEj>HUAEe0Ddj1nn&lf}x{CCtCz{oW^L*A#FzrzmhF_HJXd)HsowzKd7GXK2t z1AnhXQTfWNS9EAv_AJd`2XKn~67EnEXB+{P&!&S4Rv`@S@;`5QVle4x|G$gb&yL+0o5MP)lm2$ z26@w+^Q%$Y5&ry*2t6g`erH9EA-YkhkYSYBSz(_M!Hz;iA)k-_L8%EQY6iO9N$x{l zMqd*a96#X;{i+1}b>Ro{a(bQypIlAxzDWW@i&KgMDHTDkDfm41^BP2w`^D6H z_gFUylGL+@*^ea#?Kg}75afLz{n1VBNR-9g|9&k>YqY@gsJ5>X#cNXd4|BS+2~$c{qXNS){}pjtLS;_?0E341 zWekSjIpV`~wq26Kc#dl2_!lD5fI#@vaPkjQRgzWy4*)m;=Bul~iknC5gUaUKs;EU5 zv8E0REp}-V(+dZWN>R!S^xSozGWz>JSSbx825iG^x_(y~Dy7#OG$_0CdPJa{C8|ho$i~bxWwel)93-V~d9KjX}lNE$);3ew}leNhe(U4)PQVA0=cu zed9m<3i(0JIDQ5GLV(O2cH2nqT_Q|t0bY9n@FfB$e*B&R9NyGmJ4^|mMu?&%CfbQI zEq_uy&Oxj|kWXM$O`XW$Cf#|ZM0%SPO;R&RQy}mM!axvZ@@+lg zL9jIWh$m$(YjsNKNMbTPiwEZ~pRKv@(@r=G^O9c=TrkzbP=)}3*k_siRu2}izH^?B ztbDjxG15*CFb`2xt7uBhY^-JmywGlPx6~Y0VnWE)uiW8$`SRM@u;Y;vCxlSEsEp@t z;NNcUM$5JMGovOo1w{mL<~TgMQT@W(v-5IYgvhIB;Rq%3H}PqI;T{kUZf7fOQsNlY z^!J=1+zAn35uq{BRx`Uy9sh*h^qh2a(h`$^nvjsnU}?{CKYG^!zr; zB#S+ASASFjzG5OE8(Fij0kqJg$=lWZgr%ce|Ctl=8`r#Hdr&!ex8SjzH_KVUj3Zq7 zqVzPp_H!c-=tzVIyc@Yj+-!8#mZmu?f4sgYz1FyOBv$*ve(*IqeBzobBfR0!Bg0)=#OC^Wm{plO1O<%0;9~A9spE9~5cv zcl}fW|HV3QZZyQpNB$%CfOF^qZ1XuMeRWYJQaK+F*Fh5hh()8SaBIm5wb(FGo;Ovk z5kvcdlg@y9);&|)h#Uhu3z>&`qZAyo$>?$`s;anDyV4IkvFdZ6TjV6VQvl^*iv4LY z*Zy$3bkpe!0tpZ^-CqsTC*B<-oScHVNNiD&lzGzpI6+6iJc{w6nxkKVji)xGY8Jj* zz~@n3dILuL0RPa;0bU(K#m9DM0;U`L3Fj#!EO_K;gv{h?+I0UJKplL0eNjI0&~z*Y z&tf%f)sbBAey_V%wSZS6@K>UCA*bNmPfrhHV=)?n&B-#!c0%p<>Qn-FR28ft*dssG zM(n1sDP*DrRVQyh3GE|+F8P6@JU1GXOW+@WoNOe~R-I1Vql#xCFDk%+Y3D9R_f~*5 zW+hbTzrQ@ZNefJrqkI{h4bpB|`O@@266I{UU*QRBa9!rcDDS+8Nm0xUvcQnS2@|Ca zgDdub`%bowJ*(DS`}O+*P}+ZEis9kmGqW(Px{D^~bD;c_QN(<3TleRUxNhfwqM<2y z7UcY*61O2>W@{Jr%3&%{0y`IaI3gd+56obyw!~&(LCC~U+hI!0Bn&D|(UpbuKQ_id zjR|d5(1Iik=YQdmaJCX3E?HE;wrI{Ed1ji?2 zB0pv#jh54?uARbheLWNBN(hR_^ zax4i5X)1?U66S-f5gjhYdl;>}ajYqNFbS5NqOdwLQz zA+QFv!SkE5Vw``ce)^q4!m5^X3mnn50n_&dZ`za$J6 zKt5r=jvqlCe(0{V@^@@ekv#t_UhtW(C$VwkW+y8A+ryo2%F+rxCW5Djm%$XqQ%}BX zi9}%um$`PVzZot=AH+;UgXZuisDxnifqWFpaP%&?U!%zA-~>8FsFV<+h}b_2oV!ch zB;fO=AccaGSS|nddsw{csrvq0z6=GHdeZ#Eq9D!PJg+D4*ef3fTH61&^D5$7SzYY zMuq#%Hj$1CiJwTckg#;zsLow^u(?>cd1rh*XhksM!-(xL_8vXQi{JpQ=*=P`CPf-uk&v~l;DX3ZMJ$Z;8OGPrjqcnr3h*^* z`x$yw|JU@ge6x+gs+VTiL0IA1SFRahxEXpG`R@e%ON_Gvx+pu_dW>FBXdb58R~n(iXidur0uudru{dfc=^M}8T!P5hOd_Z0Vl9IF{Rs0M5 zxVMR|i)z8GPu^t^qz8UB<6GkvvBC6iKeZ9m&wGJ4+XOWxjTD4~K@$VAK7-|Hcsc`c z3G+eHiqrV>2RQzNJ08lx0y2_g0}*vuW}9ET0J0wBw-yJUSl&- zAI;Gj>9lfR?A>^p=H|-RrN+pJghPMYKXxtfcmEIUdt}!gk~7DVSQ>yr1(xjRxqFb{ ztj|Ua!6M;CKUE|cCga>fA*-={CpTCfhMDP0r#GhS!VhkL*T#N?^U#r7r=KuinD30GERv|77MOincD4cMh2wj7fimm+ z^T!Y5jZW3;8qUtPP9aah2wAqOOlG5gP7ge&zOiPmos^WnIbB^Q++EO+^!Ma{xy6uu z)0j2)^XG&)`Q@2*-+RTy#cf{}_oSF*VLAgwvnhJNms zk0`X)#Z$Zj*jqi^T3meB*txT_)1r)uX=tIRr{}Zn^rMH}D?(k}-Jui$0*fgafrE>q zds~}B7o}E5QB_1!rlH^CfoBZh^eYpbH6Q)ivS-hpTNsU-GYsYX&>ABX#M$MvI5Ts} z(((+l!jB2vP+2m3SB=l6EHF0P+g*Ti@mEvgl{5G1pE$5?yZC(X@p`auU0?0CA!QDm z7#gZ>ZE0!Y9Pa=9o4w5!6%p`4>ZzC%A0Mif#XlY>LW){iq_F;S<-i`bKU?YP=}9i4 zDKnk?Q~_=1G`G?u(+|s#>QsICP9`>0?u7D%#L(hwopYWJ(wa< zMft3eZ%+yrXcsrPZH;R6RaF!q_`l^^7#+EfTX-G+PEYp>hCbj6uI-IO8>EAZ`R~HQud$cM_U?kT&f`x3 zlNV^WD2W~OyJz!$IzG5&V8Hg^KQr5 zc-vWKY3UOV|zVA_kr4YP>zC=~<`GBe*ukKvkSNzcg8X=#0* z9B^vl>ly9l52sUVWdHQ!OHRV~q1|E>3a^Qdtj>2mL0|P^;blxuqBYrJRc+4C&yQOi zTU2g1s%Np%QNOa~x|JSK39Yg8Eq$=ru$}q+yv&$dMx!a$;zYn_Ltxn@fjRdZpNWd@ zB2Lh0&gFGR(ZTr0q5E0#(cAYzOL=y5qtDT3b*brztU+A1lfAtlr|N=|+G^6xQi|#6 zY443cKe}6C&+VC;( zw9(MCDqc?6xp4aN-d_U)(?c~(-^*J#zS zBGbc%50R?3R7Y;|^G}=a<_o0+YgqQ2*13OXkUj9Ey2^?2#lL@V_@gF1eDtW>-FNN> zqPHK1SMuqxRewv4|4zhq@xM_dPqvHi<9He-ZCD)=J@15`@mbEJ-VbYMY66qE(2nh( zJaGESxKdRtbbA-DNuj4h|Ni~w_)^*cOEpOOXZ4Q-#prtMzMWOm)pd}TO}3~lUH7u_ zpI^URJv}D|WJCEF2FjjSWoX37GCb@pxNfYT{iyfZ`)d+s1)cF;AFocD`s_dUWa3FM zG9aD)J?__tZQ7`K7}9xGfX3L%>*Y7I(G??70_ z?AZNum`tqxcl+9apUAzo(-%)n;xn93BrKY{NV!9+DgFK9l~P=8CZ2gdQF6)F*0!(0 zza(ZVa>BbZ6RC*j^Bt7$X8qsnfGNHC!d}Y6ib(nb4EpHrE^cmaQ|!EJplE=wVr}!` z;2TnsGjq20OiX-{skAT>5wp@-Rz`+!qh~PM2uDX6#vMI@GVcL1^0~UXjmG-o1RmNM z^OBk|Z+oVphv&-fpdWg0`WC6K)?;KodMIj3ZG^}+#6E@2FS)teh;1>h>kP9(cLS`~ zRjtHG-$R^aFJyqm&}>|V5-V(D6hw@SjGnqD@3YK4N7p^k1XWPMhmHtPiLuOFyHhRCVoa&SJw?tZE;PFa?=8{kY&-_sMx--@#_|? z-=cM+mUB<9v`F7Z(1ETL;`H|k>#sSL$xq0IT@2-WFPT%S^f3I>@w4;HEW)-$+_Te( z(dV8^Em(`nE(3MVE$;RfCrqxRFOeFwzo2UuZ212D^4G`9uX1aiY|*6uOvP_lt5b=R zkP`bojFp}{Iu+s%#q7hSOF{(s{J za6JLV4cz39nuT|h9H%;S_VC>O+BdR8LpDz=Ld-Cy6R-Y+d_+?&>&w(s9>s{jz^6V; zS*zLD%DM-gR`uLu(yeq?t!{KYwSm3)4tvb)_LFFV+wRIuYkL}#rw3{El`B`YUO&Gt z4`@DgW_`89VpG(P?^W*gSShM`bGen4SUX$WLxL-uD+`lJ>AQ7+H{M=0y``phn46mt zDUT*AKR*5hvg-YPA}@F!i@HPlBVf|?dAGB(n9mr<_W@+~3xfNrgEK&+%66VP?651w z^zZb5w8g{%i> zgCZj%3ybU0vG(GC=;M%<`SrEOXH^Wm^k;{z--D#yr6Vr!QX9M_z?C*7$Ynift_K;3 zOP<5nY+-Kx%fOoS3Bp@6_H*2h+)UG!00nWZq4Ue<&!#?8pQ4dO+cNYsr6@Qeb&QO> z2CITc2YUeACzlnkK_ryWxWP;6dr+Xd=5_KytUPDwSaXuqPhV(;czpzp@4adg_YuOy z)8RX;C)$e7dY`3(t|+y^b^lA|ISJ2gJHA=C9ep5y(l6ce{ymqvlgIR4mocQTudnAy zQOEJIym|YU5O*^&mmrYgqK*u*kW<}p{h*wml_F+PeJ_wp)mL=lUrBOEHtfk0G7ySy zrK~&2E-0*=nD_tfC4Z zz2Z;Djg@hoxUZ-?Ae}zHpL>ZEV}Z&I?|%%M7n69zy=Yr4#bbnUui=)*1qcd#+yvje zz=chjmqseOc=3rro>ijKi)3}@*5%yvu%p;G>mLyz$Fq6K*HAzEXa(xF*)>)srmC~g zpi_WT)t0GVKY~Sv^TS0Kw}#(o4(Elc>@bEm`iq802hp?$A#k)UU2Ew-elRODQ&&)3 z^zqUSrTP=PxX1kW>Mvdp6nvm$h<@As+~|>-I}{$iEpLf0iZQ0~q1MH`N$8g^LOMDozW}9wD{|R}0`^_bMk`x% zS^W0x+fO^L?%(zJ{I-i|`$A@zg*Jk@A-CRYo*S0i4K1m+dABH4OYo*sW>!{z3#L^V z&uc9qsfaEMc{@;{mW`X8ZHQ;N&KY#%$gij}!FqWHA#3k4agy$?uKH*R2cI!p$g#K0 zk1G1+_u>KmKqH?mu^{FrVHEgk3Fm* zoy2CK#XpE}hq<`87RxQhZnE#Q%f5E)T9xA6csXWq$+Sz+7tPK2FbTAszjbAKS?Bka zX}NB`P;S3zW8kQF^X=Wey(x?BA(8r_>z#}hFbk9F@8aUfTi)`zg)_95l(-qyp9rop zTba8L+G`(-M@xdZRC8A$un!#R(-@-+vTJ=+jv8q|h8Pf~4X@Q?{)C%VPacOr)mXNH>Z*s6f7NZ?KEFhp}IswTK0>VS-CV-#2ckf=CxQWk-`ppKs{5bv~mO*I*t%oKBX_&04_xX@x?)B;mXv42=%soE)e#{qCp#q5sBo;7{`R|7X0o>k zsIz9vm8H!Br8~M90dK!Tt?tyqsk;1UaYHwH-d{6}Tl@W^LU_}|Jz2-(M3BsMv#KUt z0u^5<12%coF>~SQ`Ipy9Yu<}JI*>&y)+2pz?cqc29NEUpH&%`i>p>T%6UAAh{tHs1 zhra~jitfMH%gdYdxwnLL*X8$*hH68(E}RIyaty8Cw~6+{pY7+#S;U;4A_8dlej=b#o3StUv*g%ovJS<0O1x;o7p^hLjr zJg>;gRv|w3yRP?c0NgX%TqaM{&?xHc(Of|#Sj#a6P73oHb!CGdR zCLwEx{jJ4~XoW}ot0O|hZf3iX^~84wTg>{3#HY>z+bPD%i7bi2;nmO&Y=u)Q8K?+uR57~lw0jM|gxh_yAGUiN z)F|=KgK0T{Xg8GY6}-HoqP7~q+1PMp; zIe-SI+BoVfu2(cR&AhD2^g$33p`kIOhT_VwOV|44k81Or8g2B-)dOAm4)oCLshZg? zOivf5+z|?;poEwDuLU3K#Kc6~rN4(!<~_w*eGn$_I#`@oq@3%u4LnsD20A>!@%}E|Oz6atkUP8?{wTU-WJl1TK#`o~Jp!G;ph9mrr z)68^>nGJD4y`Uw3UVH2-7(;JXvFIBOY#!djaI{5L7q*T1_)25ih3psuGf=xf+$8^E`_i29i_o1N-jOR_jLF^={ z{~^@S-5uUu2jQt?XZED|W-kN_Fv0LZA*)@(*eq-GwYu-WHudSk=iFS3nD8>T8vT67 z+mTfzbRtPHemAG`hwse__;OqDhgI>PzhF5cpS5LbmLIXqq`svE3ugfT#*JChKQ?c$FP~FF&Y(=a`6gLvW{(n3?;SKPSt(G=M1%PK)yP&&+L+A)?Y#|AI&1x%wEM;i)gM+wzN~ zsCCgIC^s(g@7$>3K{2O;E-o%H*&u^FfcOdv3*$#WVh@t}iYSeOwggO3$kqIfMdn*_ zQJaqrtaa_!!}5Jyr7rTZWyv$21QfoN+4vuDMPA!g_ZBtggA=*>0629kEgrLp4kB3H z>&`o?E2=JSI2`?m=_Mo}12DVqpK=C-XE$dI+_Vj?Wa|b7zar~nrR9BUdAYbKii>H} z(bH?F0ToEsF0qe0haSTl9Uu zbaZ&8uXzQ^=)1fska>TzDM5Tom-p2|CrzR)tIjz8sOP}^|1LcP|EKhnyNFUm^4A&w zy%2`)7+sYMaL6~}{Qfa9Y2k&ui1A{~Emx|9;TA7+QwdW5`P1V*! zK!$Q0(`6dR`G3lb)s%SB9QEp2YKanej9cp~-X^>3m+7omc`cAV`mNuU#C?cI3SaIZ z4B!L?pIFjF(UfLBNIvukSCOru7rp*vEv+hz0aKEtrRk%1($$Xf2u~j>ARwwd^S8RP z@)8<_S#G7R?ZtcXK>R8yt_<;#XB=q4!HE;bCED1?nz>+y_2)?q3D698Oz6g)VIkWg z{~b{2?DmUx8*2g&T2o4D)>4dg*U&Z(gh++s5t@`mZmbb(y~| z=D1=xl#Ly15SP@^uI;Hf$MT^5(8c3!3Dgl05wbf3N!nOhe3AST^}4!&K-~Vcv#FxJ zrmc7S-D-o_d6TY-7_XPl}3OU$B|_T3h#{ zIcTzL?PiG{|I=wAiULoc9`MVgp`|UkQ-T`kg7w4!3-9%rs)7gQ7g=yy5K&LHo=~|JN;>|a1}Zo_V8t%6};Bb)>hQ4e749QtovSC+E9yIEC_VF z{AthdZ07xi7%i-vcV5v_UsLPKy{X$5{uPpC)GHM%B6<8d*RNi6DRe$bcLyU8&e2x` zcn$?uNk)1kwhPidMF=w=?)|EjNMu(s<84n+i@j}{6*NEA*)oOx!P8fT%Jkc{H)`)? z=zM5>hW^Ntgb3S_-&9l#?L2e;=XUTKGroh2UBNo_O44irp=JT>T?Kdkn)wYzMTor4 zefxc#mVPmigGV*xpyQLhLIIqRL#8;{L)8{~BlY6vpYNYfmR2u#%)rlZ7Y!qPF~d$QvA$&16Jclf zOxi=abLT}sbCQvfXfy}Pt}9u7B(4tj8ndJZdYbtC% z7ov8~a^yxsIgL&OEOq|#1t>J_->TYh&Uu5Ui!{5sx>{KLhv*=xgs$vCP41;8g=IxH z`G{BLxx!+tshZ`e9F>2!w^x*Ml|WPH*__n6Z%Hu2yB@#svv|oR35n3f7M{Yjm`n$+ z;NxnEO6K&mBwC<8K7-%!XY93=a@8$&Di16#FXvCi4s&DjuGd9H7rVaL1Uo!>TnS)S ziBcR;p%?t4_?*#BX&D?nf6P2rXr#$AQ-0ANI@eh`<#f!}Jzttrel zyxk?S8I_q2IHmIbs}Z)pC8ui(Jy(YTXUcbx$*l@n71Yx!yCb%Iz#IPiRLdm4t?kxR z)(bb-3kFJOG+WpAJ%xia^s>+1o>if(#JmP_C0`XNmPb97V$Tt?u zlLttTBCZsRDVoS4?3ZVbW9&>&)M+h*iwi0o)kR_`5KKhTvGQ|Pi(aTO;)DSZ0802h zWdrACMgfnvFGMAG-Wl^FhO0WCd3X%qdu|;TQaVokv7qPC^z^Q|ne*ZRSKP6Ll+W3Z zRqPv}g1_MHPlt1u!x)WbQPj)Z8|GZee^D53&1x(yB|RaxcAQgp1-dF_Js7FS_6h#? zEQHO&F4EG{`iF!h+`OBjkt;lIhuLnel=Xi`B`JsZ1NnY>K*XMskDotOA1Z2n@M|tf zvJ7v&?BC72t)|W$pxI3}{(+8{*f{9N5XM~5l4#QNRc+2LW?j4dA z_2iJhcCGPRUxjOZ3bT;*4>Xyf<|_4`VMbVNt(DyAAHb~`ePgF+XOikL)eZmYN6+L| z0i1;yXCDeEgiV*r}HqfZY^zV*l{*C9mqCZ=WVl9 zbk;gt+7M@*~b<36LlAQ^f_z9LFGN;(}wgK@B4^K;qh`T`w!f8|UUM2_hqHDz)$w z&E$wy^|WcRtGnyqGJnosl+lS701WvDfrEKI&wUO<8F<%ccS+j(S8BwVY8jSW553{j z|DZ)&^{KT}VvvdIS0W;uxL~;D$@Ajkn7Y!C-f5j>o^dB~nYxI4%j{I>&v^#{^DK?0 z(moj0^Y{1XwCF;R{@w1!F(NuSF#$;~DU#mhrUj7FoEEiMowuunS6NrYYVq3g%qiJr z0Iu7|^$iSk>LSE0XB7v|aa;2IGtr3>+<*#wxq7^*mDPI+xqrQYTtTGy z1*f$O7H^^39@?m(D9SRZ>Db&X<6v89dln*}xLW(d+xz!xq_HPMN++wF_2d->EYizF1&t$xyBJnOt73pI67AhsZcaERH>yt^763}hO!$R*qZ z$B#R$E%BazB90z0%cLs)$E_nrkLJ4k+oOtIsE_%cjh}<_qF@FxjRh^;E`dm|_prQ> z72x6Ui?&-iYNS{XtJmCeO^sL5@`5}ZNi0XsAH-U&T|PCz_}^KRRx`}`;~ zvU>aq3t6*Gx((_#S_1)%zu}L!`?em@lQC!$*Hu|5Q_3UNTcKyrrT6ITYOIzP(WT zF`eg<7dJCC)vEMk5sAs3dQSV$?cDYK)-kcE_+kUNKkSeZ$-L&~xK-ZRNDHq&sqt~Z z^1EImY-5T&kvnc^v$wT1SdFOYG>*2(hYDD{uC09@-0U6f&$YQ+U^n|rsO4>?tu2Aa zn)l?s=q|i$r0v!d`1+20%X>LsU-%dy!88?iNw-ZTxBsiX}1m?VFX|;7*JzA9DVmC}3Ylp`X#R zkB8?`t?)`-!iwX{<3wvF-EmQLq(FqdZoZefuT)o8N6&QL2_)WeD8;ZJZ6Rt?&k<3V3tq_njf z$odUHUcFM!ZteIlGiuQ=*F?%IrQT%*jMQY`duc%w6gT%7$H0gPUo1$~#>`e)W~KyY zAsXVY8==^eh}F7{7Nu`+&>fNW#h5cID{Hacvs3;*s`~~8O1M`c!|1XHB>J7Fu-dRT zRoZ*0MQd`$69n@6mW5baOiw79ObT^MWu^*RQtp(?THZG>cm^C*3pL>FNVkIddmxGR z?|UEUk=h z0oLB6=76|Zc1Gh>)gVyirWRH9eFVF~+k-Mt0{B6*fjo^x7`_zWw$ARn;}hFA)^abr z=c>~V4!(bt(%Sl0pevLQ_3c?s{bmJPCsIb8;RZnGsR&1`K+6OeRt!p9=WzW>h6J%us=>cbaJO-gInJhYQS z{r&tjf*SSSz2CU6$;!ezY(WwS`?OXONsxJ{>@x|E4Xg*)f^|UY5B&5ec-3Y5TtNh8 z)qT$77fOQYB9+ofPB|&V?TqIg4Vf!fs0SY!f1JJ%eItSD_e|p9ucBAMKMmEW*B3gA zWp#p5kG(*`C()E6%LPB&10v{^(OX957|D`O!Aq+ObpU#D}$1lf5IZng7 z;SdR6_dV$K8|~!|GC9aMnStKU-CY+K(@I_9d}mRS8omLRd4)gUw~J5X+$3b`9#CIg zZj`skE#Aa%;CXJ%f~~mf+G7&iwr!i9t(!gVxI&3NrK*1~eB}OFCRM$zV{BX#2(nGn zxEs}o=K~LpQN^eAK^qAWNRryeCs0^^0Pi(%$ngzXX?5MqAQv?w;|DVl+{epO5%2sC zFdqr7!Z^{9)%SUFu6)+ z)184v782lnYi0V;RehT}32KcV>SZ(G7@_Fznk@OI@}T+ImTd*6ZotT9yuo(M;P(`s z-n>$|?_5%U4li7gk9^S>G%E35`>?dSYF0%avzYPeQ_}@AZlf@r`5?A;@$s?NlY+OK zMUZb0nWT8km;nO06}x%d3;!t9`oDt0)4+e4nD6-x>9V=mp^~!xpm{Kx%z#+R*c<@v zZhpRWuu1`gjXahuEI^Cn)-Xm$m+yzS5~sl~qypK_VXpC`iV>k4Vav+ie4SvFc^Fha zCYN?+5;a3RFCckXimUnr^<1>^i)SJHSNg9Tc>@GGGsd zAs`|t%Q#>xb1Lfy8BHJjHWLuWZ6a5+S|BG+A8$bu%g>vAS3-4IArSgC z@sDm9w`O)oRvZ?+Ya=yL*5sJ?# z@~PmX?Cd)M5tkED3fR+ilZUhxg=rC_?w1bB$RAPhyAKHQu;qI(9RY7cKO?o6j>FbM z>3_p+RtBeDWF>Lmm#g86UacS1)fy%j{?2{88P(^^Oz2gI`Q5w~m21>r&s)D1 z=686`pET<*2AF9aHFP@!N8JiHbWV&LV~tkdrU0H_+q>F$#FezHEdAGDXO@k^C$F z+7#aYWp-ukb-1`XKi$7Wk3UPRx8s$+zYEG%S{yr(52~pyh>APOjC9)-rY9Eizka2% zW)WW9b4Z9Ly#BHDz1p4_Nej1dxBAD2*rd)fhy#x&{d32-mY9s}iu$cnpTkm9l|-(% zF&6as$><&yVXewiGS`9jJ%f&Md_Hl~yxDc}r^Y;sdwT8;2!@7!Xmt-xdwJS1Q090~ zp|c;jvR~**M;eE(eR|j~6Q9kNXZwFk%vko3ygcN2g!GyVl^)a4UHTacg!2bb7sVh= zoUib#I{~uCajv1#cLC@ZmJlT!c_iQmikk;S^f@s(#8Bu2Lx`BgVnuH;`?>nxe{!p< zWm68bC1sYbS2dcetDp3TXiW0VAv3O%1=*5Q(V3 z7ZF#QrpSuU7Ur@T-e|)kjcmzd;?^`d>1WhV{7PE`(Qi!(rZ0b@H?a2ZA#t&mgWoBetwdfrB^(*Fx5J=;cyFO*0oXuc@k+LN{vBcaUSmympnP#5zAFAvh zz5w1{WU;lMw5!?=WH9rw2oN}d6atpCWk)$V$>+zKhc`HtXD*h$NLgC5&_pGy4cdGz z;><8GYsgv2A?&W|ma0&AulHjwC*S*<_v=ZH{LA5MHmEF00~aSd>0Mk-D*XoJ^BJ2O ze$>X3Z+p`wUEkGgdrzlCYSyB=u8u0&PgYh|XQ~j=;Fykk`o~3cpYs}r&u!sJC`e8= zo^tGitAUlUW}ao@)$Ua=j6bWKs*rygzhf8r;;<+^PI+c+XR43yM5J?Db_&`|kNEib z7UR%!#+*41@pRbCSd|lsc6%?)3{tH0{Zo~EyZ03>I|qDet_1o%LBA#vGK0_@e$4E& zB#}H(l32JEBd~f+ZNiJ`UiWTj>sA+aLkk@7hgM}KW5neAX$^4(LCX;Fl{;uUja02o z=TTM#%{MMgy3PF(J02v1@l>(o@o{y(g|%g`t&@@Z1KaNHKO`SKa@$YmfoSBVU9Tx( z7Jsst_Tq+Ij^xsQ{PdwNPN_ml7=^G%63AMqk? zk8!a>V2Y^sXUilN-6R;jQ!}1c$E?h6{@I?CIJ1JTiU2yQYib%k-B*OKKSoeCfN=~O z9IuVW+7$Q%9>iWx%|6l1zx~@VmZ#(`rq9QK-FFEa*=cDv{GmX67q@@6M+#62RmmT3 z(FVu_36l?61ftbND-0$+ogf9aZc~rK@k`Li<-eWANveR4UNjgJ6em2i*Bfh(b@ecz!DGH|8@JmU*Y1V zq_vyq({B_yInvn$agI?b)zs94uIyj&ZQVT<`;bL=X$KY4IVFG955H4pbr~cUN5@wM z6h*O6l1>c<%@{HD2441|IcQ4}HZRjXcOYb9)#?$7*0OV%BRS48e(6|m=-amNuVh9V641+O43RjGRnDiEqf>;a6KoTc% znZl}C!D9hew-cwWv@Y4}zSb_#I2G@8{a`R@vuH3}XpTw| zCfU__NNW$u4@)$DR8!2Kbb#SVh^XT;xrKR+9v4&*C22EwrH-yT}^$FM1sCK^w_8FNBr_ zeaD|P)Ba07ivoHTS|E+T zO?mFzIlHYtOTT}Q!n6-(=WTU$Vt-Ky97@iL`XR0pN5AXOfIPggA6;%Yzb6qc_^hG% zdMKZ}5vv8fv*{Qfo%%q5`RBce&&?lF(1U^q*Q*3!FWH=`5KE?LGVvrL?`Letn>xdX z6Cxy&%D*9Mtbd@vz7vfC=PvCHujdNn6nWV`yetsya+S(bOdl_`{r;^gr@e+Z81{|F8?LOMTH_KA4b*UVtY~F$=ZwRvwQ2a zid(4GoPI{7u3a8DG>n;tnTJgq|~jL|f~8tGVE$(ci<;sLilHTX~VOP!s46t>wik9X*9$EMHe0%G)#_22cX|>`< z{6~~kFV+9ATN3qh4(etBHrbtJz0R6iuMututoR$)_2(U`u&klkpZ!XmWq&qj-KKm* zTISWq8@Z;hdl~qbyskwWD4Cg?Ujkk*6IeB5(xdor}{37UAuOfTC1GBiG;$0`2mhwtw3ABNmU1@~EBpk@>$+J>F&OZlk4pOZ~ zn6>L*n9)7d_(xqGEBKO_I=|rVI~AYL@$=JaK5p1Y&$CfeV;TJFm5zC6vei|5PhtSk z>?=48HrbFTxfafQKVn|tz|cJAPXo&+3?MeW&|1GAxmLx_@39PoK9G~HiSe`S4@A9R z+}(ZBZvs(*6N6tpU;haT?dKVRasWJvuPi`rmia$W4O=Cu*wDO}c%lbH*VIk&;eqjD zqjo_5A}WU>n_eIIpLvB0zLyF5)(Z3R$e%_dOh})$TNcsoD&*T+r6?H7!Js|rO>uj9 z`HomA{S!M*XHNy3U$nBaDn-K;s;{2?&?Pq3E$ZkJP^1^tnP?06l|Cq4l@xu@bOq#9 zgt|PcFI65hvW2*J^1S;%Ey=F8Lv5d?RlL>Y$ZOcsjtH}t0Mm(5;#9T_NJ*g+U$e1Y zT{y`fB7ryq)RnYC~nKWb+$jOr0v#P&OHEXNnE3*DYuW^8P^ z8u|3usZuyului^vD-`wEF5AR>^rt?XRUHP|T{(iQ2642KN7nxhj@(vm=Itmh-a^Nz zbWuYiOs-=ULpsFpOxstx%$`*#b!N4DBRApg;I5XfTW(Fj0rebfia>0hq0}*}g2FAHzc2oa*xbEW`t z?z5Ued@q7CkwdcBJXc0mmdo3>OctG>6MkW{X3VO|(qC=a{ALUU(6F$i1dk8)_q#w& zXg=Hn_)$^78WARB*L7kv$|r_4CFtb%QxvjE6;Pa_xPqzirL&@}Kzg1oh#?lPEe-f} zIbM5zb0~Am-^qo!=LK`@aldEnpxBKIJFAk0@evYGYXPZ#CcDNmY`>SEDC+HvYf${DspI-9Qg!+WB*mL4>tl$<+JtYNRnfC&8i<62!(^oY23FKlx&=f1q)fieiZb z0qH+Uz~v+SYB^^xPNV>i*d$v)v$qa5+!GH^19#^>v)IMzJfY91?*8&olV-{VIWgan67=(j9BMUoA*~O5-9zfJOR6+Na({c3wsh z^vn)#R#Sbrf#oYh2ck}XC9xLRNvaQ@GJ23owcMNVpOa+n?d)X2bx`&fv;9lr!<3oX z1Xzv?>NjmsU6@{kN)6ZX_oYJ44Xxi_z@G-Wm<21`+^CuqgX?0LAl>tpg9i6(DBs6twnxfwPolZh{>~|3ouD< z+HA7DRd4ev(pf5DE>q)_G#byYb!PO-17(hNwkjw8JHX;0Y`b>Vg+4O_xay99fb(fJ zz#}gUospK7J}M#-&aiVBm7a%2gnNDc*JnZKc2IH%sYcFgZus)-ESoy|{IV+~M2AI1 zdlJ^Efsg@>jLY1B8il|)O}gJ-YnLjDWVB{fT->F1g45L0^oGBDC?6@tqx)M1MkUR6 zQr+1~0u`p8cuIiipI{IhF;{7o?*!tr0Q~oWoY^2BV(2G9LCVpM@dsX@Y2kIdLJT!R z`u*uaGb3VDt~naYw6xO-;xeBO^YSVTs+n3_OF@?tvT`(LWE#2r^fr>b&!4r1su^Nj zNot`AdHW;md>n)z-x+96)l2>(Yc4&~$TcU070rWTZs9NVif`o^wmNnq7%3kd+j?s3 z|Bgqs)9vLU?&w1J{j?1YJ@DLL2<`wjm6$op$xckTO#dR~U;6&2Fj=?d>Xj>fDNF~v zaMpPTaT@ngzAUo0_GJNj?JzxF=rN7p4B^SLRqU0H3HmUg@*Ec z;r@2ysJzb4cR$)P+#}DFVJH&w`iDbgIoa5rz5P^nU}@!pXePOP%#C)Ee`S)wnb6aR z4-oLAzNo0^XQy>WluD-#j8x%N62J38-K{Uy;a3++fM(|E8?}5CM+MN#mS$;bc@b4X zh(1Ui!%C1P7?Wt4q~N*Sp{Io$q?j_XnKoa9w+Ex5$qt?t9*IjycAd zKO$d#UnVEHTsm79Ls{l+&kbIXf2KBZ1VU!}2`8yh7fPvXE|X|D!_3taO*F=l z?I5jrs($=mW0C6Fs6Und;nSzD$aaG;s+f1o9lr;5guBKkCe$E0#){KG4VGv>scuf|2nPp6+#>#(?fs zX$PP>KSW7cQ$0729d~m-Jq*@U9=fUBbe_J!fmU#E#|9FDFF5OOZ$K_KxUgr3ok_W*+&ct3RFYpNg1)Ft47mps4M z)Eq$9@uAk3*542^ddtL1zKomOk-FbqNlD57;j)Wwyo>KcAzGhaoGOpZB`rJ_=jKX% z=zRS>W<$kc83TDCIV^pTD8^p-2m|Gpm$Q(i;QJ^rn0!(}c2Qh)zh?2lWOA2MOJL<(lYj2&2*3y-@``5}DtN;o*eV z+<7a&DHLG%ZUKde$#M^aUeEM1rNszITK^evKX|EcI^K^#c6Mz5U@NNyt@wbwp@BgP z{KC_vSedS6MzUKTPZcJidac^1CIZSPlgR$t-D&Gi#7zIP6zi?$HP<0mU z*xZ&%R+KW?2ehKTywquXxQSK8o3l73>99Ke2Qo96mvFei&rzn+#BHd*8`|G!d+6WH z#r~YOb#!>05_#3eMMtmHKMo(1aOLe;_?ywH+KoAr*?GgT_0_p(Mjn=@+%vz`EI%`A zYir|d*>RNo9WJ%6(x>VJSs9U21qb`mw_+-=L`~X749m#6@1YPUei1D{;RF>3`OOv^ z0DMhmC?c^EFnsm$P)kACx^8E=8y0Rm6%@$9J>Lg8@lPyLaEts@e;4^OsPw`V^7@D)5rt83RIP`& zy?Vtr)xGwVbXA_Q9A+RnW-lu4EZ9%rvz`UPgDa_#ZTmQZ{B$ z8diG8b*`%v*!sjh$JzJs5f>uRW~P z)RMEd&X%4aggVh(QV`t^?^vNKmi=FQZN*<+|LL_=Af3Esw_rfXS2_((pFRzdEDK(d zNZ)#`-*qotNlT>kG)MMCf9b-ut*@7v{#nd(N7#M`tZ%%pD!dPrdr`Kr{!2K5hKEOm zkby#b`Muilf|tJQp`4k0H(KR{WtDkt=ji8)76 zA?S)Cln^(-X}562N`-GrRP_DeJvp2SqFrW?ZI=Vi@oyQ#J7C2MBjjty60tog2y|9! z%g)p^@{=oT4}zBVLT)kOm=5hPVgsb_-kfrE9OlE$&}r7@JE0|3!nxD<79X|OSr~qx z;=m{!hi+gigIaOHOmy23@U@%a5V${t)8;jjeO_9hFDMt_%(G*3ud?eHnp)5~?FM{A zO1+6`%!!p(F|mye{FX5BS|Zn9+<08>8G43(@-Ib2MNyzI?ysYH9M@i8L4K*PzwW7c z?k!At90)cnt(+RE`yk@bVR36LE;Ybl_gc!F36lH;KxMKuex#v-hn4k2Qj*c`zy7*~ZZ#)jQs1Ca-dkzlQ;kBLNZ4vZI~&J%r-POEMQTGF!=5(Z_+BF)70m`yPXp zjbd@|gqK56l_(Cp==wbFR%qkFuZlf4)%uBl((VmSJMzhAtNXU{LeltXLY@)c%^pLw zDnG%sq7nOY04$tmP^yJVRN~CeUeQ%iQ5nqp(&%FSM|2>z1Wy=|kkW%6jo&><>Nu$f zq-#zWNLNd~jy~o=k!OB*5m#M1pXf>OSs?kko^0o_UkK+EWapyrgE3O&pma^O_|aUG z5cW~_(_wg%99i#t`s~?Ae>PAgUYr&gjmzW3h)i2ZNM(l>VwhJ#y)K=vFD!@;mVsCi zGNEr2Rp}H%PAemq&=glnt_{m%PetArCKloPdvS5dNCIaA7S>;=RqL{qfH4T7wEp%w zm~*udh|3Q_>OZXj>RaG+!mdhaQ1QIBNAJ+kUW5&;AEdKrkN`=I;rsie_T2DwR$Fh1 z-Q7rUF(~~rC|lDYVIU^#Lwo*_sp*r11iGv#)4@XxF|+FnuylMT5p(X>&!3+sHenY5 z!qDga3t!KJ0d}$2ub_Ynd(f|AswdgzO$ux-)nEnqJx2vIX?`QQa%+EZ%dpvWGK-Mm zO-^rg&bRr{m87!{xx=E1!rY`aC46Zl#^%(NM2~GcasCuK1fCS`LLPY*i}Gr zUGbkA6CtK^{9kwHCK+?Ry1Z=f`YiDaTt}snJ^?X;XR8N}m|u-?4TSI*%n)}#y%Fe( zDpGuh<~Ku+D6E#2tbMLWK*$LwQPxO>>^8YZK9!I!-yeVh&u{PLKdN3IT;ieAoNxWz zBhJ7QhC;t3H7y7#q1HkNj;thMYL~36Ul?ps;=%|2om1(O6;EY8*9rsmCDBu`mkS~fC(LT zZVAj+>}c)qydDIv_xh*5kETJYqXB*~l(*ZkD{Gmufe-FJ5Tf&YAtt)8@6<=%%Mv?L zT?p8(-Q={HIWaw5i@w@BI|@MmADapu8W|nHZ-mv#FK}n7e5PRkf^Nu@S|4ZuVaF!* zWOLvIoBmdY^Vi=LguQ9_dgI~vtoL-P_?9*|dLyGGoga~ZL8R(ko()5STatNN)q8%0 zY@#(jJ*Rt`h~c0p^&(yG6rKWUAW8NUUy91wnmyPshw=*wvWqv5?0NQcYZhKpP;juC zv9V{R57(>jt1wzAR^-QAuC3^qH6|(?hpm%nnrKdtzmohFA~2W_A97MZ+#&s)4PPbPu~z^_%%Lm;O!_%bA-@6 z$PA$@`2`Ostb3wC*ix*5W;z6lIR~;D`bD8YJ zKQ9kIGBJ4!U%C8(gk!_DC;A-4V$8BKGixkV*Bh7RCAfm)5Z!L;=+Icb{=0W+y2!ly zJtBgC?*2;CjUM&ZlwUSqb(+nF$H(`<{|0t){9rbEOfi9fCyh z@Y;iq9vz1=@3*(x9Q6jU0LvxO#!U2=`R?9*RX;WLpu7x~?X|2N^UHS=9H89pe-EMV z-Jzkw=-*D=`kmhN_VSTEK=QW0*|o(c5c}&TG9UlO>^6W;(e{u6&Ak=+)7ldvU>I=N z9wg+19D+WZ80jiZ%bIrR`gQm|(ZGjB3^fm(!s=cSGUr9q8Us_xd#3t1->} znslEY-tbo)>_u2U%NZ1Wk`BBLEBL1xp`xx;1=yu-mOo0 z`5y2khr2vEa{0*~+CAs=Z#`^MlCkP3zdD(wud;VEUSDZ%m@KcT=*Vq7nje2%JoJwD zlu$R1^`Pd?IQFxc|4LzYf;~6mF(g<`pp7Il zpNfM)2+y#m)0%{P%ftE)yL^I!l_ngt<1}i&+H!sT#)YG7R?4!jcW_W^`eLLxlbk9P z$ddiUkgpufalqgR!}taBrir>K_>4DW`jVzu*Bs;~Q2v>Fx#_b8q*V@M4wLSo^1_V! zBtycm7Sd)aVOI|J1vww+!y9$-FjOgeX(@9BIlbX29u}4?fz=Q69=;In!Y13yGEv{6 z*KYoDNG}n^Rxkk4fN~Q9i5kd_^C#bo1?P-OYHCK79drttW-}jt1Bb*|Sfe#nuAv2b z#j#BB9g1pA%OwJBufb*C|KzxC>$Yi!iD2Z7v)(sZ*?FF5x2QB-X!%;tX5cR6V9RY~ z&N%}M?_|qzbEM1goAop})p9cD{=%S5(@q65-BE?UF2DqGuRmdKb7_(d=nmED`_6K4 zb)D@%BjKa`Cad*;Sqk;_RAfRUxYUFU3%Tq^;lxx6TdQiumb2;@N)P@W{|?NVZzN}9 z%egBel9od@6_46W2$9L^j`nX4REYKk%Y#MwhK?AgqMfLemsB~8IbBUz#zpdUn?XUR zyB?MNlNOwg5h4~^F~C#PS6y~Z6=)YK8(%vCipQ1bQ(0gn#;Fu-Xm!j$^q7FkLn}sM zej>|~!E8)=cES~T`VmS>trkuS3PFxXKb_%lXOuz4t1t-14db!#(-xYiqY5*YiEe`h zjP{#`b2ebp+=OX%QW5hhjs9vP?0Nr`^o_5c|AawfB+_8rmNU)*Q!oc_hI!mP{x@L# zT+CH85hA+_R);b3CDyd_!-sR#0TyW@6pBJW;NctwK%<>=1He9$!||e6-(1C zV~DCr3+9kX$>{0D8W#P z>6WD%gQ3F?IPg~w>Wd)P!Q)Soh`EvAz@=N!QWDTj_;5>c!3W=xsWb=FDRlBwt=8=m56xG9UUVpl3kx3}PHEp%9;}HwR;eIfW!NrYvV-Ha}965d3;D$XwYDxMnm}n^>DH*x3 zzDoxsie*s@1Ln`KPC143_t&%o)OES?oER*KA*CFF9Rq%5fLe+Dy*?Yd`B8fL;E8LZ zPMfQnqlm|rR~HdsZF^r{@_tiy3&kn(vJ>!zqm-0JmhC9c+6oHpz0WtARvLj=ks>o0 zF=M{p>j5oWPFk7gw(lWa3iR~!TAPc#dl}jb##@xrI@#9nu@fQ55q&zSSx&JtD#4T^ zu6Ye3z$_NK2+G3+Vm99h+h_&rT?hTyWdJ3km>QgH85oG&=zvLY0o)D(?k%9r?$=6w zj;hQHRS!!ob*bEgNm5*pXM;spLb-nw3X$>Q%{DU3QbiXS+9*Y4S z(wI|`Wa@xfVHv0J_`_CF!m+TK~61hS`XnDbyWnWfsDX{ zJguGPV|uzI3hEXNX-)_FM%z-Qc&30_Whkx^#L0Lr$ogNxtThg^;2 zqCsp~aiEh*bNoYh<%(%#4ipIqm@Yh;ls7nnJVT0klx|A#3=@+`RyTWIC8k=Z34*%Z z0$9Mu*H_zQWAO0Jn>TJWLe~IZa9giA+894D>NNr(DK0j)!r+{d6vM8&ckg-!1$~5* zxJ+AH+p%&UC+;fER>IW#-Mjb}WxML$LI!&JX530bV|9y&YgP zCnkEvlJb<{m)VHuTs`@~&MWMy1?L+dpNF$H4~;t83A0%u&_SDT6pIQ4b3%(_ zAN9LQ+fBWM4Szg@5g7(;rh&>^k@EUjy2UvH1vqjl8#Rh4T;UkNA%wBWT7P ziH~0cLC^@ttiNH5cS?~hN{WBmi)RG>g%jr9ii1VhOgvc_Fv)JN7H!s1SapVHXIoeM znRwAFW}gw-vwQb7g_dNuYypd2S`nrNv&3&+F*?(|J)?;GwDk1MtEb=j`s&Z^GurS= zO%!vmm=k~L@*gfhNL2DX1}R%Gn{wLh=rywydAD?^DUM zOysX+B9ZF+H29m6SLJNK_I&Ca(zD8!mKxIaun$RHJlR88Q{fpK=ejm-n;6jF&Uyp0 zHLu4-uh`SGaZ~z-h9+a54I{_+#5x7qvksUMcZGzSfY@E-_LP5>#eSnDj@m|u za$uJV5z}R20z6)8m=D(#kN<{4Z<9{pd+wEULcn!c*rfGKFn~>z?z;57ard`MW7RC1 ziQe9l^OIeIp$?gi(Lvd8qSbFp9k3jrSIFaT%*26MI&MmdK?$^xn4YRki$HDcwE6-s zsLuN`iymSev&E#a$~L!&SB@n)SH(X}L-T4J{%)jRbL9TJ8XnO?GI2H>N$E;+66iY>V&4SR(kP zEfpcbD4v|DtgUSc=YRfvgMpbj^GBLucvra=k_NbJMj-0zx2KO_25hovK|{my0E!S_ z-x$aeI|{0CG^8!rcY!*3io(gmBLb~-UKqqS&5hiQ4{|aw`P7`adW-lA+_z>6RvgdT z$T;h(h<5w;K5dIz0#U9g!r*76gla?d;m|UUV6e$6H&_#_ zKc6caDwmrS$}!|8t7q8Na7D=~X#Vj9?M|wf#MnSmN@Cuj?WOa3Q^WQ&j8MdX~20bM%V|mxk zoqFJxVsJwgBG16+Xf)LFdLWLp!{Oa$|09ZTs@Gz%8b%xAyyP=py-iL|z8}IFg>h21 z!RAbyP3~jWShbtKD}izvSunjzaTsXb`rtpX+b=yTP;s)dVwnfO6os>xq@*{rYUXpp zViR3?4?r_bYD!*|Bq-*t`|rsBJKVZ;s|9g&9hoAK1jfq@w17r1KhYrrXsGf#XB57n z4?-y|eNusI3XTAoa*vm;Uefo)t}y{Qb@NrvduZ7|#Czq4UU50u-Nth*t~0 zxjLLeQL-7V0njUi00k1u46KiE+*$JqJLBA#nn>B#ue2AVVlW3e92G3ar_r3(D1!0p zL#r<=2y)B~vd!VwK?xAm(|e_^E`J%WJD@x?f+02jins$g6v50V9hZK;rR5NvotwjT z>=G%Q3BqnhkR6@o9AkZ2R7B97BGLP~%*rb3?;n$H(npO&nk?|r+T!FY917r6%qnIIreQ@j6W9pI~``I3;_rEZfDISe?zkQoyDx2{!p&NGY z&HB^5&zYPy?7e;s1XG6T?%lUf61JVVQW~dCQZSdDG+yWrd~W>u*#L~)Ycb$|3$&o+ z?lvn_!8g_U9}UiWN3fkI>c{{y^8*U}(kh{~bY|w9sW1{8ZM|L}&UV4Ve{8#??|F4i zD4N8uTtGI*GrlmQ_dFeUnK)~&qJaC;!RQO>5*CBju(4#S+Q-{SyiA9v;uR<nT;yeSG>LKh%9Cl&J+gae-}DOzI&jcX^&(bxlrG*2M-0=w7wKAQQ& zYH0T(F?B4-&rcdjWCPk$^Ru-a8TiF|LuXqnK^sP?@dk!U^}kVQUS`X=c}bT{Ch*^s z@AKgqOVS;4(r(d{VK<1aPa=t4y3_lN>85sf{ zL-Z5-U(w(zp!?Q zG`N79%T0YDc1GR@YjhqB-5T=iiJwz84dtXK%dPq7yK@ z{{-<5Nnw+}j~Yu3U?7tuaJ(xUJgA+q4=rG=^UT$imXT3%a&l6uoXcq=AE|djdb;|94rfMe|MgHB5>%J$P2_v~ikBWBpk@+b|NQf`tY#BatIYe{B}Uc`l17OjsM~g< zieg`Ob~X(sry>RW1sbrz^oF*gB#CRP*@2>hC|RVWwbrSFaA9lcTkZ2bMc}Fkwo9cK9f*WQ*$4j#h@;IY}q3;q(%wM*Uftmv&je> zOt{lSJ)Kc#L_{cnEk-x&`zzj_V$?aranRCcAx9fAMw4+I5bP%xMSuas|Huj5y&T8E~S;IGg0rQ*c*T3;O?{b&8h z#^3-+0`#Si^b8qNVsko&<#^ra$1Zc_JR2{cYPOc zP|Q)J8f~t$RLLV<{Pz1&$kH~B15L-s$rHxDy5BtMh|#uV@D{=Hz*k7^md1M`TmlQp zP6ZdBqK!~PvDb{ICo3!QZPI>aqMq{%RecxHmTRFO6&)?k8_WFf9xxdA?>zt=FWm<4 zYL*@UKINbP%%5fEw~tkuURY?xDM~;2fNK#z9*L&X7 zisk;@tCue)S`MW<=V-*Gr+?QizfK~}|MlrC>F4rk-jW7M4NY){Zk$jI=+E3n`gy1H zIf)dzSzA#K{!Xdwf=cxB;E!A z#53Y6Y~a@Q& z_-}||R4R2Pea;ys{kx(`&KGniJMB)-m=4O1jGVtHWY}Jc5_I!c_`kcLkqrO*&sP9* zFaN)P5;=~&^@w}h?SKC->GMej-K3+(Tcpi~y7`O$>*u!pzx-S*`G2oxMicPQEl2MK z5ce>qiP5jb*V0jZI`QQk?Y{J%ZFd_9#;6DRe}3anc?ce!9iaSPSM0K?@6e6Uk+xl_ zc-1p95`kidhsONhPq2ORKW$u`i!~Zy|Fx-{qbJ&rUwSv!BvVB(^G@I$J~Zs}zmID4 zZ}tD&F*$Ke{<$V16f{z>ydv>xs9!rVx76pvd}^W7>;c`)*$dAaoNa7vD_dHUdl#eb z+0b6OlFnM_>6r{d7OkVUwu-2(?&TeSk>>x&v-~S}B%1AL(cV3K5ZAeX#AaSUbB6c& z^-mBwIm0ff^Gw#S$Ga${czMGv#C`lI3D+nFOri+;Mv?4AT`g*A>iepv$017`k7389 z8|7rqr&z5Y6&;}LL{*S^Wanx1{)#bppccLA*>zx?Xz2(SGd_C!_?D@Kg%5!_4)>NB zxb>adw{ypi9T**oN*dQYZ`qM+^`L&fA5VtRO)H%DO9<(}r)TR=X+CCVX1x z0ae__`g6%OWVcU4oHnM82vdMq%YFNwwNvGrqX`2m8>`0TP0Y-5r#WV@AjS)Iz96j@;eG@~~3);zbL*fVp8cuG#M@S3NdAZ}Y6Iooe}~HMvm! ze#uEvtjwaGam1>HMxIP95G9RM7ZdIIoec z?6@o!7~}c!WmCo5z9WbvK+>r<8E9#LWEuv}mmLqi%)rocwxcdX{~$Ui^*S8iF?Tq< zaD9OebzgVV04Q{r>84F{<3=Sh1;nO3<(Htt@__hr^L@t;9SXyUI9Qn1XpLE8SZI5e zu_qe;@nn}g*do7=sTawxJc!qCp%nOZO~`iUo1nqu&mTxZp@o-#sorb$ZyyO1o1A4M z5|2L`?Lm54VrA$(T{?97WhX|Pd>T5yq?y0%V+f23^h;k{Kl2@{;lhPd9hIf!9gG2n`d8R2`=^y}bMGuC`zefI`BR1njFx~)flnA7 zId&{*u=>3DuPqK8hZ!E;(|viBC+de$PSAAE^&F zEkvWdaOr{<)oTP46|G5HJYiH~USukRC-QsOYck z+VOa2vPhscfHk1=;^E=d>Eb+QLf>G0y)^F@tg6QKKOTq@D3c|Gq==};iHYwwmVk4HPKRJ}$bW4VoZJ}EQq zC?$9Q+jx5{4~ulE>rVq^M={*(EWz&~fkMV5X#u z+p+5bOkva;FH!Z|{OB@taRXAICAgMgZj4h4)levT!arE8CBZ9ipDQe$Jc z?L@|LfKk8XI|f6z8W>G_8ByxG^$``!*|TDQ{q=GM&L(rP5Nd3n8Pg+H&fRy153@H7 zw+3*n(g)TY1lEO*A+Be0ZD_w?SME!P_?!mZH zcZcu!KL}@+Z_a84S0`ltPNEXIjd4RCA{1z87_W{6w0)T;LY6VL7i{)=Pw^u$k z-5%tLLuiet8b03#Q$qr5iK9g~Z$6M}8|gsTTK|Y_7luhvkfg)y7LP<|b^{GxU+P;t zR!+tG$gh41xa=5&eg~Aw$C5*3Cp3!vk6pF>KD-#f)Kf=ugg0@dE<8f3I=5?>z03RJ z1#`xeVCd6YSsgrj{J6D4dKW-m;)pJB@I zt)EI%xau^17H8Vqw_?<*J9MrDog8y}ZJ>7Jqs{CKYKi7~lhv2=A12qI1!6?;x5moHTvhx zK{_TYBosU$Pi(8n!d1tF;41-rF!#`HFp^ePeIuQ`;ktJp@$$li&RbQmRZ#G096Hr5 zJUJOHvQkoVKE6v=#jxhzcUFcb?Tr;xiTgFrsJphPO4MH8j-S(Wd3+8>i1zx*e&!X2 z-#@n;J$5XBq6NW)g8c-`Q;gbTdbXSND8OOWGH_qjch1`yojYG2ufXy$hU$8K9my%!RC)Kp%S@EE4 z)R3er=#gKee(4cP+a_4~5-qWXO$0y$gsM}?MNu`I^ve0M05EmRUJc0S%+LX!YB1my z5fyy{d85>TDP|ED8w@#N$F>*P#YiWvh=UE?f>;b5i{XtBY(FeBg}w=QPh;&*vqeEc zZUm4D_U88u;?Tf!5mEP+aQa0%iG-MRGbr##qpn0c z7@NFxo6C{E)q-epwcH1i>F3A&wPucvLwh9lhD3^CGwJn1uum#>X&4wBj7H;Pq|dV_ zLKQzx;@D5g*g0$^E&aZUA!>14ZrQCTaCDvd-8Gk$QDoAMXk=j<1SDi<%VN^>{h~F~ zHxs)^m(F$XCh^Wcb!GM{Ru#qI6nK=1-@Qu(DPb#9GR|H|wi-2;@YvBY-AlA~P8=g6 zOW>H4Rc#atMdw%Rd$tsaC)r&cUO}Br7mTBEx&V#~aa{SvNc9zq0|al;sqSsBU9CnN zwc8?d&ujdOBp@R-GP;$ZIeIew)aaf6v6M4NzXzXT#UDGgIFrr8{>ar#9q@;<{91A;2>&3v(wC+$SOdU_hi_}?7zW6TWYUO&dQ zJ{Ow3L_ARv>ACKn9DW^kWrukikw8l~;DtnbhsDE0?qso$Px`EkDCWVbj7$WuAW?5S z+vUZTfwP#OX+C#3R4j{8B$>B*o{3@gsPV>+wCHgZsI*A06rAXYr z@3ArCd#Tlt6su86PJVpUF+IfefzrpVLXK1vbH?^3-T=bGf_~wY>RW#3QxfWTF zEuSDT&=e$gx?N_qE&>f80V%$G8Jupnct5Wh^VSYfmk@6xEqGH%94&2bOv~D_;~DMs zPZDL1Fr9aPyzL%%uZ$c@W6dViC4liFJh@jq%PbU%Hg*yG3|I2#Q~ReHw8Oe6NYPRA zZ;PCq>ramRk6B84iifNN^; z$aUrE$}^LbLv0s?nb<5h)`rUQ@QcS8cOB3irrKOeBcgn4Lpk2@GgoE_>1H;1KD;ts zGGH3c5sW@Km?+S!ZvfyWIW@S0w4g_%D5l=N+m=t9e&FozV*eK2>JtD0c^#6*RrIUg zyB{mxf8c-^4%qr@%0GzL@7#xd(GbKo%V;yR@3vse#=TQQrY3K3oaxN;pA5LE9m2;S zDE6C|46PKc#xQjIzpHPv5<10tt!sD@NxZK8p8#;+Fybs4H5Zwm3Vf@sR+=Dt&Cx@L zY>;PT+FR(-qk;QDtVuY(+ccNnoIO9G?CIwx8_`4b_z&{f%M6%rF4vAxmjH&l>On=- z)2-RcF_o`8U`p&9%piO1ioM!iP~gMy*((ytpytTgB2$8qHpH`Okoo{6(DjkhDs0U_$J)e>tUrScRLvxsQ*DY^+`BF%ck+@^(S13{i&s+T|Cr26DeYWsU zgl*#`IhSC^#^aRVvu|JgLi+Ud1cD!mCbXi-GJHfB9oLTRq0~_&@&V>dcSR+o1TI62|9tHTN#+BIga=wtKq6%!gj?WBOiu+WH#@sviN4pklvd_QocjuM5Q<63OtQtwT-zdnL_Azk(U)6pWjORrpA znK4&*_9K0f={tzlteT=z+9mVXGtt*E&Wt$EnN0t1;%m<|{+xj8T19vBoebmxtXddF>d=VI_^KHjCA9AX!!6 zp78$thS9%D1nJJ5%l*&^rGliaZ0w(MG&l|!bh@nN!s)%Z=el?)m4M(-L@Fd)@swQR zM^2o$Vzro%HH1d(L#>b>EA#ppx(&8I@892j2V#ifoX;YDp6D4~5JGj_g3>-yJ`)EF zmc!SVsb$Oqz}Ws|oT(}okdL}T6}3AwGbfiYqty&`M! zRRw09iS{P++kG$Ks1noD3w@;vD}pG@iSI}cdhvoLDtbYTkMG0U`ZiojIMP<7T<;jd zWCg8e)O1AAo!{Q^b96MG`1}n02l)JN$mNkwBUh=cDFnUd=6n^yt~s3Y3Tx*I%3gg4 z%(I=#W}o`~`ws4sXkCM1WMh-l{z)USIax*9?7WM9S$S52^9Fy@Mau0qtnnD4}#ackrMmtl!Jr+K8TQ;l_$tFq@a|EE{Uu zTK&oEp(CX)HQq(Gwl<%-p^0km9Bs`=Ii(#h1qU_V#VD&XMwD*UL*@heKcViOui?07 z&|QO~j#0go6?ROOcqe!kL?raT>5nUjf$k}_H^BO;iVBZN0HczTQ4#lG{%p-*kTTnP zQ`s$rnRAXasBB=1va6Y#GHYoE?KWqDD=rQ{x)?Zj{NTJj+oP@7L~z5#juT(!zEtb3%9*pG2yJ?hMCzdn>SX6tJ zHd5Ry$%!()JeM%IfLQ71-+?D94ox^5>mren{P3Y0^o5706?p!#D1rO?`XX?NVDJD5 zjBH|mD0P^OBTmqhvFvN++n5+%z($)>Co0kGmBZ3YfbK_vp+=5Tgv&0PZT_N24p{E7 z+O>>^ol9gKTP)ETyv6VOngGE7kzEiv&Y(4Bt`*H}SZHhfDT7j{>Soq-!PR(i3ZA;i z4g2v{H3B4vVEsDZB9ghZR-gKi4m?_EHMPHw&JWFKf2j^k=I)SQw8>=-Y2)ML1Nrcv zB2M`%EMIn4rGi?lLtrKXLFUzIO~4n_?z zJs4Q#0E#5sDwdF3p4H|@Eb?a1gEx$oCOF`&;y*;`bhxQbaRJ3RG zNSD6G>$YeRF?$`I#nde!V^Q-}G&w=nA(L6%0itz4P1H8liVh0|wgcyGCPMr0YI?RH*H3#}Z`>+bx}(oUqmuz-E(xw3d^rpdXj_T?4u^QBm&bTPoH{Vi#-is(x->g4h?*%(+WU&RDX!w zHk@=43~69u%N#vNFCJ)OEqJE;PX1f#^V45f4jG%9vq@QSp&6L{&MU8?wzfz;hhP}l zFTH{7WfXK4GGZ-)UH62A&oMu>jfsr>2iXYuu}zF2*cV8C(rPM1>&C};R{k8Yd!m&K zR!%i(qYu`M+A5(y#Ahm^$!17JUXL>=Bj5c4Utg=3MN)=OOI&3CE2Y(BlvdOw23OPe zlQ)AMndGD1S`feft1h>5N@@Tkh50BDOVoY^1R12RzXva!j)0qFrGl?0)1hO`P%!r( zBIulcZ5Ym7(kX^sew~U6igzP?ts5B$cE9g9KXXrx;4 zQ$;F<^Rm~=PfisC&mX9ho5+bj&z>6v<2sq?Ja)Eq;JX3WHom@^Wkk+Q<+zmj&DzII z7(|%C zMI1l+P~0;cM&KaFFOG_uPt+N+M_8a&toQ+1V7l#m%h7V9hrp0P5elYtgf>k9(woGl zfoi|&9W+@NZq9nYzq*>-q)XfL0CdR@`^g=>HSIij0g);Mfx8LDmU?q-d_z&x17kLy^_CDm})iUcvo0B(>gfO%{2aCxrE{1 zgrv{C=6oAJJoOkqzviPuir}k&VHw7xUs(VCD*4r+|{}6@Zp#kY~5(P(Q zfN*F~r-+)`uVX_~lataDcl9(iy}wlj;<6(SkU%g?aCZLuss|=gbuY=vect`JM@HSL z^Dbz-xAz=Tjb5@6Gc`RP$k7eD4Ct>84V= zKm5%O4+--{MGDoG9fem`cUAQ8pm~$XXg+-Bs=|Va&#gNO+s>K|c1GbAwYHHHsaJ|x z1e0dP?t;w?h4$2k5xspKr+gCK-0t}>yvD{b%2taP7q|6at1T;wQeVX`ltF220T+UT z)k*TBk3-^1OH1`?g7W$&CNvhT4;=IqwYGkW00b|fu?*9J<3Mrl1rAbBbn#_%S!SW` zu1>N;^Jry(A`H!n*sw5O3|)SY={kWTW1J5wMYL+qqA~@K%<7KAYY>jU+|x5XZRqp9Sq4+KUf0hT(qd=S zS+s~S=6L$_z<(fVatcz~k&3q}bKfpe4a53cdZ-Q21$QmA*8NGt?S5!YW3UyyB;Lo~ z4oaB;i|Condw1{7)o@4|M=b4G$j+VARb;8kB$vu}05D zQ_!q&K4#4pLL@`#a-rpna~CfBgN7-S_3vxYToi#E7p<3EL|l5Vu1DKLZ1f%5x5FE{ z5CX?!48zXAiN+7u;u?GOmy+`d!pAVBvVOd6_DV>Cva&LA>I4+$E1=W9#!??x*fbb*rF9O7vL0F4YoE&v`6g+41kNN{~}dp2RfYuO7jwrZqk-`%x#v z-EwgqiSpfuQ*mUo7E$7sx9a@#xnCBxZ&K*FVLlpA!FQmi-iP;8c=b#AEnBwat}pCB zOW>NFJ(x$d*RF|v`ecZtQWjh-aRldBOTi*uk@=CEfPE2(M~XHbWHA3Uu4w?{71Y*K{NCUWDos(!iVZtcnV(E~n1Cr_VN_bw%; z==w1}`y5T7!5XDlJo00qe+jyuioyFFYt!D8uRr-BEu`&7gW#z$4x}B}T{M6^6qtQP z1_Wvcg4^Znv~r~dzAC$!&EiSH&rYbK~9TyHBT+}P4A4{##72%I*WH4`*j$rM3XDVRYX6%>AGXL1lXb&CEyjH`0?C=Fl~vZ|@=NEwuufg(=qRlDsjkr{ z?22sFMWkU8SOfvou4dhK^V3V_SDLU=n27`@*l0T9U16akvlXeeut2xVdUkNWi8f}` z`~(4(giW90e(L(7^Q`If3+0&_0Jey3qCeLGpd56jJ8&=9TaSQt+(t&>AynwhR%1Oi z@!HC{Mz8iEto{p|9LuHYFUPsn+sMnx>SXd~Ra zJ|COeA=w%@h9H&N))}BuzL!yN^QzLsn}+uXNF5pX>ORV4kvo_R<^~uaLZ2@s<&nk6 z50M_nRd-+*5?(cJx#lC`C;glfgP9^~oQ@{}|sji+{Z*hfXQQe1v z#GV>7S~4x!VZ$+K_N`M&-1PQMt?$eo&v|W>oB%qXVj8t2P+fEI7b6_5p$+C`HHkjk z(e;NI>dsG`B+SO$s8&pgi$Gzv&ePv@SuR3Lih zs0mSlc14%=1B_>~n&bi$m&M-w-9HOT%Eww%j#s(Xx-43Zz5QCh^*f3>5 ze_Ls(6iOZg7r<^(E)vy$AlZ+xJX}ryr;Tfyua*|w)txS1-Scjt$WN{}b$Rk+{&z_c z%W7&xCzBeU0S?5AEG)HpS1+AAch{41CbF$&^UBMYx>3-Ul}4I`8-nrn{sX&1LX?R=e`o%hcm)xBu`8=}_~BgBm{attSFejwtR`Ai zzh7_p9T9I4+et%MA!Jy3hO(OEidY)Es0jo%82#acoWItG(>QLPoUxuy;#Z8k)A85}v1j)CQGC>@s$GGFC?w=z#23nK%W z)o$2Zf1tGKf504?0HEm9HPsZ%$y4fc=zraJ`N9RsgWOAYZoyH0c?@5Hzd%bu%fK+D z7i^~SRk$wOgr#*Z=o(hZt5>-J7n*V{f{xMK*F|e&JP6$g;9i#wY>vZ0zcOJQ%DDQW z{YSSW%H;@Q^#f!(fS4S?+*{ld@P47P+bz6O-FH*)TT)~#J8{|aO-a} zm5QK3ZTRkQrnk;MG%%3JF{K#$k}Rf;bIRG7x68CjasO%7#!>6}vl|+8AYB0^`+Rs%{Ew_D-Y%OVJ}*r( zGx;o!28Ts143V*-G<~`M)3%?Y161Al(iZHT9B7CMQJlMRqdm!@Eq&yY7yDR-d&8BJ zJdUrvrJ!pjf4Zl06M9(j0aM@?moJoN-xuww>36((zRzR(>?}hE^t`zNGUoji3_g<) z#rw7X&?l(RomcSlRvM^@qASlM%FEBEQC{j)P@J>6V`{(=jbw1_tzbxc4%r=Kod|YH zc=;U5tE&nWEksc{{imo@4TdNg1C+u$i~>~#IN0$|0robg>t?YUI3R>rR9!V=Lhj0} zV#UwT58-rC#>gqzB~Jc<8a;_$6`bq}bHi`r z@yW*Ef3vyl+~0(0q-roD;ZA-buxA-YZMGQk$1u~mKF{(9+e$%+BS@ z8&k4AiUs>1uF^ppxg4S+YHU59sz+Ls=ZSWTgP`RBX93jpM=Yj7)EPlEX&gPy`1Q5F z>dYZ=1+D^_vUcCYNz!*BAm7|CPy#*_M1FcRJDwAaIVKI)fv<76lbgD%;sC0le$hie zcctI!Ak=ONc*++eOaLY`I;s@oLLFb@Dnf1oni z+ux>AGYFh18sG?BRUtf^j$X;}igv>N{fdm2J%zlY!7TYc_z?C)W8HRC@6iz}8<5T2 z33#Y_1Xl=CpDOt;)UFcxZ*zJ86-W7GNDKb&a!`Nns!wy5^PAur@=Jx)dPp^huz1TpCK>jJH0J3Lpb z%K56vyBWn&a5H1FX;V_PFEfzyNiHdU+3>N3Xydyb=TVzu3aOmUvfQW%um}{{ujs^8 zw3CjJv2iq`_q)G@rQ)`P!mpUUQPLk}CpEwV49M%34|#c}5rwviKTyN(g*3XTUYndB z4G84>sQ9%e*eyx|A&8d_ez>-DS-PdEp`l7Ir4X-ee0?w}&#`ZgJuo7PX8;+q&JR+Y zA_1){=j)hG!1c*>ZQE9ZZdaT^m#bs)arPnE^z~80k94OZVu6VIlwJ7 z9t7-(t~3Q0p)f6v>MW;DW@|jIzBQf88Ui<*KOu2Z>VC~_3f4E!#vSmxBAC0vH>GFl zRwPpO%1u8RV%$J)je)OnEk;#yBUu%gKS30p-*RM-i%kav1$CD7pOTU5Dcr_n*j|0C zb_7t4n@XhHixSgDzD7)tH3__{3R2&Z_ZIsQJ|2d zWE({Ysw8(8thS6Tc_tE3YIVXYuwY8IeFK3VYRF{iNe-VFlV&!h292t}P5u@v`xX2c zSR(THQu5%V1fD2IM%8n_MTw=pca!jz7th+iZ(n0$T$**=Mdt0S-Z`EMAv~c{i8k;G zXp7`l^|Tl(yqRy3KEjiB_N~Y5V<2%MX zjKzAM`<`=N^NMV_BxHh2Unu6p94kavouKZ5UeIu~{PlJr3)q3E4T1|p{s4!cdKuqD zLFc7bf>L%<)Aq-s1KzKJM$wgH%=pqHqBM4(^+l%Ib zdIM%-s#}#^Cj$9=E)Cx%I#6;<2hxZEn67I%Y;UhJ`YiWCc3YRzdbTepwIhB!qj5wd zv!lpmGRSJftQn|~UCfher;li@6`y04Dx41ui|m+aThvR86wIvr`E+p6#cs9|pd}34Pw!Jb$i9F4xmFNi1kz z`!dr9!E-D40aP{;Hr-scQXjU{PfjYM7aRF;?+@)aHw7esl)E%2A@K{I#49-r33C5hmUDv%cc5U1$ zm)F-3U!n#gw+BSNmWPXuJKR<6kk4>c7dlgM@jJ6fb?d;pbx#BjUpyoEK}6j#LwI>9 zp49P7jZ7@p>1Ds1SdSn##E5^(SQelR7NV%5vq^N=Cf+%nx*-)hn5Rw@pD_77EiIaF zb*(>G!T%Vx1ETtm=;MWLKT#TV+oRq@6>$7$Vqy|N&QAfC&j3&T4as0Hy+)7rkQWcL z^79)T++FV23vqMXNJZPPY+B@<-z9k(kfj7xL>r?l_s@BFO49?9C z@mH{4i-K}`GUv|iTMVC)uAJLU%^Tv73@({LDE>m9Ww!JKxuW7XpGz=j_JN46#c{)T z8Mp3(AqlS2^!BvFE}s;vCcr>^RzXeeMzYpDe4M^Jy+Sq5$a&ZwB!|sy2<&%t4xQ)y z_|f$BrVE&Vp~Ay3rfSJj2nH|QmmVq7U?A*CsMP>#UHR4Ru_6x9>H#<3+jqAY$BI6s zrKjh3>TXS*FJxz~$csV@fc%#~xPA`D3p_W7B{4}Q7?2?Xtg-1jA*XQM38Lz2 zVFut#;~Np7v=1NRB___STljV^2FopXGuyyl z2(CIoMz0e1Z|ls|68cJ0;`-i#B?AeB%H0>i#+`bWO-~OPvz)lBEG-B>A->n*aQzXa zE%=_~1D6JW!C~b{>?}ko9Svh6UFfRWx2e*4>)Y8WxZAQB_E?4&g6)3K?(yAcYHEJf z@B#J4f!*+5E&w`k*_%1WCnxtAG)n0?!Q&TbV0Kt|0TESbNEy;G`B%aW47hDUp0JaZ z6QqD}4T{Cq(3mZR z+l`}@2wm?;_?Z*y`+C!GV&Xqz^_PUAr?BVqUQN0Vp7B_?Y4vZPNs_;5eSFuNE|CHQ>3?y z8Ww&S+5xMoHAS>$^eCUVv zm;E7_BI%0rvo6Op)C)QGJsWd<1i;H^0Onbi9{ALH0@e}Az}*_Vwe!=ap{?z1Pfrg7 zz`c->X&wGSh$6U{rLd$1HOl)<#^EQ89Z6G@&J(k)s&H_sqw+R=MjNKHG1C1)R#tg5 zxWtlQ;BbiSkVGWDYic2^g~8qhG=+3~Iy7Zz&S{%uzc zS2%`5Z;SII8QshGvVSSR-$JocD0s8{nfL0#l7D+9&{I@_GJK%(hv3zI5_i_B%f%j@ z#nyy=S=WU8&aCq67p2Kawk7J;Z%uJGXwBdMmH79I(r?m0O}R7M^=w|{7^(EecP?A7 zujL+E4A09W_qt2zxwn{v!|4KijDU;)8jX(5mP1Pwl?ZUOrycndDDpk4q1uXZZ^j`h zXLSDtlYm)1phh4&9U8OnR98jCuZ! zW^RJAl7%JRq@a@GFfF{k(uo4@3@nU{Eg-uO4L^8BPS(=!{RypHVvvy2*}Y?3L&IP& z8_|PsgCRTV@5fx8*nP@Z8km;>#2m8I8Nky(ysDrWaaYXtP-YOX_^1;t452u|f+i!5 z;AUL0ZYH*N=5VW?O1^ShJg!c_EskQF|HzvJotJL=7cVC|gfWc@YSS=r~(jSEvB zfb3}LeKZ^(nUl>2;PdCvs3s7+}vvxs#dSW+Ba9b>>`N(enqE>>byhR z*4M|HXRYPzd{w)QaEh=m(ByT_ow6t?B5LC5z_YIPY8<;vqTghLfxMC5g~xK`VGub6 z-I)*w=xa|Fu0gYm43{ndM+ORVXA@aPlVRCg)`;o%BSu@$r9a~v=A2c?>8P5Ecr%ms zr5kpo;t+Sid6q!EIkv~xzY3)lPfmz8u95}vRKVCiNKMXYrhx{l~9AgKfKma-><8x3S3#MtUu`Ch&7LYsiR+Kdg(VfTgpP6fU$Z z0YGbAC#v_kEHAeXmxsJJZpEH3tj*~fa%h~%zTY7iyoPdgvX<)kw)#NH@#^%p8@Y2; z{|Y>%EYC_>4Ju7XN3utF%jn7L>+81e*T}9AiQ$C>p?gqyi8iOlCo4o?x}cwL2a1aa zjpP+oKrlW!%EzfnKTeZKaCI(=-3g7mcX9AXPtDV%zdzI`pjITj%zS4sYeP0c%H<}I zqgz+HH}24xLTizhcKdDBXEt{B0dY-mw3+d6Bg*1Ne^<7Sfv8tFX+y)q;M%J9w(74X z!z6^Q@K;-TK$l%~!E5Pw-I9xq8szhMk^ zqtUnooEH%A)*sq$i;>mb^bu1>}1~C++yhk&v4}SY;aq# zev$&}O?WF{XuW?(2Dtp&pI0WPQzuxU4)ccI1xL1^H6DzP9Z2OT!- ztu6km2P2e8mbqMQ>N4{*QH6ywwaZgo;_NX2=nH*sp~fmR-~+?JYirU)fq#)J;5On% zAITH`8{kP}+uKHG^g3I4tYq~r_{e{36e-Qm$A;_;Fn;$nk|Yq*?z*}?2aX;;o$=vA zq_E5B8)WDKB4A5iYz1o@jhxM{m@veTl0AnH`Hy3jd!#VZJ|E%)3|oZWv%>707|15F zC}v2mz!_fv8d65_1vHTg2mI{f4uB;~LUr{m_VM}gw&Ekoj!k4jf-gQJ0SzN#SZsx_ zMcQPI&F!m`MZ?CIFPWos&X3-Gf}7d++G`WBx_Dc(!0}vsnmT^`TiE>~$J15ix(#-r z7$GO@^r?DFI&{X@jzLRH&vhNa3cB^`(xJ#|BRKrK$j;2AwxMAgfeW0L@17SI0!->h z#NeNu%{7Ozo{$Xqqo}@lIfOpm4q}sp2MVMg-~Cm&``G>X_{-oZO$kA{a>_V|AEz5_ zOK3^-x`A=yrQa#kvd}`gP|`co)Ea1l!s|LYHl{cZen0}LR@Z%f+sX!-^#QZ3iLdsE zi$SWcJzm@!5`N%nOZezf%UHD)M-bT^!owa)m-u=KH8qAvmg@ktt5Mb@UTDCbPxZsH zHut-ES4|nZ1q)2_Pfe8|^c9bQun%H`_kv#`$#)0;Dw{D@+E_z_;>m*f7?qB~{`7U93A~QSEI_~Z_VZqeaM^0$+u8D((gZEVrquAs*8~( zFDIul^2zMt!Jqrjt6-RY>=4&F#zNFYVAK(wnKhS)Rqh(wa81%$fVhG@F$~qM_>E67!L{?+=S$xpaGhN_`ComK5( zE926`UuD+E$C_pqPQPiZE_!WR#QWV7Yq{`eFI_H=wd-m z@97{>1^=CT^+2V}pnLnZKn2Owy2R&<1gF59uo$qz(H_ouq@3h(`WI#9d|^VFyD#1v z!GLJcj%H*+df?t*`w$Xp&}RTCMY!FAs~R%kyY}Ka;K)9`@}y^>>0V5G~PP{nOi%hX>X<0A&?l-?q-K~#5UneTh2TGJRHwzzN72zE|wKwiWnk~XMdJa&_GE2hx=wP3O2@}S@3$S zl&uVTW5hsnDy4V>CmU#6^2=Dn_#{MJrg#y14Act@vhjUeeO4#C$Wqah`3V^Pz2JDD z*uZ{$YFMCk^Af63rPDcq!U&+8Qk5lbMByE4+!tVZO}4HI*l zYmJ^cfQ{7hm&LhXvq5bE#Bxsl73Pv_m>rPr=0@o^bk!XyGsGDP>{a*mc&WO8U$*Erx1PmxTl?ff+F=QRxU_iq4Nbe74}*7;ay zPbpBKxYb`@kuu18Wnem#2x-V6L-c`d601BF}N6OnYj8VAMCM^ja?_;fviTI!2w#(1WCm-~pnXnZE~o z;DNwe)&T6JCvF*GUvVnG&QBY1^yyQy-Oi!st2|hU-4%^*1ArN8P>N6n5lk=_MK1iw=`S3pz0SpGI z;=fG2DgHj5z$WetZf|P3?{pTz^$X+;;JS~+XSH&?)3&(Q?~?tuurRT?oc>@YqYpW@ zG0C3_+o?Vqs$T7E0#%qB4EbL}utp~v%?3=&KOd}#ghzz7i_l3&Gp)tS6uo0jdjj1V zC;k*1%Z1D&uC-DW0Vs(PpUTQgQ&$eMS<}c9i2VHg3LTDj)ZFud0{{ti7|K;}YZyw? znl(TwimesJboJqba?UL^=!Ob3{WCMU<>Xt9+(+EMo2E+Zw@u=n2`i211&|yPak^Am zG2YnQE8;uTMsdD6b!gCYunf4LVJg?HUjZ(p6nTruirGWPaS!3vfZjUK>9yj(O$OWK zk`j)c`fmL;#Pp5n#oKfPiJd6mwVb9VIX1<`s~u;^vr8v1n#yfkX-snYI9elTmUknH zW$*@t%QbehwTui_ncSyOZxU9o;7;26lev?9{W|w#>Ot=?U~_UK=}LSAyTSJ+VSAx` z%ckBCGQNgPmVh_;c>%boGX{mn;Dsk?Jnym@ef9ea{0dUo$)Xvh(!b$WTaY_c0UH`; zXA#Kt{S2(55)g-!RlNJ@(#aiq^Y$>wTbd9{;@{!%d4Gj7PfAzjvt>zWLPE=_R(tsX zXcKd`JiK=ZznFVt_n)Jn3gw1T2);=D3&YTikVo*(tBIGU>Tlo5OLbNADYA|h>4nJZ z*+5Yg_rLnFH*@`p*E~+?8U+c!VxQpP!Kz@$Ihn3Wd!VH5+TWAVK9>${oR44iqcu`~ z`={|FgO#F^W^h@!|3)%m0QBh|p^fCuG12||(BmXzT>{UFcPp?*YiV;T=tN6TPY(?_ z85#wqCwWDT^ecfL+G(3@q({NbRm-9uYuYE>aVbFO`zc}lGgvFmC=raoDe2C9sg%gHRt9VJqPVa4FH0AEU9B-v;TCVyQin|@_f(!7((ia z(v4OPO1MU1^NKKuMe{WQq!{L-D=aG$d-jZOT{F&}8Dx>N#Ig_%p&&bZVN#gjw%r87 zQnkeU_a4b6WWrYk55(425-qt1EMrDv?1d)rz$I>X=jUxqYatw>h*hm$eO~o7lFY zZLvp&o?PtXqhVqaI7-XQ6FQzQk3x8mt+qWSn6)8|iPJiC2<(=}hS-C}2ocYwLr;aF>kZMtSq zh_dEpi<6c_a`c|CZA;Y@ad{ox8DEv?s0UCe=z^Xo-g6iWM}6E9_NJH)LsM20~5JB zFDn6u_yKurXa$yXP1G=2WnW`POzSSaVBb)*wvHSzoq$nP(BWG{U%uwzJKN73axc>r zYp<_X`2yoN*4KRuha!E`*m!tD6*hKSX7S0of=IK9(;K@#Ls}XR*x>tf*SxqHyOUb} zn(8c8V?INgc5vfj^w)R^-L0+64@A?heMnA7yZgPf(D`$cxcu;))zp~s*~-d#(SW)N zZ7E{-UATyKt2vqxYQAc3zx%vaj`-2k;t}WRsa6l46K<~ z7)Z=o0yIWHH%-|YvADy?kjBQx6?z{iSKb-I=&W?(68?!K(uRI7lK7^OI6oyZ@sjOy zh774OCDpipWk-O+v2J$NvrnJUGBq7wWB<+aNs~TvwG9gax_F=~-;2zSsj0P1aScz2 z&!1C9EFv)8gz8BLOkaP(&*0?bthC#&PTqf4^;x8k%$2_tV23+gx*&nfaAVjVQF16w z1QE(}l&0<%a*L(J7URbO(}`qc6N(+7VC;uh|FgOI-2NPu+0zr{F$)V};X5G}a5}}W zYunS;{JXomWLHr?RI{wqXiPdtS-IO+4!(M(Go&zg4Le>$`EBM zQ+`Okr^;-SZ{6vmSnfE9JJh;}tzgm+iTf~pI$8?q^`@U-4!~;U@!AwrdF56yp%ZrG zBtb^g8Lc;O=zg!&RISDbwveq@b+aXzp<7tkVAAfCmZj?xO|ZNEwYWH;64ffRKU7SS zW8*#zmOr^6`M07|w8v)Apz_d~!*NjNgSuCwe<>=j0NfH-2&lBx`?!56uvt>Thq3hm=y%BH z68Q}+0a6D}t|!=nY}AcZT7>O(Fq$hK=gSXK!?#vc#D-|PArLIf;#XtijlqoW=IhJZ zMu}nGce^&4M>G>7nW8-=vOAgq3E|%h3+K3r^K)~X0eATuR8m%k4h=cR(dMc9*%PMR z%yHT={r}VGh}}2fKM0ZRw?<>Gg9VmV*kiSzw=JtMZMhhA4f z|HU<9cU2cHv$z-+%CP-0BMVFBQXB<^7Y5SWf@pc&iS2y(>pOQ!pTyVHTwPVuA7OZv zqAa`;VjP-bWn^@(mM}5;cZs~<=PNpl3Qv0{`A$<)<_}+G^(h1wbx0(ejIyq4#ER&@ zdi5%Mhl(c}d!z#^GA=q=yU+gr41Ej^nA>D*f`T#-Yy+yKxGf%?j-w$mYktvlmHKDi z_wJ1zPyokG%=Fbm+<&uJ>|hWGBH@R&Z2hK!?A~Cs1wAF&1Q-+f7=B%&_IJYbDS4w)}6UcP*;yWk|!QySO+36;pm z?+vImdsKCF>K=4r9g_i1H*JM3IsBp^Sd?tp>JSEoq=@zDSL`S9YUoojRREveA4x0N@NQmE%= zzX7HUIZAnX9~)S>Ej^`a??>FL2lZfPO2CF~bUc%+QO=LcN{d@>ot=@Rg)TodtgaZO zn@>jKbv=KsA!78H{s0SY&F8MLMgUCsX_=YeS56FdIwUld_V;On##w>)VH~rO@W;tT z%h$(y-`!tkWS~<9!yF>@%Kwf|4$h)GM0Xij_?b&xLhu-LAtK*xv*dqT(veuMgtURN zs2iGP-%@?*T;D*+Cz+b)$i&Wei_6=uIlzs?)2^8Vq9x1jywCBWDP zH>ARjwl1@(!lLc}?j0_ZvGnM)k8SyqM&CdJ;Dz>t1k&J?XJM&o6+~B6#T&S8;~}Kv z6j$(QB{wLjXGln+Mc&ytKg%Wzf=LHy1?_IegCL|eRZ3GcLA$AKU_iHLsCvq7cVJdYEDW`~*vo#*)HO;XU#?z3K_q;2XFahuo;OIZ4SO$N z4&0dC!PB{^wN*kB9IkLyy6jM4qe6HX56!!P)!Kt~GRF=2nA{H2S;XL_R{~Q+UC=a~ zNLFCV>yk5uzF>u2Cz-Ibj}^s#wlDvdFs$m)TR0`;Hv$&hn#+iB_qC8NkHjsw|N7CzgpA08g|U?Dx$8f$<^PuyCyIitAB{vCP?Qonb*% z1(ymlWNPiqB41sRvwktEQ%n;ovBA#Qs&fVOnn=1&&~)svt~yMTBYC81$CSLEnGEM+ zvWIcXQc}nu1GojMO4ckQ!541`MMOjsDd0K@NyO{mKp9+3ZooWCdTKQ}cVOe!*@M;r#`_^3a)0itP(F6np`YX&`tkfSv&6k$O13#r6lP^zheIzJMNx7lW z2KE8J+S=Z%3TYVb3=H794@1xv|FAU-J6P7v`JxRFNCw8;CI_dLF~qvMDIt-O=rAG< z)yvAte)_!byFE>bO8uCTQCxT7CLrxZ;F|UGp5@w4k~uyxW3hL0KxlZ-HHz9&t|~K+ z&wZ}0x6}O=E$`>IZ@P)cX2^5j-9%`ANT{mP_ved=Ip6?n6ywjp$Ve5|jzdZsNSA1< zs7XOYB&y*ZSM```zFuVGSyp3GFOoRJqx0LZUNK624^3hbAXR{ye`e2MC~J>M$T56v zbqXjK0Kv>sUS@x*`o~&+@u*kV zopN=luXlcVxf?NKV|tGv)ozFevyn(wLtvS~eRtR`!jnmneiJDP z!ZPSrE!#N^WuJCxbWFpQay!J|F^PC>bqZF>8)9KPoh4;ZEAOl(<2`bFV}xj z^}-~yiNh}S!+J0RIaj!jp5j{$Y|W=*(^GB|B&{}st5ia7-g3cY^0h`G$h+UFGtOl9 z0fqu_Q~G=CcaD4a<{i_06|#mY!v9s4G%CtEIY67L*EZ1M(}vVU4v)kyi( zm5b@|1%c%QW!LhPleYlg{AXx7A5q^?(klruUyd`6jLE&`@D%h? zW=LOAT3DnA-$N}i?XhSa+REnLw1(1@N5#PFE9H6Cj!{JZk(poO-%s5|PXFTus(b&$ zJfHP?N)d5|jBoVj&>;q`;;-K-ACVAWm*erY@$dKSb*qr(?GYTz0*rY7^jD)P5M6<4 zmoMV}+w+5|Y3XY^14tV@Ilqmge^#tbDeR2qa#{8&q$x8m?}?jR^&D{VTuu%K^-()z zZ|+KdBh)* zwdXNn*C<1bww`?9qa!2h(`}vp*qaYVbP^^jeC{0S8;47!&;7y|J%5&8l5228Eh#BU zjUnwWN+&FQ7rmwFcyUwOfQQ|Bg}U36Rwkij_fB;SLq{OzjL}FP{TU^H4VTP%oacMv zMF!^g)cA!`=~qX+A~2!#CR+?Cp#0nfk!2MRKxu+Z8c=gY%h|v6sZSLNSUO8Ub^QmX;;iRjK|z z7&mzS)p|II0k4t5?y{+I{O2fo9qTPrAop;N@pF03Oe*s|Q#!HaL?tCXmn@dUOO+(h z4eld4aa@Y^OHEDfU+;p-URtN)nRT9>%Ve&ywWe^8{g{zs)8;o}v>eCUJEKw7najn+ zMW4SPrzue14q{%Y&)N_Y4{`PN{j1()m?I-xv|HEGI>8Nu(yFGH^brgt6m&4P#&&+p z%#auZI%QD>fn&U@`I$nAp6LhHaU8flo>E34hzF>t`cx5q8`V|*AIwA}p>{eRjXR)W zVKw>O)0^Aa=&ivSZSLs6MR3lnw=b|lL+=GT(*gj*pmqt1%(87^P$KEp*EL6-s{xgz!)l`!fc=!-yF8mn`N%W{Bxa}W9Frga-8jEAESP>3}i zsVL1U5sZzO-3!MTp>(|Q4c1`LUUHqqI}(3}1sr0F4%-h1{VQ6VspE#AiOkU$VOCZm zaAZNG#CC0XjKyC@70uO~V_@uA)IcJ6w)va_H7%`T!q>YGN(e1D2&d~DBrjbgBp5>L z!0uH3xuoP@1Hc(}^2dc-R8&xUZE1R5N|?T;w%l@AUpMx(p?jUhh#!)XpOO;)`^Cx< z6SKT`{oY+9V-x(Lcv&B0zrKA=8;5V5?ar`0rQ+mFf4TMJ(Sd{Gtn1jRJK*MWak)tD z{mOhc*csv6h)2^{akU&KM`Y4X8y-qSNmCAy=cOdRfbKAarRM+JPg~q9_TlWnp|+ee z;2x^jUZ!r=spDOZf07_+CDh5%#2U0-#M$8z(4BfAQ(?_pt5dH+YmSEz#?5Z0cQa4q z-w8T8dQQp*`)|0|?(k{-zKH4n5^d_U_ZU11#3H~HPBu-zi;NDGJL0pFpFTb2%>3yh zthKvm*h2MuO|8u4=B{p2!y@>)wZo>Z8hw3u= z7ecq>H>2|72v=Rqm zWNNoat1XfY?9EB42d-lk4V0X;w73uvt#D0%hnH*NXtO|ayfe$O!N=-JZvJWwahMb| zHL)V3#JiB2X&G16bGpppS*;z6Kh`9ykl&0U*L&p#mklOr)0cleuX}&vCyRSgoG~Wv z+pP#za0v-hdp1o?O#Gn<@6T3Zy}0CuJ~G(mJ0H+6fCYg|u3z`HKa~z=5+5HQs8xg2 z*K-X`zFYh@DdO)P(8w-bN;+ORgphU4r^KDRZUs3`OzU9l_n5Yzd> z!YOQ)@*R$djSUNuf*awLhBvFy1Lfa?^>x8(mawVS1_s6uB6mmf%O;VBD>nK3#%k{m z>5WMnBnaO5IyGh5FbYXsuKt~3R2gGT%%UFhFbx7bt&gX*MSI7!cWi2K@Rc(88oU|z z(K9=vdKWjx!z4b)0mT-Up~28pAgz~h-9kl874w$eJv?0OemVdfRv^>`!lErsr#W0czxJ~1i^Cg0Z%2P0tG3lOeu|w_Y}mGQ1S8Me*d1f zkdpjhHkW)B==AhD)p2o|bBnN`KS0lwP+Gj&v+{9RnU--qh04=Y93yjv}CEUfw=x zS`FATGC<7F$AOYox;+cJ`3@e@H?8J?*!}%$d2itGyHfpI8WpNVmygWErLU+%ebngA zz?Akpejk8)G<5WPRso8tqtx~7l6DZptOLkHj<^^({!yDzwMi+r^PazqvM4gO2nzcu-J$s#379sTDwnVI37tqmpT}O5PWnt`_6Az+d)w5s*>&!sg?E0UslU4dI6g;9Q7zX?gd#ru@zh6Yh z8sE&t_zy;ZD`2NLA}lNrTHap+it&lp+tWk|KmB==qN46@L_ZG+6Qe7fDlsHYKR3$* z%0a49=ChQ$cw#0)QS3>68J4gZ#w6o;TQ?n&+uqX?)aR6zA%vL{nUo~?(C+h>FN%{_ z7MC7+y42lgr!qQi4l7+R`;SNg6hRb0{a~svmHS(MCT}t@V2*y+^0dvO5f3`bE$i6( z25rCJiy@};K5BPum0$3~Ji%bY(5d&-*V4+L)6-`sURcq1zLU8KMPp$UhrCG?5P$4zV~T=#mb5c1N?Rai1vtR zWgXqdSXf!n6thd)Wm90e*Wn0iTILg5bMr;UTkGpaZN`LzZ{8akt_zm4AtP0d%1YV0 z0vVy1-vzkMcf`z*U|M?$K8ExsQ{v*D9*w`pRW&}B{Pg98@31-!hurTpkD~`Z^h4Cq1%=~vqs2hG` z#@f#Sb-L2RVIb4)nXNUASCJQ)Wod^#@tjaYfu8TqP4|!xdv6Vw>!5Y^Dh*j8VYs*& z@5>2f&wTuZH_x~Cm-ix;ROVg=#=S9cn$bPpo{e)%LZDnd#j>&s;ZLTtI~>7~57#L& zFE3$kt_s)pdB~u-mKJmM7rQZRP0eX_6^Df*1)#J7);yOGRmxQtkpfd>OO<0~q9Y3?J1n3(H;ty9NH zSLcsI6r}e;_%d{SXSDwr_aMDZxi}RTAvlu&n8R+6cI6uA+s!AbirHYWV7@XNvA1O# zOm^C~c`mFpt_KO?IQzHRoR>w9%(nl34&t0-M~*9ecr}8ao-G7<#%LDUcj5c*wX_xq z*@1$>##T{eu}HFB(aP5hq~Bn=jRDoXbBH5PL=cO_3=FCp*57=WpBu`isYPwp<-q(v zC=`x62^0bxM^jiK2F|pecs$TZkKnp-Qw-Bt7#Os`UE~FBn$PE=j!-u@fu4#brr*fu zNH=cqp|j_&I==&q4RliX9fK`b1l$XL&Lm%{!+5t>BmG*&^mvP3ez(fZ%&f?G*rxgP z{FpNBu+C-;=RuD1*4Ebi+FI7tqCq*K7*Ktj&$lGg(nDjcJ$PDTim%yer#6|9SZ#OF z8rsfJP84F~F~{GO;{g-L2Z7Wc#9v&q03vCzfG08vsr8Pj z`S6h*+sdL1@q-33Y;04k^`o5Q4ae?W>oYiW4-$L(`-@r*zzG9oh)lBU5+MT`%g@gK zDyq;#AR8c21jMGuP~Qe4s!umt*HVDA+iVTRKP=xYfOGQK*Cw9*t;cyUiC{g}I2!eo zwLfBS63>#pzN!nF8I)c9$)(F>sTTlO_M=q8iOc!EKNl8Cnt!j^luJ) z#Si#G6kkPL6U9kh#&Oy3*4q6$Wd#3D+1XW@)o;=`B^G5Bm6s?UAaVDZTdetVN8ewR zrU9<=RxwEOk&U>s^`o+wC_>6J>#>Dionm&{nG`p-#T_w0!8-als(S5W4b{-+)b|qN zhgwdQGZD+l>;FI6VsjLjKf*6PY zU@j7jU;mvl=t|*~JtX+5R2ds(N9amhLh89 zjy7TCqs<>&sne#F)zuH=QQas?V|(QuSG~$dL{mj|Kq+KEj^Ijk{2VkMd_TiTZe^)= zdP?s>iQZEd#AT$-8hiWIYZUka`invTWeefE7IVLn?2i-J^-)zK6J#V3frQO3N(6Zn zRCVJeFk~dl58;lNc%si#1rV`Cj>*1yC9a~9KH|xefO7t=XaD@v>X*8Com@Eh=!lwI zZv$CacdGJbI1X4+6}0aZCO=5}EsBs8fXmE$E7o*t8xYiqz$>GF@~wr%J(ZEnUZkn2 z`+577@aCiFze5m~<`x%U)$U`FzTOa!V2%Px@qJ`u@?m|mNk2;u7y^?!GGr6J&brK| z9FGCr8~;n-+S&^Q`meJo;B=+|q~Ppi^BP0B0b4=gq%YL|{x$9hPo@e@4GkQEk^4ZB zw(k{w|NBJ;8b2qe_~rHGxy?P23C+5#m6af{BbxeIAk6O%$PZ9}v&&dwo_;_Fg?RE+ z9uTZ4ol50*?DX_f=Xb9A?kJY@vUy*1M#J_eIW6s0PtRTQ8PGnsyIfzY%V*TXGC@gS zw^+NBKv$QV&2+3O`6gh&_8f%B&pv;q24BG8S%ViZ9)m_|_~#F0hKF=?v6uVcRe-CS z3%g=AaAN_*m+Y#=>(?Vh90s*;%A+suA}uZT>0N;?rbxf@7#Qz`0*AhUx=fpoI`GWYl`NFV;;Vj9cb?o^TB841qubVx|h8;k1d z-T=h1i@~dbl>_xIg;%O%6a6A8Gq}?|KYEV@qaJNJ0?YOL>4F4gE}vo$2AWJK#Wes# zfUTfV5o^U95hY1VBXBhKFrME=s-WcjWG@&ahrde03${q26bswiUz3xWhxRRExOY4W z*j7#31v>Usg@{{Sk=;LmA`7fV`y4JH!otVQGbJ*_snp;!xX5nk=LgHErnshxj-|i_ z9RxshOD(vzf9x>r=ewMTBghGSD6pcFDKPz^MKSXA33?2i3sycrGaxmY`5*TaT(}El z?WDD}S?`e%A%QI_>GJ#6Dk|bUEYO-$KYfY`Vjs64N={=YmDd{2C*2{X5?9ahvS_i; zW(Qkj^v_J)?MIV+n66CW@MrM|3GZ!i-@Pk$a%%MqEQ&`K5ex#^O=ttrXH1#@t+nA< z&j~>t(l)zfedhS~EsL$Kt>V|9(`f!Fp2RRYDfix>87tB215ohdILV9vV)|};v zC|D7IJKXbQ6D+4mm#h72pJMZR)t+`F*? znE?_=-Qh9Tg^G%L-;&XioKCtwF?RA}W|g*efHYS#VxDmRr+`6iVtv~BRsKccQz0O7 z%id9_MEp^Yi)T(Nlph~je$1et8ZUW-dxz=COA)H1l2@0a1F{=-WaQ-8e0>pCpu@gW zUWo^>+*<8uE|c2J2M_g--`FW%2dJWcz{r%NOzi;0A~ruie}a{e%Zxv2yBdfLGi@mI z_vMeb1+D5|VPIegTpn1u<@cJo!(iujt9R;b-hiiXbOq!tUVvGwR~-zuA?li;Zca{4 z%8f^S{QP<+pDz7M?DnZm7d74x36D1F*|%cFAC#lf<%b=w{^RgW__ z&Ksp+;~u?K{|MK2oM$*Q?V*C`FevWsPugtvAwSjAYcB>fhkKLbKw9F(cKQRK;BtU*W zr{$^YU3>tap#!8bq@neWw5+J80U_U6cDb&Ld(uNBvvRU0#^Zc}Gvji7U9>5K>2+^p zDi%yIG89alV6pJdIZ@kXTY^(6oytjwQ z8_Ql^b&hV%B>c&pXi+aJ=#^Zq{FT|!nSrd3wo(VMRdD!4Tl8X2>Xi(41=$SQBq=as z$p485lMnx?2(gZHWoCY7E+)V^O>p`Kuw8*b7tUpYfxN8Q*w`3}S%7vi6>8H3T)E2f zW~8K~wWli%YUej#Ro)TmAxF@(rCyqs0Gj8l;?+cTSFpN8j~zVT;Pzn%_N#vx(bCdy z(g)5DKW-9jz<%H!n9{SWB!=J=sAQy@nIgFYu%e_7%Tw(5`1rDp1On!Zkm52>h79rj z9C-AD{pcSFkLl#b5kx&+|4EPxAmfdU^?w{8^;}{`JK=v9NBtezf2mMY_gE*XNGuEg zd!u{;Kg7fGoA8A*z1>As={clg|9@|j8$v%DEEW^{>;l5VIJ~epWWR|x%?4Ga%8g$4 z3ZG{=Z)Im1V60y!+8bjdUip4qw`=5Q&vE z`Jq3wkoSsky0t*VQxqCMU_jEGA=q;Wgqn(K+SY|q&|V?H%qrr!o>S?UO{%6!$SHWziqu`>7?Uey>uEg~}@r?b5y$ zknbMT^#lI)0Ho?YXNZ1F;kA;IJ5&hwQcn;&EiJvjuWxOA^tRN9zP5Hk>3nixVm`q} zNaDq4pG28TKF0)DnQMRekiJip{~17}xoI2W70w)M3yT)IBuDx&ZM=E006*C`EsO&}y5{ z_^%EBxO+JBahA3O=-Uwrv#~H#fHk#W5IO(;j9e7+ciWA;yICf)^-f{yNwP__C2738 zyz>zEKKKVuK&SCOkP}%C&0fD2!mHlbm$%)nLgBkfSpH$r6<%aZ4>n?hL6;b}IPL-^ z(OOs6AP^)zs+~=N#b^MrB^w;TA#;A~3*+X!!dM%!fz>5*bb#;E@%9f7Y0j)au23gy zS)}@VCn%C~VJJ_BcUD|1XdnwnX;o5sL ze>ggueinm@(7D15jdMsgKYyOZHVR8iFjpf+ECqoy+B7&w3TMB$)~8Mi;K`X^M~D5jvp|6u=y5dbK4OA{Gz^2^cK3ej@H(jerJ;iq$}6SB33odNavyvh{4vpy|o3|KS4Y z<(!)8y?5%SYC=O_cLS5DcpFVXSP+{3uEa~s!^!2&RSJ6O%GwenPvf}C$g~Lm1DQO1 z7hPMFhyXD`l@`XG2$a=Weuyo-cYV1-t(D0MH>Bg=J9VuejISrtUp|S2yfe?c&cQ}rAE4Q{aM?5XBmk?UK&w70%g2U! zYlqVHc=j6eRa8FsKg`a<4W)2S#``&hp`@_zR~w~^Cs5P&dV+yN!V1H_vFzQU+ZFx+ z0m&Tz2q`$RBh?-Zm_&ov=XFg6{LcB*;QA#iGyy9=3dX}gtY=Ph7BX}tXS|+|W}MGy z7!-0663De2w#<0=`IR;@F3+#idY(Lgt-pzoyLG(v@V=HAF9g4Iz4mV^wLO=B4k13V zbm9fx;iT~O=AcPU%m2cwKsYe*0y1=S{6FE_tv)UaxTWAQCl6}@_)+Ic}i(}(eC{53KqU zVo3&$GAvgg)QKp`h<`bFl5=-(Gj)5WuE{||QIUEt0m$iD6%kVU{&({A(fPH+v^TGM z@s5a)Tx#9mB3Ufyjno}TqesKW{t8QZ#F7ABaGxs6B*I~D;*vyrV%hI_ei2BgdCBa&JO4Z;$a;m z0_}eg1(u|}Jpi5dn*o1MA6iPR4(U=361$a5R6#boCD2odqI9$8f&g2F3znYHI8caWQNiD!{+Bq8i9jj}Y^Mi3ww6 zUdQ`kkEY0(M?Q9 zzif`(fko2L79A&NOvi2mc&ni6>!-uYpZXR*q6kU`kv#`;1Yb!QLY*3%=t?E#rL^kn z?|*N41%lfH;27K&#RKLqJhwLR^$dxP#jMgmg|-Wb@b|nn!I;O*<1OGxu)ZP#^5@HN zaD65PJWa_E57;?i?;{GgX@R^U!qG+jM{>vU)}-(8%sJl3$X(Ar00kqw6~K}oO8ry# zX+WTa5B9NX9x6!k8X6uh*XbyxAS!QO>tFOaTArT=y$NgGs6&%w-KkX`MEw6Eg}BF1bW)`Rw6nim%! z34N~1D0=vuV^x-n%kllN%z~R59ilA2HUk+6e0UYij=n$JM1{sV&^k%OlN||@LICq7 zDyD?9GX=ad#>Pu0kV6@(Ut90MjS9vxyH^=zwRyR@kfww(Tu<|;og3wU8tox4Cc4R^V2U-I|bHhSp&#HXLbb{56a zT=m6^N^cmjo6jA$$|^vybNkMnJ^10S-u+w+gomrNPlv=~wT{-G2`L>o$E$O5uB(aX zcSmwnjR%g>z+MJyTmXp?sm%nDZ;H!xL#5>^7qH2IWQGDS!NC?k4m|8sJUY+?{O;*7 zRWLy~Bxuk5`aX1S4^&J*fiR){frohu-eZVq+zr|Rm=`#gdKjE=(plL=N2VN(F1}-z z_C^VQ8p%D4=Ch&&{Sxw!VajCPxlO_-c>SEeE8lv6(4e%p`>vmw0b>9SZLPUxWQn7k zbtKjdsBQpF4@6sj`PJ;J3eqLrVS%)WgdiXwUD6FI0@B^x&CoqFXMJJs{rK$X{k^~Q-#OR0-np*5g&Ds0 z+_CP}>$8+7AUBADxn$|2{2-Kg6^>t-Z$`Xo(e`v$Ck6mUnNGUWQK zEG?@6u>zX@yvoTT0qnjX6%`cVBPm&FfUxH+8u#s?6sXQRuYHaT|P0Y1hynB_aqc%K_afZNZbQ}$RD1ZOphdl_}OZ0^@TPZ zq%LZvWq){d6?vk+$lCXhNlpkhuREz<4uJi?1ch6kuU|VFOV>Ziv4Ct)&=2iTQ*phd zCj==DZ8v@E>raE3(B0Q(v=Cymu;ZKW1lh2&%lF`M2pGNf^1Avd4RT+gddCw)H*vL! zva%M~|DjOJ3vK4#MMXi*p(r>pz|rEXNDEO8q%1a<@tEy=ceyJej1^38HoXd6bzaBB z2*BMIh?e>Wq6`w7RgN5S2kCe%f+X47P)V~4q%Kl>eFQXsQ{z)pP=$FRI5>D|!?bBm z&2seTQ}6`KOb9%auW~_a&42NEtfPYm0_HUs!DlA&^78D~JMu3E)?gL8Rq3zpOyU5@7OJmjg~F8Y zUHM0#UrwSW?2qw4(Z|g}@dKC?60Dm0@UMVZNS(Yi>4jdZDCay%Lh62D1%+dP3Q2UP z+0$-HU~lI2Ww~GjQ&yp_a#vgwcsmJCY(qm}&P$7xE<0gqXvSEX05S8Z z=55h98L&`+J|0E2!Zq!^WI^ZV}WKq zB#-C|QBp0lY$IIs-Zd|&&RL&=7B2T7R`wHfn*5ehgt7+of+N-a+ zplO%%JpU9CF*4t;TE{H`M(}MlwPSxIuexG;cKIu54N4$@~Ky$Hs-0*s4hDz^d-o@Z8QI1a0tK*a;ZZ? zL+`D(;18YP;Ev`D1P~S^m9FtK{+Jtk^d8iCLu~7LAm{jnY@ESe++Zp9EwjN>a{OcC z1}A6G&I&=L2KnxSU#PZiu} z+A6S`;Q>#U&<6nR_38DO4AAo0fL6Wd!5u5>>Z%C^2wHVowzJT ztUUn6a#L;VQZ8Q>Bnp83h`tS_0{}d7(M*b;58w?DpFN_2;^}$vJb%Ir2{CwhDAtF( zxw>Ldv*nZ8MDnvrNE;~2^TAF0IW9BwM#=pN)|&eIZ;Jyt%M-B|aktkBDx7itD;y>w zoF(j`CDJ?~BnEHUk5#PZAG43moPC^FvOM22Hr;Tfc1L4ImNGU zftwp2uTKd_SUn_VB<61~Ub%JaHAHuBQ4|tKS(`wyuV{BS77&0T`N6Le zF}uM`uVqi5=KBkwuVrM(E>PAV8fMt_E~aPCz8GM5FOOa*DeYUWB>;ZkXdbCXQ8m1` z-6mJqoOuWZg|L;75Z%rt)pZ*ml&d$;4tfbo3no$hzDd*#*m$R*F++)sF>viA0H?Pv zQDdskC^yxLIQp*)=UdeFp$k$KmLJO(-bG_VFG$@8+S=%o-$J|cM?cdiARu@-*Z_zf z#Gd$UxBqlkZCkPhiH0-+0-$F*3v_Ec2Iy^*9orB-8v!A3>=cS#DA+&(_;D1FF*F7x z$S45~u@5O)gS^M}-r|n-_F>3Sz7G&bzivMRU3IC70xgYw0rOPB9DjD_JYYms_ZK$h z7j1OPINgAfm$BSJ3kwUG{m0-Ze-?^f56HR!vdgDR zTI!uYz}>y&ngln-IDXMJ#5oP%P=_ZbejJqs+S{58*HL-ZerO%~ykLtpcb`O8*snD4 z=U;))GW}HDqd2)6o?=X~!oCTli;(%Ff{wlr~c%Z8}~7!jljk_}8=C~#Om>$11Z{e3+YAY1@1UrJAtxE64)o-dDbN?U6x zDte4n6OQ}}cwUNW${$K6=_h0bf0XC?oMLoLjM^L@Xt;nN4`bk271fzgb2 zGQ_k(Ao4VVQ4fh><={BZk6A12S<{TCg={jwhdTvE(@`2oP*?&#9T>#0ZvU|Za`2Gq9_F^{vM)c(qc8wP)G7Ja~7PxL4Bofy-2CgjViFxGm<#ewi zW(ZG1NAb~bFZh#&EG+KW?+#8HN&x02oRr88;570vc#=>t?fk_>i+P%FH3J2k!HQ%0gmcw2HC1?>^Tum3fVQ7dqa6aFwKLZI z2oS_vM)?DC^MFPSD0a)MP2r#`asuBC7w65j@!^d!Nd>NjBm*@@lpCmf7=<|ZS3&!^ zwjDM9mwVMpV+6bL;&dKe$G~+CnKGr*_y=;mMZ04)V)Q0iQPH3`!+iM}kAWKvevlgbM!g&lBAQ=X(J!8`7eH$eFmdM09kn zLHo&6SqZ#QA+*U!s?SM#g<&rNSP%gz^E&x-Lk-f=l!|TM+&|Ln8Ke_N?Ax+V2Qa1s zML{vfmM`)-j<81MrAgYW@w{&rOVo*D3jZheZV%9~r2yklvD6+#>|KrC?OX|3q z*~dPAD{W$;U;k9v0KjLUW>W^z@#DdJ z8s9}rN0(^8sxTnBy2PLYv6-)S%e&ZP5 zdpQ;5`q0Q`104R8ybfoeDiGx+LvG-xA&%YFV}>}n4*;PfwwFpDz*Yh-cgkHW{qiT# zf*KkGWqT{cud%Khi@bNUvP+1U7z|jiBnijv+A-yw4-YrFMR4YL2L7RwN*9&IZdKJW zUhQd^^uMYq^_Y<&>$T327boQmWs|NN%bYl&{I<);XSuiP=&dK(6t7I2bgDvcm-#ZW zFdBRbD7Hoq@>Aw_Trm*3U>&vCX&;%KG;0~J%7w9+Tbmi$F5CRH*}65M=~A7nxNw8i z`! zw=L7TuCx>^{qPd-dDkqudS`{+-4L7x2`hJ z$W-_8V!@c>ZB{G#!QH{3w*VG#4!!!57#c%&ITy0d6p)IyC+FgncRudSNa8lPay56; zy7SE@80G1S8jMn~&8rm3kenq%l8oRrgAY&i`i6qyg&PgQ$sSeb$`$XHb^Jhn74 zVh3x|)*98H!SeDtS1))AN$zmNTD;BsO!9vkX{_$p&4@B9Sr6qG6p-5vSWlhcPeB*W zHQFs04JmPvDnn&SxM@*(JUXEmjlI9OmcZ}n&FmsxvJym==I}}GCh6Glcj3{ZxiY8L z$1-yfYNb1TsSd9mfkrciY*BaNYK~oBxuf1E%SC%S9*xq4Jk`<3aI=*-!Y4hO&2MD+ z+||&_J1S+CQx+4|_8k37LF5-4ZVavj=U4hssn6BM)Mf{`dLB7W?PXiOdn6MINYC-p zRaaL7B7C~{E}DNE+#?rXk7aCO zV0R;0H(!a+h^EO^H@R#OFQ?mH-c)TWyB?CVHzgtC+D2h-b}hnNj=_VUZ%oBtDYisQstr zlLopl@$Fl*GnyN6!33A{@=IA7Ij2hG`NJcfR+bCkoFlD>h zk(!@5;?T6toVH!fk3DWa@`kMPAz3LG)B$DBItvSDn`73yQhUc!*Gi@sICPVHwO{aI zX8bE12Uq9JvD;*+T!TxFL%TNpPOBVRE-t1$8)@s9yjIGw+_ItfPBijH5|u(#TvTY5 zsBIc^==@f_J%v*PXs1tBd!0Xij2@fiC~YjSa2#bc+n&&wqj9mrouJuli^5(7%0CrS zx(Tg1OVZ0ja{V$>Cc6{AR4psavLv+bmg~tI@l=VJ7R~Gw%*4-)t+h4ztTuA<*7S}cE>D=k7qjt?&&JId%b7<_KU3N*X+KSKhb55>u(J!qg=pe8D z1LYf^RPOpY@$@^1sfbOrrA$|x3tp0}>zc6ZS#BF1vic#1IonY<$m_*TkbsGN;oF5i z+n>|Z0e9_|=x+0tW-`k$GBScEGqSsQb8UKo>TOL;Y(N9G-xXn@PA9ynf#OXeGc!$| zo#`t&_XjG;a9Sl}REi&*mv_3Wv31E$2hFgP=%3!2d;RyiV}+~7WTSSv-HnP(R8{8( z939|4p`3i%pIck{5Q@7C+oION{3T^(X2zGL(M|(WudDX<)PM-8mzIXzHS0tSjEwwQ znw&J|dn_FwMMv&5Az{_8U(YLPjRDN%p{AxQGU9x1E_ZxF!j*7d%AWZEavB;{+2OCf zj*AZf*bpEMvtu*NE{27LU53x0Z6_VoyK;>NfHZ($5Vvh4>A*nKr#~ntoo={$c(AjQ zJI>5yTTU4u1gq7TmlqmJ?*IL};JL#0(%|7NS$f0l2W+c5D~@etTMhUVC74EhBNwr` z-O!;KW+{X$^8t^gypJ9(Vzw!L5}Tr7W%W!8V1YAX*p}9;Yw-!Iz>P>H`L^vIR&cT> zq^B?5U9ERU=5y-QDq!SN3JShr+n!IE!0N#a?#`u73ByWra&n-=_zH+K{f1r7xI8no zPh%sM&YZi)@vglsf2VQ(*!<1H`)8p|h^U(FK*wCKJ4;i);?8M=)mC^Qq@>?aO#~}9 z?^JH`pPZbuTWg6*x7U2%z6(Tiv6078(?CF#-SI8~4RzAily#{AbYsS1U)){DQHRCZ zl6Y4lyAd7zJ?rs+)TyZLdd;fV)gqehUmiC)-_6*8QRgY?y3-Wm7laVmYOJho;Wr1EK2{f5<>WuC-Cg^uwtKl9FbuY;2VvCfdj^T;Q4TTnO4}gfI}P zUSuJ&uC8t{zR%V;9WFI>yM4fR=)9u(dZj!|Apc$kKa=XFKW;1E$S^eUXJ)%6UmfIT zb3>od%YidKnVH6?@u|E(tXP+`r=_Weh!@dNHKcRO7VNETR;FmIn~Bsxq{|PHPS(;1 ztdHiF=d*VMBDifUzkM@kX5-vfVf{Z$e6^6As-Y@cP%?e4d#1;^4(Pt9Y0lPm^MNbw-rB~- zLt9(3`lhDpU%#FJ?aQz2-G{2*wN11SG$xy#neh$|o^-`5-lB|+74BSjl3dRo>1m2D zb!>VI2RA+a>n9X?`S|-+?tj77&I4t6HqFg-zrf@;y)wN`5dQ zLxMuNRb8b?KD~X$=;^%FdKH1Z7IoX~ZhGf5n&xc;w`8uS=Y_6tV!Neg$He~_fT`_n|qaOTD4GCeZyynDrzjfB!jLL>e8Up zbZ2^!Q&aaY4e68`0i|U(x!hk{Ul$gx-XN!_gIg};sYe3Ss2m=#EVx zFa%$%x=lwc_hLcrAF7qkA4eO#HA$Pq>e<-DYwboVmhY@DyL0UIWm)5v2wjPH zH~H;as{!Qmii3lLU*B;ml}&czHAv#7{~7C#%`qL4Y@)vMMLb%Ho-NVcfrZs7ku?!= z-LvWjsWi81aYVT`JIKXpP%9edEnY8#rGzqM9UFq zC(Dkg)!weMOk;cK@+w}f@|a3AVmiA-W@tcy+eFc6=@kv!%L|5*CV z@CNFnaIU!fD*k#=eg2fHUBjulE}H%G+mg9)>t;m?oFydz4Pt{^J`Eo@6k;S2!Z;Kh zTnmGv@`G1xA$XIdKUhMYb*a_Z!GVi_+UZ%vkR^MT8N{8!etpSv;?vAT6C~{PEY0i5 zUdz+YO-=3vjiu^wNAK`(G&yBX6dnp1M(`b7uk4PNmG!h2OR07BMw$b<5$ME}nQIaK->#&1z<9ijlj^!U^zY7iV`?As|hwYY&_*HLxJC2p@fs9Bd zm-P`gwv&9gy(rZ@)6%hf2y5U}3D)`Ik~*Z!rWUYhEi z!`n#-k#cgM7YAH+ZO6PA1~pa65WDNgDQ1y*+ataU))|hnJu482KXF_Sv)5FfY`k5z zyO>nRV5ZOp*B=C{Ku(^ivP_8PTgcz%1SZcnqRm{kMFw7z^epRFulzUr(kEHD^yG>U)nSyr8X=#Zt8!aLe0+StsMlsCKc;OcV#yqlC)*QfMpeYP zX=`iqI*I>?{iqb@1=L~I&@nn=)-bJ1@}a#+anZ6*WLh|^he9b1?sgBRx>{J<#-@tm z5UksSkgys6XsN(+z?=q|b6)&guW&?~*cJY2EQXMY;#q4)2Nmd^u5X=p8<|vbz+e^U zy+lwQR$c10Rd{42cQp1!Er~Iu{M$7z3g*(eOHs|ki{-dTD0rwAsXI=H9e=%9zTRmT z!iXC_o}Z9q3ipPbOl@hU6_Cv>_Xg3yqT`6 z@^f&I4Ph1=2H&Dk)*^$CuDruPl-dBg@gWq7_2l-MuS8V@hfu8PV86M5LSHV|i5}it zb!BpyvUTAv+v&hj0Dob0tnbk)zc{#AHuM&J$yw_hLwnYEZ;o993ZcYD9eUK>5<9l+ zYt~;dxl6;d^@x&Z>+O$2DC-3@4x2Idie~S5PZ6|`uFk;im?3OSOib(n)23BUUY-y% zA_TSS=I(jK23dMjE-&`^shZ=0{IZ|z-tJF{&b@KE3^sxtjOv z*(n)VtxuMX_5hHY*IIT^ITtTC%Vs8!D(6$#|Gkjv^%pVG_p~c zHk+4=+xCO6vr8u|Sck0j@CAS4KR(}KMI8n4gU@9(T~n}9%lxx=2=hWmTmhP?K^Yq} z2uz7Bmh{q%aZD*f!N#IE3vhO*m8SQxDWw>SAqD4 zP zR%h|ff|Y;nQl*fP`zUeLmcE+Hw&Uo_QyN5|iyJxgZ*AGBIj@r;ZU7P@)aU!ZC=5ul zF2MxdtO5z|XCWK^nOe=VAqe#SBZz$>?27vq=C1=OTpM7{8NoeMAbJ`IL~HB=^Rr&8 zWhg)Mv3{eKtPiYy84{Q(8&{ZbId%?r9?bCNl@OfM>C^LG*s>SXi!+`Jecakm0YOV! zh3q*f?Pm%3_nw?4pq9CddLr-7Yjdk?tFkF1xVlQ{+_edCON=7pgSu8ero-%Lk7&hf zo15Up(X_I*ekdzzNy*KAUR%58mo8!Tm_P0&1fep4&{;3qUx78DP+!6`1yQK>lq1Jo zD325D-92{9rb<*}C2JbZ&j@4?Q(R2f+sCa1MWP^u{XyTOZ1^P{pagMGwRGg--F-Jem-;&{`u$YeBmmK*67$#QWd<(}pJI52ku|&f_5iJ^Iyp zQ`bN!kaZh5n|mwIfYwo{8Cn|TydPya)VRC#wxQ0*7(acgL4UPmk{!4)>YeGqCafjvupe!YJm&UJ3rYmN zkUy6?0gt95q23uk(~Rx{HVD#g|$NvOv?SG`2EeFk>O_K&EW4< zk-z7~SeaJ7LZ*&N)}9H<3ejcBz5M_4jJUXXkg2Ve)dwnidkEM8r~dnRyL z;NBefI(`ayPOh_cUdMn|*63iXXaBj6Y_N~QNA6vurKQy}H_y!ZRP&4k51#w{BRKL4 z;ym{B^epOOKR8_L8h2#OR&l=lyrcV3-2P*4*7IO9u2!m0St(U2!a9%MKZF&!XCtI$YN%r&8Csl;NF4+r1v@$cW=@yDOv zeJwUt`!)+p@#vxb!%WaMr~=4XirCn7#~|8+wd(w1t-c@d57ofcp$}}JzCN%xJ^ei9 z7<}!MBT#@=yAl-FBU<|Y;7i^A@uhEj&b+&ck9u)OR#p~#Ga5Fx0$`K(AC9>5QLN{2 z*z)o+*!ju33kTQ3_|rc|Nc?jI9U&nhkW586dnHr6lG4bYbs8AN!_=t&aTPy%;oy*c z1OEN8T;QZQ{2q?!YfNnGvqZxN1}lIUJpnS>2|&tZ=HlWKsq-Mf7vhUD zDww*oe?64=|1oAZGA4){1vU;2a7>+Q0lY&ZiFRZ~pX!(C48UEc4fqmIqx|df9pDc8Sa2l7RnyLY@=P<%B9z388^dtkVwFQEaDjaw2 zoE7$`HrbjwV2F2b|6^J#dV=??;R1bXUt6>4=?J@gxv04K%4Hal0bwEU41p^_2qQAf z8nE)iXRjVG%_llpm-Yuj7~1y!A8HuXYNss zJitdrllUk`3&CgA3-uR>5T8YJ^!HHcy=&E^xPQNvD0|-R6T%~o?RWdz@F*ik2PVk6 zzDYhz+>o>;-Ryt{-E+uYd-UeTix=eNKpBF96+r6QNm0cI zV{rfHsI0JT9c0<>A;tEgnHf53U7`~PLWL{C_Q7BPtV*xS&&Nj%WR{ft=3pR-o@UGa zW4U*s#v}58ceV<#AC(I`=JxIS-2A-Hix&|sop~lbI8J5ciW-x`f`L4O)jwXp40Oz% z)pO-G8(UCq?=OTiyERC#=Wrct#M`&$GHOYPGQm!Sg6i8`T=7Z=q{mLy)4cNw;ahGELVP)V`&Z-%3tW-M za6E}`!7hPLI^KhWgXbhWd5mUi!t^AE;jdBct=V)sFxy(wjAx*rR#jJnf_n4ho0p@E z2P0nyFiyO4umQ2}3FY1QcSZj})}_zEK-o9ai;KAdX+h*TJ2ILtQ%GGUM4gbkQgz|a z^*&(F;oE27PqG6D(+)_zTMVv7E~s2`;zo0w)3A#ReJsE*Vv>>`BRAg7UFs$&G^?p> zlbq55jT58<$pp#4fI?R(l27k%U~}TQV9?*w{Ns@qFikD+SjU2C0YP|DNGkDVR8;{l zp(rG_aKAu~=W4prP2iqyx3}LQUR@|C35|&GMaVZYQsMKB1MaXc!2W?}|0|><#|3Kw z_gNmid9?k=|A(&mKl&|l{{Q~^AH^KuQ~!MZAF(J@KcF6o0A43B(Mq@(T63LgnL46I z;LY*`)}U`_D030!c~KfXX-JH)iTUS`A5IV|tOS$4zP|n^w3R+A|6>2{Cz9=7=7VK@ zRsbfD91m`ID{w?@EPMn-h1MLoXV!5b2N1^tbjrJ&7lhFS&#oF1nIwONcT!SPXjGK! zSJ{jEVskcVpZ^~Wm>3ifZugiwsG#$}WTB!$9LAc1l zWAOib*MPUnJpf-6xWEziUaoQ8xez!gQPXKJ4TpUm8VqtAVvZkzj~_dxP>Kc*5P0^h zyStT0PU2On3hWCc*30`B|6tsAtf121&!N%Y5G*)HXEU|B{Yy8S2>MaM=gmqWhl9mY zuARt0zP?q{)1d+fVx!yX_n9G#?o$>7ddbPV&zqR^1!FYX!+o9j%w&*ZzFt3l`t;9l z-@aw7g9L*>p(kvRT<636*?c*0qa4g8@hZ$lHp{3heR^SmQ8lD#>PmP+SUVzSja5>_ zH>hc7{2}dLuH#@OUwnVx*rV{QH3S2_c^z$pRaG5(c8u96dW;obz|(GWN(Nb*_4QVu zB2~kA$Ga@S-y?s%uN{bAguzA*0VxCbNMh>eTAvTCk_l7NsS-NqtgRDVQ+uC~5DJ+S!$ z*&c_S8xaW7%aP`j_D?+@u#3sUZG5|l&(+NEMo5oE$Z zB|R<4arpF6DU7x4#0NT#=o|t%0Adclm!s11Kz&ZViT~SyODT(EFh?HhpcKFnOAO&D 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

WnA%MSBm0k_74nX6`EOQ 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 jm;A#W<32CQ@zlhJKUn Date: Thu, 9 Jul 2026 21:42:59 -0400 Subject: [PATCH 013/494] Filter out disabled --- server/lib/ip.ts | 9 +++++ server/lib/rebuildClientAssociations.ts | 46 +++++++++++++++-------- server/routers/newt/buildConfiguration.ts | 7 +++- server/routers/olm/buildConfiguration.ts | 10 ++++- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 56576282a..fb161df1c 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -496,6 +496,7 @@ export function generateRemoteSubnets( ): string[] { const remoteSubnets = allSiteResources .filter((sr) => { + if (!sr.enabled) return false; if (!sr.destination) return false; if (sr.mode === "cidr") { @@ -530,6 +531,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { return allSiteResources .filter( (sr) => + sr.enabled && sr.aliasAddress && ((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) || (sr.fullDomain && sr.mode == "http")) @@ -662,6 +664,13 @@ export async function generateSubnetProxyTargetV2( subnet: string | null; }[] ): Promise { + if (!siteResource.enabled) { + logger.debug( + `Site resource ${siteResource.siteResourceId} is disabled, skipping target generation.` + ); + return; + } + if (clients.length === 0) { logger.debug( `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index efb856825..eae579634 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -1561,9 +1561,19 @@ export async function handleMessagingForUpdatedSiteResource( updatedSiteResource.udpPortRangeString || existingSiteResource.disableIcmp !== updatedSiteResource.disableIcmp); + // Toggling enabled on/off doesn't change any of the fields above, but it + // does change whether targets/peer data should exist at all, so it needs + // to drive the same old->new diff machinery: going enabled->disabled + // diffs "real data" against "nothing" (a remove), and disabled->enabled + // diffs "nothing" against "real data" (an add). generateSubnetProxyTargetV2/ + // generateRemoteSubnets/generateAliasConfig already return nothing for a + // disabled resource, so no other changes are needed here. + const enabledChanged = + existingSiteResource && + existingSiteResource.enabled !== updatedSiteResource.enabled; logger.debug( - `handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)}` + `handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)} enabledChanged=${Boolean(enabledChanged)}` ); // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all @@ -1574,14 +1584,16 @@ export async function handleMessagingForUpdatedSiteResource( fullDomainChanged || sslChanged || portRangesChanged || - destinationPortChanged + destinationPortChanged || + enabledChanged ) { const shouldUpdateTargets = destinationChanged || sslChanged || portRangesChanged || fullDomainChanged || - destinationPortChanged; + destinationPortChanged || + enabledChanged; logger.debug( `handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}` @@ -1657,20 +1669,22 @@ export async function handleMessagingForUpdatedSiteResource( peerDataUpdateBatch.push({ clientId: client.clientId, siteId, - remoteSubnets: destinationChanged - ? { - oldRemoteSubnets: !oldDestinationStillInUseBySite - ? generateRemoteSubnets([ - existingSiteResource - ]) - : [], - newRemoteSubnets: generateRemoteSubnets([ - updatedSiteResource - ]) - } - : undefined, + remoteSubnets: + destinationChanged || enabledChanged + ? { + oldRemoteSubnets: + !oldDestinationStillInUseBySite + ? generateRemoteSubnets([ + existingSiteResource + ]) + : [], + newRemoteSubnets: generateRemoteSubnets([ + updatedSiteResource + ]) + } + : undefined, aliases: - aliasChanged || fullDomainChanged // the full domain is sent down as an alias + aliasChanged || fullDomainChanged || enabledChanged // the full domain is sent down as an alias ? { oldAliases: generateAliasConfig([ existingSiteResource diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 5b7f211ae..a12d035a5 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -148,7 +148,12 @@ export async function buildClientConfigurationForNewtClient( .from(siteResources) .innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) - .where(eq(siteNetworks.siteId, siteId)) + .where( + and( + eq(siteNetworks.siteId, siteId), + eq(siteResources.enabled, true) + ) + ) .then((rows) => rows.map((r) => r.siteResources)); const targetsToSend: SubnetProxyTargetV2[] = []; diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index f72731c92..0266fa041 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -16,7 +16,7 @@ import { generateRemoteSubnets } from "@server/lib/ip"; import logger from "@server/logger"; -import { eq, inArray } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { addPeer, deletePeer } from "../newt/peers"; import config from "@server/lib/config"; @@ -70,7 +70,13 @@ export async function buildSiteConfigurationForOlmClient( .innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) .where( - eq(clientSiteResourcesAssociationsCache.clientId, client.clientId) + and( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ), + eq(siteResources.enabled, true) + ) ); const siteResourcesBySiteId = new Map(); From 1580b7abff4d434baf550e1057a23bad76cdd1c2 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 10 Jul 2026 07:02:22 +0200 Subject: [PATCH 014/494] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20batch=20status=20?= =?UTF-8?q?histories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/statusHistory.ts | 80 ++++++++++++++++++- .../routers/site/getBatchedStatusHistory.ts | 62 ++++++++++++++ src/lib/queries.ts | 22 +++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 server/routers/site/getBatchedStatusHistory.ts diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 7c5b5c370..e3b13270b 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { db, logsDb, statusHistory } from "@server/db"; -import { and, eq, gte, lt, asc, desc } from "drizzle-orm"; +import { and, eq, gte, lt, asc, desc, inArray } from "drizzle-orm"; import { regionalCache as cache } from "#dynamic/lib/cache"; const STATUS_HISTORY_CACHE_TTL = 60; // seconds @@ -273,3 +273,81 @@ export function computeBuckets( return { buckets, totalDowntime }; } + +export interface BatchedStatusHistoryResponse { + entityType: string; + entityIds: number[]; + days: StatusHistoryDayBucket[]; + overallUptimePercent: number; + totalDowntimeSeconds: number; +} + +export async function getBatchedStatusHistory( + entityType: string, + entityIds: number[], + days: number +): Promise { + // const cacheKey = statusHistoryCacheKey(entityType, entityId, days); + // const cached = await cache.get(cacheKey); + // if (cached !== undefined) { + // return cached; + // } + + // Anchor to UTC midnight so the query window aligns with stable calendar days + const utcToday = new Date(); + utcToday.setUTCHours(0, 0, 0, 0); + const todayMidnightSec = Math.floor(utcToday.getTime() / 1000); + const startSec = todayMidnightSec - days * 86400; + + const events = await logsDb + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + inArray(statusHistory.entityId, entityIds), + gte(statusHistory.timestamp, startSec) + ) + ) + .orderBy(asc(statusHistory.timestamp)); + + // Fetch the last known state before the window so that entities that + // haven't changed status recently still show the correct status rather + // than appearing as "no_data". + const [lastKnownEvent] = await logsDb + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + inArray(statusHistory.entityId, entityIds), + lt(statusHistory.timestamp, startSec) + ) + ) + .orderBy(desc(statusHistory.timestamp)) + .limit(1); + + const priorStatus = lastKnownEvent?.status ?? null; + + const { buckets, totalDowntime } = computeBuckets( + events, + days, + priorStatus + ); + const totalWindow = days * 86400; + const overallUptime = + totalWindow > 0 + ? Math.max(0, ((totalWindow - totalDowntime) / totalWindow) * 100) + : 100; + + const result: BatchedStatusHistoryResponse = { + entityType, + entityIds, + days: buckets, + overallUptimePercent: Math.round(overallUptime * 100) / 100, + totalDowntimeSeconds: totalDowntime + }; + + // await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); + return result; +} diff --git a/server/routers/site/getBatchedStatusHistory.ts b/server/routers/site/getBatchedStatusHistory.ts new file mode 100644 index 000000000..97ed8a9ac --- /dev/null +++ b/server/routers/site/getBatchedStatusHistory.ts @@ -0,0 +1,62 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { + getCachedStatusHistory, + statusHistoryQuerySchema, + StatusHistoryResponse +} from "@server/lib/statusHistory"; + +const siteParamsSchema = z.object({ + siteId: z.string().transform((v) => parseInt(v, 10)) +}); + +export async function getBatchedSiteStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = siteParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const parsedQuery = statusHistoryQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "site"; + const entityId = parsedParams.data.siteId; + const { days } = parsedQuery.data; + + const data = await getCachedStatusHistory(entityType, entityId, days); + + return response(res, { + data, + success: true, + error: false, + message: "Status history retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 8fdb1c531..19b28e2d9 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -640,6 +640,28 @@ export const orgQueries = { }; } }), + batchedSiteStatusHistory: ({ + siteIds, + days = 90 + }: { + siteIds: number[]; + days?: number; + }) => + queryOptions({ + queryKey: [ + "SITE_STATUS_HISTORY", + "BATCHED", + siteIds, + days + ] as const, + queryFn: async ({ signal, meta }) => { + // TODO + // const res = await meta!.api.get< + // AxiosResponse + // >(`/site/${siteId}/status-history?days=${days}`, { signal }); + // return res.data.data; + } + }), siteStatusHistory: ({ siteId, days = 90 From 0b2693a317ba9dc788db3a44cc40e25423defc1b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 09:59:11 -0400 Subject: [PATCH 015/494] Add toggle to the ui for enabled --- .../private/[niceId]/general/page.tsx | 44 ++++++- src/components/PrivateResourcesTable.tsx | 109 +++++++++++++++++- src/lib/privateResourceForm.ts | 3 +- 3 files changed, 152 insertions(+), 4 deletions(-) diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx index 6e1a9fdeb..d14b9c3d5 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx @@ -16,12 +16,14 @@ import { Button } from "@app/components/ui/button"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; +import { SwitchInput } from "@app/components/SwitchInput"; import { createGeneralFormSchema } from "@app/lib/privateResourceForm"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; @@ -41,7 +43,8 @@ export default function PrivateResourceGeneralPage() { resolver: zodResolver(formSchema), defaultValues: { name: siteResource.name, - niceId: siteResource.niceId + niceId: siteResource.niceId, + enabled: siteResource.enabled } }); @@ -52,7 +55,8 @@ export default function PrivateResourceGeneralPage() { const data = form.getValues(); await save({ name: data.name, - niceId: data.niceId + niceId: data.niceId, + enabled: data.enabled }); }, null); @@ -76,6 +80,42 @@ export default function PrivateResourceGeneralPage() { id="private-resource-general-form" > + + ( + + + + form.setValue( + "enabled", + val + ) + } + /> + + + {t( + "disabledResourceDescription" + )} + + + + )} + /> + + >( + `site-resource/${resourceId}`, + { + enabled: val + } + ); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("resourcesErrorUpdate"), + description: formatAxiosError( + e, + t("resourcesErrorUpdateDescription") + ) + }); + } + } + const deleteInternalResource = async ( resourceId: number, siteId: number @@ -429,6 +470,36 @@ export default function PrivateResourcesTable({ ); } }, + { + accessorKey: "enabled", + friendlyName: t("enabled"), + header: () => ( + + handleFilterChange("enabled", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("enabled")} + className="p-3" + /> + ), + cell: ({ row }) => ( + + ) + }, { id: "labels", accessorKey: "labels", @@ -643,3 +714,39 @@ function ClientResourceLabelCell({ /> ); } + +type InternalResourceEnabledFormProps = { + resource: InternalResourceRow; + onToggleInternalResourceEnabled: ( + val: boolean, + resourceId: number + ) => Promise; +}; + +function InternalResourceEnabledForm({ + resource, + onToggleInternalResourceEnabled +}: InternalResourceEnabledFormProps) { + const [optimisticEnabled, setOptimisticEnabled] = useOptimistic( + resource.enabled + ); + + const formRef = useRef>(null); + + async function submitAction(formData: FormData) { + const newEnabled = !(formData.get("enabled") === "on"); + setOptimisticEnabled(newEnabled); + await onToggleInternalResourceEnabled(newEnabled, resource.id); + } + + return ( +

+ formRef.current?.requestSubmit()} + /> + + ); +} diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index d9f6fd691..86c6228cd 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -342,7 +342,8 @@ export function createGeneralFormSchema(t: TranslateFn) { .string() .min(1) .max(255) - .regex(/^[a-zA-Z0-9-]+$/) + .regex(/^[a-zA-Z0-9-]+$/), + enabled: z.boolean() }); } From f4bee6406a23250fd5f64fb6cad44c5dca1959b3 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 10:11:47 -0400 Subject: [PATCH 016/494] Support partial updates --- server/routers/siteResource/updateSiteResource.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 173a15190..e1e25f918 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -152,6 +152,11 @@ const updateSiteResourceSchema = z ) .refine( (data) => { + // this is a partial update; only enforce destination when the + // caller is actually changing mode or destination + if (data.mode === undefined && data.destination === undefined) { + return true; + } // destination is only optional for ssh mode with native authDaemonMode if (data.mode === "ssh" && data.authDaemonMode === "native") { return true; From 538b57941c114307528098a2ef4c95e07c5a3b50 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 11:32:27 -0400 Subject: [PATCH 017/494] Makes sure patch works z# --- .../siteResource/updateSiteResource.ts | 30 +++++++++++++++---- src/lib/privateResourceForm.ts | 1 - 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index e1e25f918..0ccf29908 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -416,8 +416,10 @@ export async function updateSiteResource( : []; const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId); - let fullDomain: string | null = null; - let finalSubdomain: string | null = null; + // undefined means "leave unchanged" (partial update); only nulled out + // when the mode is explicitly being changed away from http + let fullDomain: string | null | undefined = undefined; + let finalSubdomain: string | null | undefined = undefined; if (domainId) { // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( @@ -453,6 +455,11 @@ export async function updateSiteResource( ) ); } + } else if (mode !== undefined && mode !== "http") { + // mode is explicitly changing away from http, so the resource + // can no longer have a domain associated with it + fullDomain = null; + finalSubdomain = null; } // make sure the alias is unique within the org if provided @@ -521,15 +528,28 @@ export async function updateSiteResource( destination: destination, destinationPort: destinationPort, enabled: enabled, - alias: alias ? alias.trim() : null, + alias: + alias !== undefined + ? alias + ? alias.trim() + : null + : mode !== undefined && + mode !== "host" && + mode !== "ssh" + ? null + : undefined, tcpPortRangeString: tcpPortRangeStringAdjusted, udpPortRangeString: mode == "http" || mode == "ssh" ? "" : udpPortRangeString, disableIcmp: - disableIcmp || - (mode == "http" || mode == "ssh" ? true : false), + mode !== undefined + ? disableIcmp || + (mode == "http" || mode == "ssh" + ? true + : false) + : disableIcmp, domainId, subdomain: finalSubdomain, fullDomain, diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index 86c6228cd..3edd436e4 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -165,7 +165,6 @@ export function buildCreateSiteResourcePayload( siteIds: data.siteIds, mode: data.mode, destination: isNativeSsh ? undefined : (data.destination ?? undefined), - enabled: true, ...(data.mode === "http" && { scheme: data.scheme, ssl: data.ssl ?? false, From cf9a17cc2ef6fb6f292e0f9379334d8afb12cec4 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 15:03:50 -0400 Subject: [PATCH 018/494] Add status filter --- server/db/pg/schema/schema.ts | 10 +++++-- server/db/sqlite/schema/schema.ts | 6 +++-- server/routers/resource/listResources.ts | 14 ++++++++++ .../resource/listUserResourceAliases.ts | 26 ++++++++++++++---- .../siteResource/listAllSiteResourcesByOrg.ts | 14 ++++++++++ .../routers/siteResource/listSiteResources.ts | 27 ++++++++++++++----- 6 files changed, 81 insertions(+), 16 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index b207b423b..f3375b0d9 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -200,7 +200,10 @@ export const resources = pgTable( authDaemonMode: varchar("authDaemonMode", { length: 32 }) .$type<"site" | "remote" | "native">() .default("site"), - authDaemonPort: integer("authDaemonPort").default(22123) + authDaemonPort: integer("authDaemonPort").default(22123), + status: varchar("status") + .$type<"pending" | "approved">() + .default("approved") }, (t) => [ index("idx_resources_fulldomain") @@ -451,7 +454,10 @@ export const siteResources = pgTable( onDelete: "set null" }), subdomain: varchar("subdomain"), - fullDomain: varchar("fullDomain") + fullDomain: varchar("fullDomain"), + status: varchar("status") + .$type<"pending" | "approved">() + .default("approved") }, (t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)] ); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 4ec4916b5..3fcf38a41 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -209,7 +209,8 @@ export const resources = sqliteTable("resources", { authDaemonMode: text("authDaemonMode") .$type<"site" | "remote" | "native">() .default("site"), - authDaemonPort: integer("authDaemonPort").default(22123) + authDaemonPort: integer("authDaemonPort").default(22123), + status: text("status").$type<"pending" | "approved">().default("approved") }); export const labels = sqliteTable("labels", { @@ -447,7 +448,8 @@ export const siteResources = sqliteTable("siteResources", { onDelete: "set null" }), subdomain: text("subdomain"), - fullDomain: text("fullDomain") + fullDomain: text("fullDomain"), + status: text("status").$type<"pending" | "approved">().default("approved") }); export const networks = sqliteTable("networks", { diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 9567b5bcd..e0baa49b1 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -138,6 +138,15 @@ const listResourcesSchema = z.strictObject({ description: "When set, only resources that have at least one target on this site are returned" }), + status: z + .enum(["pending", "approved"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["pending", "approved"], + description: "Filter by resource status" + }), labels: z .preprocess((val) => { if (val === undefined || val === null || val === "") { @@ -451,6 +460,7 @@ export async function listResources( sort_by, order, siteId, + status, labels: labelFilter } = parsedQuery.data; @@ -660,6 +670,10 @@ export async function listResources( } } + if (typeof status !== "undefined") { + conditions.push(eq(resources.status, status)); + } + if (siteId != null) { const resourcesWithSite = db .select({ resourceId: targets.resourceId }) diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts index 4ec87b8bb..100b1624d 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -45,11 +45,12 @@ function userResourceAliasesCacheKey( page: number, pageSize: number, includeLabels: boolean, - labelFilter: string[] + labelFilter: string[], + status?: "pending" | "approved" ) { const labelsKey = labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all"; - return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}`; + return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}:${status ?? "all"}`; } const listUserResourceAliasesParamsSchema = z.strictObject({ @@ -96,7 +97,16 @@ const listUserResourceAliasesQuerySchema = z.strictObject({ type: "array", description: "Filter by resource labels. A resource matches when it has any of the given labels (OR)." - }) + }), + status: z + .enum(["pending", "approved"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["pending", "approved"], + description: "Filter by site resource status" + }) }); export type UserResourceAliasItem = { @@ -130,7 +140,8 @@ export async function listUserResourceAliases( page, pageSize, includeLabels, - labels: labelFilter + labels: labelFilter, + status } = parsedQuery.data; const parsedParams = listUserResourceAliasesParamsSchema.safeParse( @@ -172,7 +183,8 @@ export async function listUserResourceAliases( page, pageSize, includeLabels, - labelFilter ?? [] + labelFilter ?? [], + status ); const cachedData: ListUserResourceAliasesResponse | undefined = await cache.get(cacheKey); @@ -257,6 +269,10 @@ export async function listUserResourceAliases( inArray(siteResources.siteResourceId, accessibleSiteResourceIds) ]; + if (typeof status !== "undefined") { + whereConditions.push(eq(siteResources.status, status)); + } + if (labelFilter && labelFilter.length > 0) { whereConditions.push( inArray( diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 4515e033e..2cbbc2c4e 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -86,6 +86,15 @@ const listAllSiteResourcesByOrgQuerySchema = z.strictObject({ description: "When set, only site resources associated with this site (via network) are returned" }), + status: z + .enum(["pending", "approved"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["pending", "approved"], + description: "Filter by site resource status" + }), labels: z .preprocess((val) => { if (val === undefined || val === null || val === "") { @@ -283,6 +292,7 @@ export async function listAllSiteResourcesByOrg( sort_by, order, siteId, + status, labels: labelFilter } = parsedQuery.data; @@ -315,6 +325,10 @@ export async function listAllSiteResourcesByOrg( conditions.push(eq(siteResources.mode, mode)); } + if (typeof status !== "undefined") { + conditions.push(eq(siteResources.status, status)); + } + if (labelFilter && labelFilter.length > 0) { conditions.push( inArray( diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index a9688a9c6..c97900bec 100644 --- a/server/routers/siteResource/listSiteResources.ts +++ b/server/routers/siteResource/listSiteResources.ts @@ -47,6 +47,15 @@ const listSiteResourcesQuerySchema = z.strictObject({ enum: ["asc", "desc"], default: "asc", description: "Sort order" + }), + status: z + .enum(["pending", "approved"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["pending", "approved"], + description: "Filter by site resource status" }) }); @@ -110,7 +119,7 @@ export async function listSiteResources( } const { siteId, orgId } = parsedParams.data; - const { limit, offset, sort_by, order } = parsedQuery.data; + const { limit, offset, sort_by, order, status } = parsedQuery.data; // Verify the site exists and belongs to the org const site = await db @@ -124,6 +133,15 @@ export async function listSiteResources( } // Get site resources by joining networks to siteResources via siteNetworks + const conditions = [ + eq(siteNetworks.siteId, siteId), + eq(siteResources.orgId, orgId) + ]; + + if (typeof status !== "undefined") { + conditions.push(eq(siteResources.status, status)); + } + const siteResourcesList = await db .select() .from(siteNetworks) @@ -132,12 +150,7 @@ export async function listSiteResources( siteResources, eq(siteResources.networkId, networks.networkId) ) - .where( - and( - eq(siteNetworks.siteId, siteId), - eq(siteResources.orgId, orgId) - ) - ) + .where(and(...conditions)) .orderBy( sort_by ? order === "asc" From 609fb357bb1e2ec26535c202181beb4112d70fa8 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 15:16:50 -0400 Subject: [PATCH 019/494] Support enable in the blueprints --- server/lib/blueprints/privateResources.ts | 6 ++---- server/lib/blueprints/types.ts | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index aa146679f..7d5d9a874 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -240,8 +240,7 @@ export async function updatePrivateResources( scheme: resourceData.scheme, destination: resourceData.destination, destinationPort: resourceData["destination-port"], - enabled: true, // hardcoded for now - // enabled: resourceData.enabled ?? true, + enabled: resourceData.enabled ?? true, alias: resourceData.alias || null, disableIcmp: resourceData["disable-icmp"] || @@ -493,8 +492,7 @@ export async function updatePrivateResources( scheme: resourceData.scheme, destination: resourceData.destination, destinationPort: resourceData["destination-port"], - enabled: true, // hardcoded for now - // enabled: resourceData.enabled ?? true, + enabled: resourceData.enabled ?? true, alias: resourceData.alias || null, aliasAddress: aliasAddress, disableIcmp: diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 5495a2d8e..cc9865533 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -470,7 +470,7 @@ export const PrivateResourceSchema = z // proxyPort: z.int().positive().optional(), "destination-port": z.int().positive().optional(), destination: z.string().min(1).optional(), - // enabled: z.boolean().default(true), + enabled: z.boolean().default(true), "tcp-ports": portRangeStringSchema.optional().default("*"), "udp-ports": portRangeStringSchema.optional().default("*"), "disable-icmp": z.boolean().optional().default(false), From 14680df160dead2fc2aae1259327f24bcbe314a3 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 10 Jul 2026 15:23:09 -0400 Subject: [PATCH 020/494] suse tiermatrix in server component susbcribed check --- src/app/auth/org/[orgId]/page.tsx | 25 +++++++++++++------ src/app/auth/org/page.tsx | 6 ++++- src/app/auth/resource/[resourceGuid]/page.tsx | 25 +++++++++++++++---- src/lib/api/isOrgSubscribed.ts | 9 +++++-- src/lib/loadOrgLoginPageBranding.ts | 6 ++++- 5 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/app/auth/org/[orgId]/page.tsx b/src/app/auth/org/[orgId]/page.tsx index 2329d5b7c..b2a225120 100644 --- a/src/app/auth/org/[orgId]/page.tsx +++ b/src/app/auth/org/[orgId]/page.tsx @@ -13,6 +13,8 @@ import { redirect } from "next/navigation"; import OrgLoginPage from "@app/components/OrgLoginPage"; import { pullEnv } from "@app/lib/pullEnv"; import type { Metadata } from "next"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed"; export const metadata: Metadata = { title: "Organization Login" @@ -68,15 +70,22 @@ export default async function OrgAuthPage(props: { variant: idp.variant })) as LoginFormIDP[]; + const hasLoginPageBranding = await isOrgSubscribed( + orgId, + tierMatrix.loginPageBranding + ); + let branding: LoadLoginPageBrandingResponse | null = null; - try { - const res = await priv.get< - AxiosResponse - >(`/login-page-branding?orgId=${orgId}`); - if (res.status === 200) { - branding = res.data.data; - } - } catch (error) {} + if (hasLoginPageBranding) { + try { + const res = await priv.get< + AxiosResponse + >(`/login-page-branding?orgId=${orgId}`); + if (res.status === 200) { + branding = res.data.data; + } + } catch (error) {} + } return ( { + const wildcardMatchesRedirect = ( + wildcardDomain: string, + host: string + ): boolean => { if (!wildcardDomain.startsWith("*.")) return false; const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io" return host.endsWith(suffix) && host.length > suffix.length; @@ -228,7 +243,7 @@ export default async function ResourceAuthPage(props: { let loginIdps: LoginFormIDP[] = []; if (build === "saas" || env.app.identityProviderMode === "org") { - if (subscribed) { + if (hasOrgOidc) { const idpsRes = await cache( async () => await priv.get>( @@ -271,7 +286,7 @@ export default async function ResourceAuthPage(props: { let branding: LoadLoginPageBrandingResponse | null = null; try { - if (subscribed) { + if (hasLoginPageBranding) { const res = await priv.get< AxiosResponse >(`/login-page-branding?orgId=${authInfo.orgId}`); diff --git a/src/lib/api/isOrgSubscribed.ts b/src/lib/api/isOrgSubscribed.ts index ce19b9178..16fc082c0 100644 --- a/src/lib/api/isOrgSubscribed.ts +++ b/src/lib/api/isOrgSubscribed.ts @@ -4,9 +4,13 @@ import { getCachedSubscription } from "./getCachedSubscription"; import { priv } from "."; import { AxiosResponse } from "axios"; import { GetLicenseStatusResponse } from "@server/routers/license/types"; +import { Tier } from "@server/types/Tiers"; -export const isOrgSubscribed = cache(async (orgId: string) => { +const DEFAULT_PAID_TIERS: Tier[] = ["tier1", "tier2", "tier3", "enterprise"]; + +export const isOrgSubscribed = cache(async (orgId: string, tiers?: Tier[]) => { let subscribed = false; + const allowedTiers = tiers ?? DEFAULT_PAID_TIERS; if (build === "enterprise") { try { @@ -20,7 +24,8 @@ export const isOrgSubscribed = cache(async (orgId: string) => { try { const subRes = await getCachedSubscription(orgId); subscribed = - (subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3" || subRes.data.data.tier == "enterprise") && + !!subRes.data.data.tier && + allowedTiers.includes(subRes.data.data.tier as Tier) && subRes.data.data.active; } catch {} } diff --git a/src/lib/loadOrgLoginPageBranding.ts b/src/lib/loadOrgLoginPageBranding.ts index 7e549622a..02ef66295 100644 --- a/src/lib/loadOrgLoginPageBranding.ts +++ b/src/lib/loadOrgLoginPageBranding.ts @@ -1,6 +1,7 @@ import { priv } from "@app/lib/api"; import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed"; import { build } from "@server/build"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types"; import { AxiosResponse } from "axios"; @@ -11,7 +12,10 @@ export async function loadOrgLoginPageBranding(orgId: string): Promise<{ return { primaryColor: null }; } - const subscribed = await isOrgSubscribed(orgId); + const subscribed = await isOrgSubscribed( + orgId, + tierMatrix.loginPageBranding + ); if (!subscribed) { return { primaryColor: null }; } From 5ef068c8dcce00d239b162a3c2da458d7c59af24 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 15:31:37 -0400 Subject: [PATCH 021/494] Set the resource from the site --- server/lib/blueprints/privateResources.ts | 10 +++-- server/lib/blueprints/publicResources.ts | 47 ++++++++++++++++------- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 7d5d9a874..45b76f9f0 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -198,17 +198,19 @@ export async function updatePrivateResources( } } + let resourceStatusFromSite: "approved" | "pending" = "approved"; if (siteId && allSites.length === 0) { // only add if there are not provided sites // Use the provided siteId directly, but verify it belongs to the org const [siteSingle] = await trx - .select({ siteId: sites.siteId }) + .select({ siteId: sites.siteId, status: sites.status }) .from(sites) .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) .limit(1); if (siteSingle) { allSites.push(siteSingle); } + resourceStatusFromSite = siteSingle.status ?? "approved"; } if (allSites.length === 0) { @@ -259,7 +261,8 @@ export async function updatePrivateResources( pamMode: resourceData["auth-daemon"]?.pam || "passthrough", authDaemonMode: resourceData["auth-daemon"]?.mode || "native", - authDaemonPort: resourceData["auth-daemon"]?.port || 22123 + authDaemonPort: resourceData["auth-daemon"]?.port || 22123, + status: resourceStatusFromSite }) .where( eq( @@ -512,7 +515,8 @@ export async function updatePrivateResources( pamMode: resourceData["auth-daemon"]?.pam || "passthrough", authDaemonMode: resourceData["auth-daemon"]?.mode || "native", - authDaemonPort: resourceData["auth-daemon"]?.port || 22123 + authDaemonPort: resourceData["auth-daemon"]?.port || 22123, + status: resourceStatusFromSite }) .returning(); diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index df3b3799e..66ab130a8 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -25,6 +25,7 @@ import { rolePolicies, roleResources, roles, + Site, sites, Target, TargetHealthCheck, @@ -74,19 +75,40 @@ export async function updatePublicResources( )) { const targetsToUpdate: Target[] = []; const healthchecksToUpdate: TargetHealthCheck[] = []; + let resource: Resource; + let resourceStatusFromSite: "approved" | "pending" = "approved"; + let providedSite: Partial | undefined; + if (siteId) { + // Use the provided siteId directly, but verify it belongs to the org + [providedSite] = await trx + .select({ + siteId: sites.siteId, + type: sites.type, + status: sites.status + }) + .from(sites) + .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) + .limit(1); + + resourceStatusFromSite = providedSite?.status ?? "approved"; + } async function createTarget( // reusable function to create a target resourceId: number, targetData: TargetData ) { const targetSiteId = targetData.site; - let site; + let site: Partial | undefined; if (targetSiteId) { // Look up site by niceId [site] = await trx - .select({ siteId: sites.siteId, type: sites.type }) + .select({ + siteId: sites.siteId, + type: sites.type, + status: sites.status + }) .from(sites) .where( and( @@ -95,15 +117,9 @@ export async function updatePublicResources( ) ) .limit(1); - } else if (siteId) { + } else if (siteId && providedSite) { // Use the provided siteId directly, but verify it belongs to the org - [site] = await trx - .select({ siteId: sites.siteId, type: sites.type }) - .from(sites) - .where( - and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) - ) - .limit(1); + site = providedSite; } else { throw new Error(`Target site is required`); } @@ -139,7 +155,7 @@ export async function updatePublicResources( .insert(targets) .values({ resourceId: resourceId, - siteId: site.siteId, + siteId: site.siteId!, ip: targetData.hostname, mode: resourceData.mode as Target["mode"], method: targetData.method, @@ -172,7 +188,7 @@ export async function updatePublicResources( .insert(targetHealthCheck) .values({ name: `${targetData.hostname}:${targetData.port}`, - siteId: site.siteId, + siteId: site.siteId!, targetId: newTarget.targetId, orgId: orgId, hcEnabled: healthcheckData?.enabled || false, @@ -406,7 +422,8 @@ export async function updatePublicResources( ? (resourceData["proxy-protocol-version"] ?? 1) : 1, - resourcePolicyId: sharedPolicy.resourcePolicyId + resourcePolicyId: sharedPolicy.resourcePolicyId, + status: resourceStatusFromSite }) .where( eq( @@ -590,7 +607,8 @@ export async function updatePublicResources( authDaemonPort: resourceData["auth-daemon"]?.port || 22123, resourcePolicyId: null, - defaultResourcePolicyId: inlinePolicyId + defaultResourcePolicyId: inlinePolicyId, + status: resourceStatusFromSite }) .where( eq( @@ -1131,6 +1149,7 @@ export async function updatePublicResources( .values({ orgId, niceId: resourceNiceId, + status: resourceStatusFromSite, name: resourceData.name || "Unnamed Resource", mode: resourceData.mode, proxyPort: ["http", "ssh", "rdp", "vnc"].includes( From e1bc0b7efd26d4d6a7e74aed3051891dc4855bf6 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 15:36:05 -0400 Subject: [PATCH 022/494] Make sure the enabled gets set to false --- server/lib/blueprints/privateResources.ts | 11 +++++++++-- server/lib/blueprints/publicResources.ts | 5 ++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 45b76f9f0..44901e8c8 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -219,6 +219,13 @@ export async function updatePrivateResources( ); } + const resourceEnabled = + resourceData.enabled == undefined || resourceData.enabled == null + ? true + : resourceStatusFromSite === "pending" + ? false + : resourceData.enabled; + if (existingResource) { let domainInfo: | { subdomain: string | null; domainId: string } @@ -242,7 +249,7 @@ export async function updatePrivateResources( scheme: resourceData.scheme, destination: resourceData.destination, destinationPort: resourceData["destination-port"], - enabled: resourceData.enabled ?? true, + enabled: resourceEnabled, alias: resourceData.alias || null, disableIcmp: resourceData["disable-icmp"] || @@ -495,7 +502,7 @@ export async function updatePrivateResources( scheme: resourceData.scheme, destination: resourceData.destination, destinationPort: resourceData["destination-port"], - enabled: resourceData.enabled ?? true, + enabled: resourceEnabled, alias: resourceData.alias || null, aliasAddress: aliasAddress, disableIcmp: diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 66ab130a8..997d56e2a 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -246,7 +246,10 @@ export async function updatePublicResources( const resourceEnabled = resourceData.enabled == undefined || resourceData.enabled == null ? true - : resourceData.enabled; + : resourceStatusFromSite === "pending" + ? false + : resourceData.enabled; + const resourceSsl = resourceData.ssl == undefined || resourceData.ssl == null ? true From d9303f87c800ad519c5868413f2735c30d6e9bdc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 10 Jul 2026 16:34:02 -0400 Subject: [PATCH 023/494] set last used cookie in smart login lookup --- messages/en-US.json | 2 +- src/components/SmartLoginOrgSelector.tsx | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index ee3e16102..5833647e1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1517,7 +1517,7 @@ "otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.", "otpAuthSubmit": "Submit Code", "idpContinue": "Or continue with", - "idpLastUsed": "Last used", + "idpLastUsed": "Last Used", "otpAuthBack": "Back to Password", "navbar": "Navigation Menu", "navbarDescription": "Main navigation menu for the application", diff --git a/src/components/SmartLoginOrgSelector.tsx b/src/components/SmartLoginOrgSelector.tsx index 79a43782e..22a5bb01c 100644 --- a/src/components/SmartLoginOrgSelector.tsx +++ b/src/components/SmartLoginOrgSelector.tsx @@ -17,6 +17,8 @@ import { } from "next/navigation"; import { cleanRedirect } from "@app/lib/cleanRedirect"; import { Separator } from "@app/components/ui/separator"; +import { setClientCookie } from "@app/lib/setClientCookie"; +import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts"; type SmartLoginOrgSelectorProps = { identifier: string; @@ -141,6 +143,17 @@ export default function SmartLoginOrgSelector({ setPendingIdpId(idpId); setError(null); + setClientCookie( + LAST_USED_IDP_COOKIE_NAME, + JSON.stringify({ + orgId, + idpId + }), + { + sameSite: "Lax" + } + ); + let redirectToUrl: string | undefined; try { const safeRedirect = cleanRedirect(redirect || "/"); From 39c35fa539dc59d969743e278eaa02e146e2c67d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 10 Jul 2026 17:05:45 -0400 Subject: [PATCH 024/494] reorganize components nad hooks for consistency --- .../resources/private/[niceId]/access/page.tsx | 2 +- .../resources/private/[niceId]/cidr/page.tsx | 12 ++++++------ .../resources/private/[niceId]/general/page.tsx | 2 +- .../resources/private/[niceId]/host/page.tsx | 12 ++++++------ .../resources/private/[niceId]/http/page.tsx | 10 +++++----- .../resources/private/[niceId]/ssh/page.tsx | 10 +++++----- .../settings/resources/private/create/page.tsx | 16 ++++++++++------ .../PrivateResourceAccessFields.tsx | 0 .../PrivateResourceDestinationFields.tsx | 0 .../PrivateResourceHttpFields.tsx | 0 .../PrivateResourceMultiSiteRoutingHelp.tsx | 0 .../PrivateResourcePortRanges.tsx | 0 .../PrivateResourceSitesField.tsx | 2 +- .../PrivateResourceSshFields.tsx | 6 +++--- .../private => hooks}/useSaveSiteResource.ts | 0 .../private => lib}/formControlUtils.ts | 0 .../private => lib}/privateResourceUtils.ts | 0 17 files changed, 38 insertions(+), 34 deletions(-) rename src/{app/[orgId]/settings/resources/private => components}/PrivateResourceAccessFields.tsx (100%) rename src/{app/[orgId]/settings/resources/private => components}/PrivateResourceDestinationFields.tsx (100%) rename src/{app/[orgId]/settings/resources/private => components}/PrivateResourceHttpFields.tsx (100%) rename src/{app/[orgId]/settings/resources/private => components}/PrivateResourceMultiSiteRoutingHelp.tsx (100%) rename src/{app/[orgId]/settings/resources/private => components}/PrivateResourcePortRanges.tsx (100%) rename src/{app/[orgId]/settings/resources/private => components}/PrivateResourceSitesField.tsx (98%) rename src/{app/[orgId]/settings/resources/private => components}/PrivateResourceSshFields.tsx (97%) rename src/{app/[orgId]/settings/resources/private => hooks}/useSaveSiteResource.ts (100%) rename src/{app/[orgId]/settings/resources/private => lib}/formControlUtils.ts (100%) rename src/{app/[orgId]/settings/resources/private => lib}/privateResourceUtils.ts (100%) diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx index e09366329..70a6504c4 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx @@ -29,7 +29,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useActionState, useEffect } from "react"; import { useForm } from "react-hook-form"; -import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields"; +import { PrivateResourceAccessFields } from "@app/components/PrivateResourceAccessFields"; export default function PrivateResourceAccessPage() { const t = useTranslations(); diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx index 8064ec883..16e603c01 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx @@ -20,12 +20,12 @@ import { useTranslations } from "next-intl"; import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; -import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields"; -import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges"; -import { buildSelectedSitesForResource } from "../../privateResourceUtils"; -import { asAnyControl, asAnySetValue } from "../../formControlUtils"; -import { useSaveSiteResource } from "../../useSaveSiteResource"; +import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; +import { PrivateResourceCidrDestinationField } from "@app/components/PrivateResourceDestinationFields"; +import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges"; +import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; +import { asAnyControl, asAnySetValue } from "@app/lib/formControlUtils"; +import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; export default function PrivateResourceCidrPage() { const t = useTranslations(); diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx index d14b9c3d5..8dbdb3809 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx @@ -30,7 +30,7 @@ import { useTranslations } from "next-intl"; import { useActionState, useMemo } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { useSaveSiteResource } from "../../useSaveSiteResource"; +import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; export default function PrivateResourceGeneralPage() { const t = useTranslations(); diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx index 73d4207cd..bb9734ad0 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx @@ -20,16 +20,16 @@ import { useTranslations } from "next-intl"; import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; -import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields"; -import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges"; -import { buildSelectedSitesForResource } from "../../privateResourceUtils"; +import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; +import { PrivateResourceHostDestinationFields } from "@app/components/PrivateResourceDestinationFields"; +import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges"; +import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; import { asAnyControl, asAnySetValue, asAnyWatch -} from "../../formControlUtils"; -import { useSaveSiteResource } from "../../useSaveSiteResource"; +} from "@app/lib/formControlUtils"; +import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; export default function PrivateResourceHostPage() { const t = useTranslations(); diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx index 2bbd776e5..c1263c162 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx @@ -22,15 +22,15 @@ import { useTranslations } from "next-intl"; import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; -import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields"; -import { buildSelectedSitesForResource } from "../../privateResourceUtils"; +import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; +import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields"; +import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; import { asAnyControl, asAnySetValue, asAnyWatch -} from "../../formControlUtils"; -import { useSaveSiteResource } from "../../useSaveSiteResource"; +} from "@app/lib/formControlUtils"; +import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; export default function PrivateResourceHttpPage() { const t = useTranslations(); diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx index 6de4082b6..9a739a073 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx @@ -26,15 +26,15 @@ import { useTranslations } from "next-intl"; import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { PrivateResourceSshFields } from "../../PrivateResourceSshFields"; -import { buildSelectedSitesForResource } from "../../privateResourceUtils"; +import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields"; +import type { Selectedsite } from "@app/components/site-selector"; +import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; import { asAnyControl, asAnySetValue, asAnyWatch -} from "../../formControlUtils"; -import { useSaveSiteResource } from "../../useSaveSiteResource"; -import type { Selectedsite } from "@app/components/site-selector"; +} from "@app/lib/formControlUtils"; +import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; export default function PrivateResourceSshPage() { const t = useTranslations(); diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index 49b40e3c9..7e6144172 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -50,16 +50,20 @@ import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { PrivateResourceSitesField } from "../PrivateResourceSitesField"; -import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields"; -import { PrivateResourceSshFields } from "../PrivateResourceSshFields"; -import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges"; +import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; +import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields"; +import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields"; +import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges"; import { PrivateResourceAliasField, PrivateResourceCidrDestinationField, PrivateResourceHostDestinationFields -} from "../PrivateResourceDestinationFields"; -import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils"; +} from "@app/components/PrivateResourceDestinationFields"; +import { + asAnyControl, + asAnySetValue, + asAnyWatch +} from "@app/lib/formControlUtils"; export default function CreatePrivateResourcePage() { const params = useParams(); diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceAccessFields.tsx b/src/components/PrivateResourceAccessFields.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/private/PrivateResourceAccessFields.tsx rename to src/components/PrivateResourceAccessFields.tsx diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceDestinationFields.tsx b/src/components/PrivateResourceDestinationFields.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/private/PrivateResourceDestinationFields.tsx rename to src/components/PrivateResourceDestinationFields.tsx diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceHttpFields.tsx b/src/components/PrivateResourceHttpFields.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/private/PrivateResourceHttpFields.tsx rename to src/components/PrivateResourceHttpFields.tsx diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceMultiSiteRoutingHelp.tsx b/src/components/PrivateResourceMultiSiteRoutingHelp.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/private/PrivateResourceMultiSiteRoutingHelp.tsx rename to src/components/PrivateResourceMultiSiteRoutingHelp.tsx diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourcePortRanges.tsx b/src/components/PrivateResourcePortRanges.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/private/PrivateResourcePortRanges.tsx rename to src/components/PrivateResourcePortRanges.tsx diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceSitesField.tsx b/src/components/PrivateResourceSitesField.tsx similarity index 98% rename from src/app/[orgId]/settings/resources/private/PrivateResourceSitesField.tsx rename to src/components/PrivateResourceSitesField.tsx index 1490db5af..0b0b930cb 100644 --- a/src/app/[orgId]/settings/resources/private/PrivateResourceSitesField.tsx +++ b/src/components/PrivateResourceSitesField.tsx @@ -23,7 +23,7 @@ import { import { ChevronsUpDown } from "lucide-react"; import { useTranslations } from "next-intl"; import type { Control, FieldPath, FieldValues } from "react-hook-form"; -import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp"; +import { PrivateResourceMultiSiteRoutingHelp } from "@app/components/PrivateResourceMultiSiteRoutingHelp"; type PrivateResourceSitesFieldProps = { control: Control; diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx b/src/components/PrivateResourceSshFields.tsx similarity index 97% rename from src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx rename to src/components/PrivateResourceSshFields.tsx index e61ed2292..edfdde5de 100644 --- a/src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx +++ b/src/components/PrivateResourceSshFields.tsx @@ -9,10 +9,10 @@ import { } from "@app/components/Settings"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields"; -import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields"; -import { PrivateResourceSitesField } from "./PrivateResourceSitesField"; -import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils"; +import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields"; +import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; import { inferSshPamMode } from "@app/lib/privateResourceForm"; +import { getSshUseMultiSiteTargetForm } from "@app/lib/privateResourceUtils"; import { FormControl, FormField, diff --git a/src/app/[orgId]/settings/resources/private/useSaveSiteResource.ts b/src/hooks/useSaveSiteResource.ts similarity index 100% rename from src/app/[orgId]/settings/resources/private/useSaveSiteResource.ts rename to src/hooks/useSaveSiteResource.ts diff --git a/src/app/[orgId]/settings/resources/private/formControlUtils.ts b/src/lib/formControlUtils.ts similarity index 100% rename from src/app/[orgId]/settings/resources/private/formControlUtils.ts rename to src/lib/formControlUtils.ts diff --git a/src/app/[orgId]/settings/resources/private/privateResourceUtils.ts b/src/lib/privateResourceUtils.ts similarity index 100% rename from src/app/[orgId]/settings/resources/private/privateResourceUtils.ts rename to src/lib/privateResourceUtils.ts From 7d475f5e9186886c453975a6325aede1d9fe90e9 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 10 Jul 2026 17:18:33 -0400 Subject: [PATCH 025/494] filter out pending resources --- src/app/[orgId]/settings/resources/private/page.tsx | 1 + src/app/[orgId]/settings/resources/public/page.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/app/[orgId]/settings/resources/private/page.tsx b/src/app/[orgId]/settings/resources/private/page.tsx index 8206d14e8..348340aa9 100644 --- a/src/app/[orgId]/settings/resources/private/page.tsx +++ b/src/app/[orgId]/settings/resources/private/page.tsx @@ -27,6 +27,7 @@ export default async function ClientResourcesPage( const params = await props.params; const t = await getTranslations(); const searchParams = new URLSearchParams(await props.searchParams); + searchParams.set("status", "approved"); let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = { diff --git a/src/app/[orgId]/settings/resources/public/page.tsx b/src/app/[orgId]/settings/resources/public/page.tsx index e0b9f6210..f8cc85dae 100644 --- a/src/app/[orgId]/settings/resources/public/page.tsx +++ b/src/app/[orgId]/settings/resources/public/page.tsx @@ -38,6 +38,7 @@ export default async function ProxyResourcesPage( const params = await props.params; const t = await getTranslations(); const searchParams = new URLSearchParams(await props.searchParams); + searchParams.set("status", "approved"); let resources: ListResourcesResponse["resources"] = []; let pagination: ListResourcesResponse["pagination"] = { From 34b18bdb53396807e0544dcb56249da75eef6cff Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 10 Jul 2026 17:24:10 -0400 Subject: [PATCH 026/494] only show approved resources in launcher --- .../launcher/launcherResourceAccess.ts | 75 +++++++++++++++---- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index ec4500262..ce94a7a50 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -157,7 +157,8 @@ async function resolveAccessibleIdsUncached( .where( and( eq(userResources.userId, userId), - eq(resources.orgId, orgId) + eq(resources.orgId, orgId), + eq(resources.status, "approved") ) ), userRoleIds.length > 0 @@ -171,7 +172,8 @@ async function resolveAccessibleIdsUncached( .where( and( inArray(roleResources.roleId, userRoleIds), - eq(resources.orgId, orgId) + eq(resources.orgId, orgId), + eq(resources.status, "approved") ) ) : Promise.resolve([]), @@ -183,7 +185,11 @@ async function resolveAccessibleIdsUncached( eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId) ) .where( - and(eq(userPolicies.userId, userId), eq(resources.orgId, orgId)) + and( + eq(userPolicies.userId, userId), + eq(resources.orgId, orgId), + eq(resources.status, "approved") + ) ), userRoleIds.length > 0 ? db @@ -199,21 +205,48 @@ async function resolveAccessibleIdsUncached( .where( and( inArray(rolePolicies.roleId, userRoleIds), - eq(resources.orgId, orgId) + eq(resources.orgId, orgId), + eq(resources.status, "approved") ) ) : Promise.resolve([]), db .select({ siteResourceId: userSiteResources.siteResourceId }) .from(userSiteResources) - .where(eq(userSiteResources.userId, userId)), + .innerJoin( + siteResources, + eq( + userSiteResources.siteResourceId, + siteResources.siteResourceId + ) + ) + .where( + and( + eq(userSiteResources.userId, userId), + eq(siteResources.orgId, orgId), + eq(siteResources.status, "approved") + ) + ), userRoleIds.length > 0 ? db .select({ siteResourceId: roleSiteResources.siteResourceId }) .from(roleSiteResources) - .where(inArray(roleSiteResources.roleId, userRoleIds)) + .innerJoin( + siteResources, + eq( + roleSiteResources.siteResourceId, + siteResources.siteResourceId + ) + ) + .where( + and( + inArray(roleSiteResources.roleId, userRoleIds), + eq(siteResources.orgId, orgId), + eq(siteResources.status, "approved") + ) + ) : Promise.resolve([]) ]); @@ -365,6 +398,7 @@ async function filterPublicResourceIdsByTextSearch( inArray(resources.resourceId, resourceIds), eq(resources.orgId, orgId), eq(resources.enabled, true), + eq(resources.status, "approved"), textMatch ) ); @@ -402,6 +436,7 @@ async function filterSiteResourceIdsByTextSearch( inArray(siteResources.siteResourceId, siteResourceIds), eq(siteResources.orgId, orgId), eq(siteResources.enabled, true), + eq(siteResources.status, "approved"), textMatch ) ); @@ -503,7 +538,8 @@ async function listSiteGroups( const publicConditions = [ inArray(resources.resourceId, accessible.resourceIds), eq(resources.orgId, orgId), - eq(resources.enabled, true) + eq(resources.enabled, true), + eq(resources.status, "approved") ]; if (searchPublic) { publicConditions.push(searchPublic); @@ -558,7 +594,8 @@ async function listSiteGroups( const siteConditions = [ inArray(siteResources.siteResourceId, accessible.siteResourceIds), eq(siteResources.orgId, orgId), - eq(siteResources.enabled, true) + eq(siteResources.enabled, true), + eq(siteResources.status, "approved") ]; if (searchSite) { siteConditions.push(searchSite); @@ -621,7 +658,8 @@ async function listSiteGroups( const noSitePublicConditions = [ inArray(resources.resourceId, accessible.resourceIds), eq(resources.orgId, orgId), - eq(resources.enabled, true) + eq(resources.enabled, true), + eq(resources.status, "approved") ]; if (searchPublic) { noSitePublicConditions.push(searchPublic); @@ -655,7 +693,8 @@ async function listSiteGroups( const noSiteSiteConditions = [ inArray(siteResources.siteResourceId, accessible.siteResourceIds), eq(siteResources.orgId, orgId), - eq(siteResources.enabled, true) + eq(siteResources.enabled, true), + eq(siteResources.status, "approved") ]; if (searchSite) { noSiteSiteConditions.push(searchSite); @@ -746,7 +785,8 @@ async function listLabelGroups( const publicConditions = [ inArray(resources.resourceId, accessible.resourceIds), eq(resources.orgId, orgId), - eq(resources.enabled, true) + eq(resources.enabled, true), + eq(resources.status, "approved") ]; const searchPublic = buildSearchConditionForPublic(query.query); if (searchPublic) { @@ -810,7 +850,8 @@ async function listLabelGroups( const siteConditions = [ inArray(siteResources.siteResourceId, accessible.siteResourceIds), eq(siteResources.orgId, orgId), - eq(siteResources.enabled, true) + eq(siteResources.enabled, true), + eq(siteResources.status, "approved") ]; const searchSite = buildSearchConditionForSiteResource(query.query); if (searchSite) { @@ -997,6 +1038,7 @@ async function mapPublicResources( inArray(resources.resourceId, resourceIds), eq(resources.orgId, orgId), eq(resources.enabled, true), + eq(resources.status, "approved"), siteIdFilter != null ? eq(sites.siteId, siteIdFilter) : undefined @@ -1088,6 +1130,7 @@ async function mapSiteResources( inArray(siteResources.siteResourceId, siteResourceIds), eq(siteResources.orgId, orgId), eq(siteResources.enabled, true), + eq(siteResources.status, "approved"), siteIdFilter != null ? eq(sites.siteId, siteIdFilter) : undefined @@ -1382,7 +1425,8 @@ async function collectAccessibleSites( const publicConditions = [ inArray(resources.resourceId, accessible.resourceIds), eq(resources.orgId, orgId), - eq(resources.enabled, true) + eq(resources.enabled, true), + eq(resources.status, "approved") ]; if (siteNameSearch) { publicConditions.push(siteNameSearch); @@ -1422,7 +1466,8 @@ async function collectAccessibleSites( const siteConditions = [ inArray(siteResources.siteResourceId, accessible.siteResourceIds), eq(siteResources.orgId, orgId), - eq(siteResources.enabled, true) + eq(siteResources.enabled, true), + eq(siteResources.status, "approved") ]; if (siteNameSearch) { siteConditions.push(siteNameSearch); @@ -1476,6 +1521,7 @@ async function collectAccessibleLabels( inArray(resources.resourceId, accessible.resourceIds), eq(resources.orgId, orgId), eq(resources.enabled, true), + eq(resources.status, "approved"), eq(labels.orgId, orgId) ]; if (labelNameSearch) { @@ -1511,6 +1557,7 @@ async function collectAccessibleLabels( inArray(siteResources.siteResourceId, accessible.siteResourceIds), eq(siteResources.orgId, orgId), eq(siteResources.enabled, true), + eq(siteResources.status, "approved"), eq(labels.orgId, orgId) ]; if (labelNameSearch) { From 591cb9cdc1eaf27dcaed424904f8204bcf058318 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 10 Jul 2026 18:05:16 -0400 Subject: [PATCH 027/494] dont use strict query params in list alises --- server/routers/resource/listUserResourceAliases.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts index 100b1624d..98e73ad85 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -57,7 +57,7 @@ const listUserResourceAliasesParamsSchema = z.strictObject({ orgId: z.string() }); -const listUserResourceAliasesQuerySchema = z.strictObject({ +const listUserResourceAliasesQuerySchema = z.object({ pageSize: z.coerce .number() .int() From 933ca71c16fa5c94856cf586c1b7aa38b3a1481b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 12 Jul 2026 17:16:08 -0400 Subject: [PATCH 028/494] add approve site endpoint --- server/routers/external.ts | 8 + server/routers/integration.ts | 8 + server/routers/site/approveSite.ts | 251 +++++++++++++++++++++++++++ server/routers/site/index.ts | 1 + server/routers/site/updateSite.ts | 1 - src/components/PendingSitesTable.tsx | 2 +- 6 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 server/routers/site/approveSite.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index 9e4c5b3f1..9b9c48293 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -248,6 +248,14 @@ authenticated.post( site.updateSite ); +authenticated.post( + "/site/:siteId/approve", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.updateSite), + logActionAudit(ActionsEnum.updateSite), + site.approveSite +); + authenticated.delete( "/site/:siteId", verifySiteAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 74ea20078..f6ab6bf6c 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -138,6 +138,14 @@ authenticated.post( logActionAudit(ActionsEnum.updateSite), site.updateSite ); + +authenticated.post( + "/site/:siteId/approve", + verifyApiKeySiteAccess, + verifyApiKeyHasAction(ActionsEnum.updateSite), + logActionAudit(ActionsEnum.updateSite), + site.approveSite +); authenticated.post( "/org/:orgId/reset-bandwidth", verifyApiKeyOrgAccess, diff --git a/server/routers/site/approveSite.ts b/server/routers/site/approveSite.ts new file mode 100644 index 000000000..19e2795ad --- /dev/null +++ b/server/routers/site/approveSite.ts @@ -0,0 +1,251 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + resources, + siteNetworks, + siteResources, + sites, + type Site, + type SiteResource +} from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { + getResourceIdsForSite, + getSiteResourceIdsForSite +} from "@server/lib/deleteSiteAssociatedResources"; +import { + handleMessagingForUpdatedSiteResource, + rebuildClientAssociationsFromSiteResource, + waitForSiteResourceRebuildIdle +} from "@server/lib/rebuildClientAssociations"; + +const approveSiteParamsSchema = z.strictObject({ + siteId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/site/{siteId}/approve", + description: + "Approve a pending site and approve (and enable when needed) its associated resources.", + tags: [OpenAPITags.Site], + request: { + params: approveSiteParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +type SiteResourceEnableSideEffect = { + existing: SiteResource; + updated: SiteResource; + siteIds: number[]; +}; + +export async function approveSite( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = approveSiteParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteId } = parsedParams.data; + + const [existingSite] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!existingSite) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + + if (!existingSite.orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Site with ID ${siteId} has no organization` + ) + ); + } + + const orgId = existingSite.orgId; + let updatedSite: Site | undefined; + const siteResourceEnableSideEffects: SiteResourceEnableSideEffect[] = + []; + + await db.transaction(async (trx) => { + [updatedSite] = await trx + .update(sites) + .set({ status: "approved" }) + .where(eq(sites.siteId, siteId)) + .returning(); + + const resourceIds = await getResourceIdsForSite(siteId, trx); + const siteResourceIds = await getSiteResourceIdsForSite( + siteId, + orgId, + trx + ); + + if (resourceIds.length > 0) { + const pendingDisabledResources = await trx + .select({ resourceId: resources.resourceId }) + .from(resources) + .where( + and( + inArray(resources.resourceId, resourceIds), + eq(resources.status, "pending"), + eq(resources.enabled, false) + ) + ); + + await trx + .update(resources) + .set({ status: "approved" }) + .where(inArray(resources.resourceId, resourceIds)); + + if (pendingDisabledResources.length > 0) { + await trx + .update(resources) + .set({ enabled: true }) + .where( + inArray( + resources.resourceId, + pendingDisabledResources.map( + (r) => r.resourceId + ) + ) + ); + } + } + + if (siteResourceIds.length > 0) { + const existingSiteResources = await trx + .select() + .from(siteResources) + .where( + inArray(siteResources.siteResourceId, siteResourceIds) + ); + + const pendingDisabledSiteResources = + existingSiteResources.filter( + (sr) => sr.status === "pending" && !sr.enabled + ); + + await trx + .update(siteResources) + .set({ status: "approved" }) + .where( + inArray(siteResources.siteResourceId, siteResourceIds) + ); + + if (pendingDisabledSiteResources.length > 0) { + const enableIds = pendingDisabledSiteResources.map( + (sr) => sr.siteResourceId + ); + + const updatedEnabledSiteResources = await trx + .update(siteResources) + .set({ enabled: true }) + .where(inArray(siteResources.siteResourceId, enableIds)) + .returning(); + + for (const updated of updatedEnabledSiteResources) { + const existing = pendingDisabledSiteResources.find( + (sr) => sr.siteResourceId === updated.siteResourceId + ); + if (!existing || !updated.networkId) { + continue; + } + + const networkSites = await trx + .select({ siteId: siteNetworks.siteId }) + .from(siteNetworks) + .where( + eq(siteNetworks.networkId, updated.networkId) + ); + + siteResourceEnableSideEffects.push({ + existing, + updated, + siteIds: networkSites.map((s) => s.siteId) + }); + } + } + } + }); + + for (const sideEffect of siteResourceEnableSideEffects) { + rebuildClientAssociationsFromSiteResource(sideEffect.updated) + .then(() => + waitForSiteResourceRebuildIdle( + sideEffect.updated.siteResourceId + ) + ) + .then(() => + handleMessagingForUpdatedSiteResource( + sideEffect.existing, + sideEffect.updated, + sideEffect.siteIds, + sideEffect.siteIds + ) + ) + .catch((e) => { + logger.error( + `Failed to rebuild and handle messaging for site resource ${sideEffect.updated.siteResourceId} after site approval:`, + e + ); + }); + } + + return response(res, { + data: updatedSite ?? null, + success: true, + error: false, + message: "Site approved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 292c57971..43b778f62 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -3,6 +3,7 @@ export * from "./getStatusHistory"; export * from "./createSite"; export * from "./deleteSite"; export * from "./updateSite"; +export * from "./approveSite"; export * from "./listSites"; export * from "./listSiteRoles"; export * from "./pickSiteDefaults"; diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index c6851a3c3..42ab8b246 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -21,7 +21,6 @@ const updateSiteBodySchema = z name: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(), dockerSocketEnabled: z.boolean().optional(), - status: z.enum(["pending", "approved"]).optional(), autoUpdateEnabled: z.boolean().optional(), autoUpdateOverrideOrg: z.boolean().optional() }) diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index 8cdfc424d..e2af5ee4e 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -111,7 +111,7 @@ export default function PendingSitesTable({ async function approveSite(siteId: number) { setApprovingIds((prev) => new Set(prev).add(siteId)); try { - await api.post(`/site/${siteId}`, { status: "approved" }); + await api.post(`/site/${siteId}/approve`); toast({ title: t("success"), description: t("siteApproveSuccess"), From a8f3f710213ea16400cafd4d46410d2719b1d420 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 12 Jul 2026 17:48:48 -0400 Subject: [PATCH 029/494] add reject endpoint --- messages/en-US.json | 10 +- server/auth/actions.ts | 1 + server/lib/deleteResource.ts | 8 +- server/lib/deleteSiteAssociatedResources.ts | 85 +++++++++- server/routers/external.ts | 12 +- server/routers/integration.ts | 7 - server/routers/site/deleteSite.ts | 18 +- server/routers/site/index.ts | 1 + server/routers/site/rejectSite.ts | 178 ++++++++++++++++++++ src/components/PendingSitesTable.tsx | 26 +-- src/components/PermissionsSelectBox.tsx | 4 +- 11 files changed, 309 insertions(+), 41 deletions(-) create mode 100644 server/routers/site/rejectSite.ts diff --git a/messages/en-US.json b/messages/en-US.json index 2a2c83b18..549865dcf 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -449,8 +449,14 @@ "provisioningManage": "Provisioning", "provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.", "pendingSites": "Pending Sites", - "siteApproveSuccess": "Site approved successfully", + "siteApproveSuccess": "Site and associated resources approved successfully", "siteApproveError": "Error approving site", + "siteReject": "Reject Site", + "siteQuestionReject": "Are you sure you want to reject this site?", + "siteMessageReject": "This will permanently delete the site and any associated resources that are still pending.", + "siteConfirmReject": "Confirm Reject Site", + "siteRejectSuccess": "Site rejected successfully", + "siteRejectError": "Error rejecting site", "provisioningKeys": "Provisioning Keys", "searchProvisioningKeys": "Search provisioning keys...", "provisioningKeysAdd": "Generate Provisioning Key", @@ -1420,6 +1426,8 @@ "setupTokenDescription": "Enter the setup token from the server console.", "setupTokenRequired": "Setup token is required", "actionUpdateSite": "Update Site", + "actionApproveSite": "Approve Site", + "actionRejectSite": "Reject Site", "actionResetSiteBandwidth": "Reset Organization Bandwidth", "actionListSiteRoles": "List Allowed Site Roles", "actionCreateResource": "Create Resource", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 50c98c528..741d7a057 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -21,6 +21,7 @@ export enum ActionsEnum { getSite = "getSite", listSites = "listSites", updateSite = "updateSite", + updateSiteApprovals = "updateSiteApprovals", restartSite = "restartSite", resetSiteBandwidth = "resetSiteBandwidth", reGenerateSecret = "reGenerateSecret", diff --git a/server/lib/deleteResource.ts b/server/lib/deleteResource.ts index b2ffa0f0f..3f33c7400 100644 --- a/server/lib/deleteResource.ts +++ b/server/lib/deleteResource.ts @@ -14,8 +14,6 @@ import { } from "@server/db"; import logger from "@server/logger"; import { removeTargets } from "@server/routers/newt/targets"; -import createHttpError from "http-errors"; -import HttpCode from "@server/types/HttpCode"; export type DeleteResourceResult = { deletedResource: Resource; @@ -117,10 +115,10 @@ export async function runResourceDeleteSideEffects( .limit(1); if (!site) { - throw createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${target.siteId} not found` + logger.debug( + `Site with ID ${target.siteId} not found during resource delete side effects; skipping target removal` ); + continue; } if (site.pubKey && site.type === "newt") { diff --git a/server/lib/deleteSiteAssociatedResources.ts b/server/lib/deleteSiteAssociatedResources.ts index 61da82f58..e69585d1a 100644 --- a/server/lib/deleteSiteAssociatedResources.ts +++ b/server/lib/deleteSiteAssociatedResources.ts @@ -1,6 +1,7 @@ -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { db, + resources, siteNetworks, siteResources, targets, @@ -97,6 +98,64 @@ export function exceedsSiteAssociatedResourceDeleteLimit( return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE; } +export async function getPendingResourceIdsForSite( + siteId: number, + trx: Transaction | typeof db = db +): Promise { + const resourceIds = await getResourceIdsForSite(siteId, trx); + if (resourceIds.length === 0) { + return []; + } + + const rows = await trx + .select({ resourceId: resources.resourceId }) + .from(resources) + .where( + and( + inArray(resources.resourceId, resourceIds), + eq(resources.status, "pending") + ) + ); + + return rows.map((row) => row.resourceId); +} + +export async function getPendingSiteResourceIdsForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx); + if (siteResourceIds.length === 0) { + return []; + } + + const rows = await trx + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .where( + and( + inArray(siteResources.siteResourceId, siteResourceIds), + eq(siteResources.status, "pending") + ) + ); + + return rows.map((row) => row.siteResourceId); +} + +export async function getPendingAssociatedResourceCountForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const [resourceIds, siteResourceIds] = await Promise.all([ + getPendingResourceIdsForSite(siteId, trx), + getPendingSiteResourceIdsForSite(siteId, orgId, trx) + ]); + + return resourceIds.length + siteResourceIds.length; +} + export async function deleteAssociatedResourcesForSite( siteId: number, orgId: string, @@ -105,12 +164,32 @@ export async function deleteAssociatedResourcesForSite( const resourceIds = await getResourceIdsForSite(siteId, trx); const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx); - const [resources, siteResourcesDeleted] = await Promise.all([ + const [deletedResources, siteResourcesDeleted] = await Promise.all([ performDeleteResources(resourceIds, trx), performDeleteSiteResources(siteResourceIds, trx) ]); - return { resources, siteResources: siteResourcesDeleted }; + return { resources: deletedResources, siteResources: siteResourcesDeleted }; +} + +export async function deletePendingAssociatedResourcesForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const resourceIds = await getPendingResourceIdsForSite(siteId, trx); + const siteResourceIds = await getPendingSiteResourceIdsForSite( + siteId, + orgId, + trx + ); + + const [deletedResources, siteResourcesDeleted] = await Promise.all([ + performDeleteResources(resourceIds, trx), + performDeleteSiteResources(siteResourceIds, trx) + ]); + + return { resources: deletedResources, siteResources: siteResourcesDeleted }; } export async function runDeleteSiteAssociatedResourcesSideEffects( diff --git a/server/routers/external.ts b/server/routers/external.ts index 9b9c48293..62ca15316 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -251,11 +251,19 @@ authenticated.post( authenticated.post( "/site/:siteId/approve", verifySiteAccess, - verifyUserHasAction(ActionsEnum.updateSite), - logActionAudit(ActionsEnum.updateSite), + verifyUserHasAction(ActionsEnum.updateSiteApprovals), + logActionAudit(ActionsEnum.updateSiteApprovals), site.approveSite ); +authenticated.post( + "/site/:siteId/reject", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.updateSiteApprovals), + logActionAudit(ActionsEnum.updateSiteApprovals), + site.rejectSite +); + authenticated.delete( "/site/:siteId", verifySiteAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index f6ab6bf6c..3abd664eb 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -139,13 +139,6 @@ authenticated.post( site.updateSite ); -authenticated.post( - "/site/:siteId/approve", - verifyApiKeySiteAccess, - verifyApiKeyHasAction(ActionsEnum.updateSite), - logActionAudit(ActionsEnum.updateSite), - site.approveSite -); authenticated.post( "/org/:orgId/reset-bandwidth", verifyApiKeyOrgAccess, diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 602032ffb..2126b69d8 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -159,15 +159,21 @@ export async function deleteSite( siteResources: [] }; - await db.transaction(async (trx) => { - if (deleteResources) { + if (deleteResources) { + await db.transaction(async (trx) => { resourceSideEffects = await deleteAssociatedResourcesForSite( siteId, site.orgId, trx ); - } + }); + await runDeleteSiteAssociatedResourcesSideEffects( + resourceSideEffects + ); + } + + await db.transaction(async (trx) => { if (site.type == "wireguard") { if (site.pubKey) { await deletePeer(site.exitNodeId!, site.pubKey); @@ -180,12 +186,6 @@ export async function deleteSite( await usageService.add(site.orgId, LimitId.SITES, -1, trx); }); - if (deleteResources) { - await runDeleteSiteAssociatedResourcesSideEffects( - resourceSideEffects - ); - } - if (deletedNewt) { const payload = { type: `newt/wg/terminate`, diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 43b778f62..c0c399789 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -4,6 +4,7 @@ export * from "./createSite"; export * from "./deleteSite"; export * from "./updateSite"; export * from "./approveSite"; +export * from "./rejectSite"; export * from "./listSites"; export * from "./listSiteRoles"; export * from "./pickSiteDefaults"; diff --git a/server/routers/site/rejectSite.ts b/server/routers/site/rejectSite.ts new file mode 100644 index 000000000..037879e82 --- /dev/null +++ b/server/routers/site/rejectSite.ts @@ -0,0 +1,178 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { newts, sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { deletePeer } from "../gerbil/peers"; +import { fromError } from "zod-validation-error"; +import { sendToClient } from "#dynamic/routers/ws"; +import { OpenAPITags, registry } from "@server/openApi"; +import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations"; +import { usageService } from "@server/lib/billing/usageService"; +import { LimitId } from "@server/lib/billing"; +import { + deletePendingAssociatedResourcesForSite, + exceedsSiteAssociatedResourceDeleteLimit, + getPendingAssociatedResourceCountForSite, + runDeleteSiteAssociatedResourcesSideEffects, + MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE, + type DeleteSiteAssociatedResourcesSideEffects +} from "@server/lib/deleteSiteAssociatedResources"; + +const rejectSiteParamsSchema = z.strictObject({ + siteId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/site/{siteId}/reject", + description: + "Reject a pending site by deleting it and any associated resources that are still pending.", + tags: [OpenAPITags.Site], + request: { + params: rejectSiteParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function rejectSite( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = rejectSiteParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteId } = parsedParams.data; + + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + + if (!site.orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Site with ID ${siteId} has no organization` + ) + ); + } + + const pendingAssociatedResourceCount = + await getPendingAssociatedResourceCountForSite(siteId, site.orgId); + + if ( + exceedsSiteAssociatedResourceDeleteLimit( + pendingAssociatedResourceCount + ) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Cannot reject site and associated pending resources when the site has more than ${MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE} pending resources` + ) + ); + } + + const [deletedNewt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + + let resourceSideEffects: DeleteSiteAssociatedResourcesSideEffects = { + resources: [], + siteResources: [] + }; + + await db.transaction(async (trx) => { + resourceSideEffects = await deletePendingAssociatedResourcesForSite( + siteId, + site.orgId, + trx + ); + }); + + await runDeleteSiteAssociatedResourcesSideEffects(resourceSideEffects); + + await db.transaction(async (trx) => { + if (site.type == "wireguard") { + if (site.pubKey) { + await deletePeer(site.exitNodeId!, site.pubKey); + } + } else if (site.type == "newt") { + await cleanupSiteAssociations(site, trx); + } + + await trx.delete(sites).where(eq(sites.siteId, siteId)); + await usageService.add(site.orgId, LimitId.SITES, -1, trx); + }); + + if (deletedNewt) { + const payload = { + type: `newt/wg/terminate`, + data: {} + }; + sendToClient(deletedNewt.newtId, payload).catch((error) => { + logger.error( + "Failed to send termination message to newt:", + error + ); + }); + } + + return response(res, { + data: null, + success: true, + error: false, + message: "Site rejected successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + if (createHttpError.isHttpError(error)) { + return next(error); + } + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index e2af5ee4e..5981bf387 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -65,7 +65,7 @@ export default function PendingSitesTable({ const [isRefreshing, startTransition] = useTransition(); const [approvingIds, setApprovingIds] = useState>(new Set()); const [rejectingIds, setRejectingIds] = useState>(new Set()); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isRejectModalOpen, setIsRejectModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const api = createApiClient(useEnvContext()); @@ -136,20 +136,20 @@ export default function PendingSitesTable({ async function rejectSite(siteId: number) { setRejectingIds((prev) => new Set(prev).add(siteId)); try { - await api.delete(`/site/${siteId}`); + await api.post(`/site/${siteId}/reject`); toast({ title: t("success"), - description: t("siteDeleted"), + description: t("siteRejectSuccess"), variant: "default" }); - setIsDeleteModalOpen(false); + setIsRejectModalOpen(false); setSelectedSite(null); router.refresh(); } catch (e) { toast({ variant: "destructive", - title: t("siteErrorDelete"), - description: formatAxiosError(e, t("siteErrorDelete")) + title: t("siteRejectError"), + description: formatAxiosError(e, t("siteRejectError")) }); } finally { setRejectingIds((prev) => { @@ -445,7 +445,7 @@ export default function PendingSitesTable({ disabled={isApproving || isRejecting} onClick={() => { setSelectedSite(siteRow); - setIsDeleteModalOpen(true); + setIsRejectModalOpen(true); }} > @@ -493,23 +493,23 @@ export default function PendingSitesTable({ <> {selectedSite && ( { - setIsDeleteModalOpen(val); + setIsRejectModalOpen(val); if (!val) { setSelectedSite(null); } }} dialog={
-

{t("siteQuestionRemove")}

-

{t("siteMessageRemove")}

+

{t("siteQuestionReject")}

+

{t("siteMessageReject")}

} - buttonText={t("siteConfirmDelete")} + buttonText={t("siteConfirmReject")} onConfirm={async () => rejectSite(selectedSite.id)} string={selectedSite.name} - title={t("siteDelete")} + title={t("siteReject")} /> )} Date: Sun, 12 Jul 2026 17:53:57 -0400 Subject: [PATCH 030/494] add resources overview column to pending sites table --- src/components/PendingSitesTable.tsx | 74 ++++++++++++++++++++++++ src/components/SiteResourcesOverview.tsx | 35 ++++++----- 2 files changed, 96 insertions(+), 13 deletions(-) diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index 5981bf387..9e0fa0ad8 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -1,6 +1,16 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { + Credenza, + CredenzaBody, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import SiteResourcesOverview from "@app/components/SiteResourcesOverview"; import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { @@ -24,6 +34,7 @@ import { ArrowUp10Icon, ArrowUpRight, Check, + ChevronDown, ChevronsUpDownIcon, MoreHorizontal, X @@ -67,6 +78,8 @@ export default function PendingSitesTable({ const [rejectingIds, setRejectingIds] = useState>(new Set()); const [isRejectModalOpen, setIsRejectModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); + const [resourcesDialogSite, setResourcesDialogSite] = + useState(null); const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -342,6 +355,29 @@ export default function PendingSitesTable({ } } }, + { + id: "resources", + accessorKey: "resourceCount", + friendlyName: t("resources"), + header: () => {t("resources")}, + cell: ({ row }) => { + const siteRow = row.original; + return ( + + ); + } + }, { accessorKey: "exitNode", friendlyName: t("exitNode"), @@ -491,6 +527,44 @@ export default function PendingSitesTable({ return ( <> + { + if (!open) setResourcesDialogSite(null); + }} + > + + + {t("siteResourcesTab")} + + {t("siteResourcesDialogDescription")} + + + + {resourcesDialogSite != null && ( + + )} + + + + + + + {selectedSite && ( - - {viewAllLabel} - + {viewAllHref && viewAllLabel ? ( + + {viewAllLabel} + + ) : null} ); @@ -319,6 +321,8 @@ type SiteResourcesOverviewProps = { initialPrivateForbidden: boolean; /** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */ orgIdOverride?: string; + /** When false, hides links to the org resources tables filtered by this site. */ + showViewAllLinks?: boolean; }; export default function SiteResourcesOverview({ @@ -327,7 +331,8 @@ export default function SiteResourcesOverview({ initialPrivateData, initialPublicForbidden, initialPrivateForbidden, - orgIdOverride + orgIdOverride, + showViewAllLinks = true }: SiteResourcesOverviewProps) { const t = useTranslations(); const params = useParams<{ orgId: string }>(); @@ -467,8 +472,10 @@ export default function SiteResourcesOverview({ key="public" title={t("siteResourcesSectionPublic")} description={t("siteResourcesSectionPublicDescription")} - viewAllHref={publicViewAllHref} - viewAllLabel={t("siteResourcesViewAllPublic")} + viewAllHref={showViewAllLinks ? publicViewAllHref : undefined} + viewAllLabel={ + showViewAllLinks ? t("siteResourcesViewAllPublic") : undefined + } emptyLabel={t("siteResourcesEmptyPublic")} isForbidden={publicForbidden} isFetching={publicQuery.isFetching} @@ -484,8 +491,10 @@ export default function SiteResourcesOverview({ key="private" title={t("siteResourcesSectionPrivate")} description={t("siteResourcesSectionPrivateDescription")} - viewAllHref={privateViewAllHref} - viewAllLabel={t("siteResourcesViewAllPrivate")} + viewAllHref={showViewAllLinks ? privateViewAllHref : undefined} + viewAllLabel={ + showViewAllLinks ? t("siteResourcesViewAllPrivate") : undefined + } emptyLabel={t("siteResourcesEmptyPrivate")} isForbidden={privateForbidden} isFetching={privateQuery.isFetching} From d1b0bbb6d98f6721bdcd8bd52721e5acdd2b1fb1 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 15 Jul 2026 16:05:53 -0400 Subject: [PATCH 031/494] update translations --- messages/en-US.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 549865dcf..9e2175d53 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -472,12 +472,12 @@ "provisioningKeysSave": "Save the provisioning key", "provisioningKeysSaveDescription": "You will only be able to see this once. Copy it to a secure place.", "provisioningKeysErrorCreate": "Error creating provisioning key", - "provisioningKeysList": "New provisioning key", - "provisioningKeysMaxBatchSize": "Max batch size", + "provisioningKeysList": "New Provisioning Key", + "provisioningKeysMaxBatchSize": "Max Batch Size", "provisioningKeysUnlimitedBatchSize": "Unlimited batch size (no limit)", "provisioningKeysMaxBatchUnlimited": "Unlimited", "provisioningKeysMaxBatchSizeInvalid": "Enter a valid max batch size (1–1,000,000).", - "provisioningKeysValidUntil": "Valid until", + "provisioningKeysValidUntil": "Valid Until", "provisioningKeysValidUntilHint": "Leave empty for no expiration.", "provisioningKeysValidUntilInvalid": "Enter a valid date and time.", "provisioningKeysNumUsed": "Times used", @@ -486,7 +486,7 @@ "provisioningKeysNeverUsed": "Never", "provisioningKeysEdit": "Edit Provisioning Key", "provisioningKeysEditDescription": "Update the max batch size and expiration time for this key.", - "provisioningKeysApproveNewSites": "Approve new sites", + "provisioningKeysApproveNewSites": "Approve New Sites", "provisioningKeysApproveNewSitesDescription": "Automatically approve sites that register with this key.", "provisioningKeysUpdateError": "Error updating provisioning key", "provisioningKeysUpdated": "Provisioning key updated", From d4c602cf917bae495d29a5120515dd8dd6e44d99 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Jul 2026 17:21:19 -0400 Subject: [PATCH 032/494] Update the counts when deleting resources --- server/routers/site/deleteSite.ts | 18 ++++++++++++++++++ server/routers/site/rejectSite.ts | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 2126b69d8..6cf584d18 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -166,6 +166,24 @@ export async function deleteSite( site.orgId, trx ); + + if (resourceSideEffects.resources.length > 0) { + await usageService.add( + site.orgId, + LimitId.PUBLIC_RESOURCES, + -resourceSideEffects.resources.length, + trx + ); + } + + if (resourceSideEffects.siteResources.length > 0) { + await usageService.add( + site.orgId, + LimitId.PRIVATE_RESOURCES, + -resourceSideEffects.siteResources.length, + trx + ); + } }); await runDeleteSiteAssociatedResourcesSideEffects( diff --git a/server/routers/site/rejectSite.ts b/server/routers/site/rejectSite.ts index 037879e82..c671dcd79 100644 --- a/server/routers/site/rejectSite.ts +++ b/server/routers/site/rejectSite.ts @@ -129,6 +129,24 @@ export async function rejectSite( site.orgId, trx ); + + if (resourceSideEffects.resources.length > 0) { + await usageService.add( + site.orgId, + LimitId.PUBLIC_RESOURCES, + -resourceSideEffects.resources.length, + trx + ); + } + + if (resourceSideEffects.siteResources.length > 0) { + await usageService.add( + site.orgId, + LimitId.PRIVATE_RESOURCES, + -resourceSideEffects.siteResources.length, + trx + ); + } }); await runDeleteSiteAssociatedResourcesSideEffects(resourceSideEffects); From a7010b2788821821f587823242041db86a8b7c4f Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:53:51 -0400 Subject: [PATCH 033/494] New translations en-us.json (Spanish) [ci skip] --- messages/es-ES.json | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/messages/es-ES.json b/messages/es-ES.json index 9bc4c83a1..d7453592a 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -449,8 +449,14 @@ "provisioningManage": "Aprovisionamiento", "provisioningDescription": "Administrar las claves de aprovisionamiento y revisar los sitios pendientes de aprobación.", "pendingSites": "Sitios pendientes", - "siteApproveSuccess": "Sitio aprobado con éxito", + "siteApproveSuccess": "Sitio y recursos asociados aprobados correctamente", "siteApproveError": "Error al aprobar el sitio", + "siteReject": "Rechazar Sitio", + "siteQuestionReject": "¿Está seguro de que desea rechazar este sitio?", + "siteMessageReject": "Esto eliminará permanentemente el sitio y cualquier recurso asociado que aún esté pendiente.", + "siteConfirmReject": "Confirmar Rechazo del Sitio", + "siteRejectSuccess": "Sitio rechazado correctamente", + "siteRejectError": "Error al rechazar el sitio", "provisioningKeys": "Claves de aprovisionamiento", "searchProvisioningKeys": "Buscar claves de suministro...", "provisioningKeysAdd": "Generar clave de aprovisionamiento", @@ -466,12 +472,12 @@ "provisioningKeysSave": "Guardar la clave de aprovisionamiento", "provisioningKeysSaveDescription": "Sólo podrás verlo una vez. Copítalo a un lugar seguro.", "provisioningKeysErrorCreate": "Error al crear la clave de provisioning", - "provisioningKeysList": "Nueva clave de aprovisionamiento", - "provisioningKeysMaxBatchSize": "Tamaño máximo de lote", + "provisioningKeysList": "Nueva Clave de Provisión", + "provisioningKeysMaxBatchSize": "Tamaño Máximo del Lote", "provisioningKeysUnlimitedBatchSize": "Tamaño ilimitado del lote (sin límite)", "provisioningKeysMaxBatchUnlimited": "Ilimitado", "provisioningKeysMaxBatchSizeInvalid": "Introduzca un tamaño máximo de lote válido (1–1,000,000).", - "provisioningKeysValidUntil": "Válido hasta", + "provisioningKeysValidUntil": "Válido Hasta", "provisioningKeysValidUntilHint": "Dejar vacío para no expirar.", "provisioningKeysValidUntilInvalid": "Introduzca una fecha y hora válidas.", "provisioningKeysNumUsed": "Tiempos usados", @@ -480,7 +486,7 @@ "provisioningKeysNeverUsed": "Nunca", "provisioningKeysEdit": "Editar clave de aprovisionamiento", "provisioningKeysEditDescription": "Actualizar el tamaño máximo de lote y el tiempo de caducidad para esta clave.", - "provisioningKeysApproveNewSites": "Aprobar nuevos sitios", + "provisioningKeysApproveNewSites": "Aprobar Nuevos Sitios", "provisioningKeysApproveNewSitesDescription": "Aprobar automáticamente los sitios que se registran con esta clave.", "provisioningKeysUpdateError": "Error al actualizar la clave de aprovisionamiento", "provisioningKeysUpdated": "Clave de aprovisionamiento actualizada", @@ -1397,6 +1403,7 @@ "createOrgUser": "Crear usuario Org", "actionUpdateOrg": "Actualizar organización", "actionRemoveInvitation": "Eliminar invitación", + "actionRemoveUserRole": "Quitar Rol de Usuario", "actionUpdateUser": "Actualizar usuario", "actionGetUser": "Obtener usuario", "actionGetOrgUser": "Obtener usuario de la organización", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.", "setupTokenRequired": "Se requiere el token de configuración", "actionUpdateSite": "Actualizar sitio", + "actionApproveSite": "Aprobar Sitio", + "actionRejectSite": "Rechazar Sitio", "actionResetSiteBandwidth": "Restablecer ancho de banda de la organización", "actionListSiteRoles": "Lista de roles permitidos del sitio", "actionCreateResource": "Crear Recurso", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "Introduzca el código de su aplicación de autenticación o uno de sus códigos de copia de seguridad de un solo uso.", "otpAuthSubmit": "Enviar código", "idpContinue": "O continuar con", + "idpLastUsed": "Último Uso", "otpAuthBack": "Volver a la contraseña", "navbar": "Menú de navegación", "navbarDescription": "Menú de navegación principal para la aplicación", From e497fcc3b94950cfe43fe74edb2233060bdb2596 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:53:53 -0400 Subject: [PATCH 034/494] New translations en-us.json (Bulgarian) [ci skip] --- messages/bg-BG.json | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 2d455afa8..25e192fec 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -449,8 +449,14 @@ "provisioningManage": "Осигуряване", "provisioningDescription": "Управление на ключовете за осигуряване и преглед на чаканещите сайтове за одобрение.", "pendingSites": "Чаканещи сайтове", - "siteApproveSuccess": "Сайтът е одобрен успешно", + "siteApproveSuccess": "Сайтът и свързаните ресурси са одобрени успешно", "siteApproveError": "Грешка при одобряването на сайта", + "siteReject": "Отказ на сайт", + "siteQuestionReject": "Сигурни ли сте, че искате да откажете този сайт?", + "siteMessageReject": "Това ще изтрие окончателно сайта и всички свързани ресурси, които все още са на изчакване.", + "siteConfirmReject": "Потвърдете отказ на сайт", + "siteRejectSuccess": "Сайтът беше успешно отхвърлен", + "siteRejectError": "Грешка при отхвърляне на сайта", "provisioningKeys": "Ключове за осигуряване", "searchProvisioningKeys": "Търсене на ключове за осигуряване...", "provisioningKeysAdd": "Генериране на ключ за осигуряване", @@ -466,8 +472,8 @@ "provisioningKeysSave": "Запазете ключа за осигуряване", "provisioningKeysSaveDescription": "Ще можете да видите това само веднъж. Копирайте го на сигурно място.", "provisioningKeysErrorCreate": "Грешка при създаване на ключ за осигуряване", - "provisioningKeysList": "Нов ключ за осигуряване", - "provisioningKeysMaxBatchSize": "Максимален размер на пакет", + "provisioningKeysList": "Нов ключ за разпределяне", + "provisioningKeysMaxBatchSize": "Максимален размер на пакета", "provisioningKeysUnlimitedBatchSize": "Неограничен размер на партида (без лимит)", "provisioningKeysMaxBatchUnlimited": "Неограничено", "provisioningKeysMaxBatchSizeInvalid": "Въведете валиден максимален размер на партида (1–1,000,000).", @@ -480,7 +486,7 @@ "provisioningKeysNeverUsed": "Никога", "provisioningKeysEdit": "Редактиране на ключ за осигуряване", "provisioningKeysEditDescription": "Актуализирайте максималния размер на партида и времето на изтичане за този ключ.", - "provisioningKeysApproveNewSites": "Одобрете нови сайтове", + "provisioningKeysApproveNewSites": "Одобряване на нови сайтове", "provisioningKeysApproveNewSitesDescription": "Автоматично одобряване на сайтове, които се регистрират с този ключ.", "provisioningKeysUpdateError": "Грешка при актуализирането на ключа за осигуряване", "provisioningKeysUpdated": "Ключът за осигуряване е актуализиран", @@ -1397,6 +1403,7 @@ "createOrgUser": "Създаване на Организационна Потребител", "actionUpdateOrg": "Актуализиране на организацията", "actionRemoveInvitation": "Премахване на поканата.", + "actionRemoveUserRole": "Премахване на роля на потребител", "actionUpdateUser": "Актуализиране на потребител", "actionGetUser": "Получаване на потребител", "actionGetOrgUser": "Вземете потребител на организация", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "Въведете конфигурационния токен от сървърната конзола.", "setupTokenRequired": "Необходим е конфигурационен токен", "actionUpdateSite": "Актуализиране на сайт", + "actionApproveSite": "Одобряване на сайт", + "actionRejectSite": "Отказ на сайт", "actionResetSiteBandwidth": "Нулиране на честотната лента на организацията", "actionListSiteRoles": "Изброяване на позволените роли за сайта", "actionCreateResource": "Създаване на ресурс", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "Въведете кода от приложението за удостоверяване или един от вашите резервни кодове за еднократна употреба.", "otpAuthSubmit": "Изпрати код", "idpContinue": "Или продължете със", + "idpLastUsed": "Последно използвано", "otpAuthBack": "Назад към парола", "navbar": "Навигационно меню", "navbarDescription": "Главно навигационно меню за приложението", From 8d1f5e1096946b11031120440dd99b00c1c63556 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:53:55 -0400 Subject: [PATCH 035/494] New translations en-us.json (Czech) [ci skip] --- messages/cs-CZ.json | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index f456ee3b9..f440a8e38 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -449,8 +449,14 @@ "provisioningManage": "Zajištění", "provisioningDescription": "Spravovat klíče pro nastavení a zkontrolovat čekající stránky čekající na schválení.", "pendingSites": "Nevyřízené weby", - "siteApproveSuccess": "Web byl úspěšně schválen", + "siteApproveSuccess": "Stránka a přidružené zdroje byly úspěšně schváleny", "siteApproveError": "Chyba při schvalování webu", + "siteReject": "Odmítnout stránku", + "siteQuestionReject": "Opravdu chcete tuto stránku odmítnout?", + "siteMessageReject": "Toto trvale odstraní stránku a všechny přidružené zdroje, které jsou stále nevyřízené.", + "siteConfirmReject": "Potvrdit odmítnutí stránky", + "siteRejectSuccess": "Stránka byla úspěšně odmítnuta", + "siteRejectError": "Chyba při odmítání stránky", "provisioningKeys": "Poskytovací klíče", "searchProvisioningKeys": "Hledat klíče k zajišťování...", "provisioningKeysAdd": "Generovat zajišťovací klíč", @@ -466,12 +472,12 @@ "provisioningKeysSave": "Uložit konfigurační klíč", "provisioningKeysSaveDescription": "Toto můžete vidět pouze jednou. Zkopírujte ho na bezpečné místo.", "provisioningKeysErrorCreate": "Chyba při vytváření doplňovacího klíče", - "provisioningKeysList": "Nový klíč pro poskytování informací", + "provisioningKeysList": "Nový provisioning klíč", "provisioningKeysMaxBatchSize": "Maximální velikost dávky", "provisioningKeysUnlimitedBatchSize": "Neomezená velikost šarže (bez omezení)", "provisioningKeysMaxBatchUnlimited": "Bez omezení", "provisioningKeysMaxBatchSizeInvalid": "Zadejte platnou maximální velikost šarže (1–1,000,000).", - "provisioningKeysValidUntil": "Platné do", + "provisioningKeysValidUntil": "Platná do", "provisioningKeysValidUntilHint": "Ponechte prázdné, pokud vyprší platnost.", "provisioningKeysValidUntilInvalid": "Zadejte platné datum a čas.", "provisioningKeysNumUsed": "Časy použití", @@ -1397,6 +1403,7 @@ "createOrgUser": "Vytvořit Org uživatele", "actionUpdateOrg": "Aktualizovat organizaci", "actionRemoveInvitation": "Odstranit pozvání", + "actionRemoveUserRole": "Odstranit uživatelskou roli", "actionUpdateUser": "Aktualizovat uživatele", "actionGetUser": "Získat uživatele", "actionGetOrgUser": "Získat uživatele organizace", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "Zadejte nastavovací token z konzole serveru.", "setupTokenRequired": "Je vyžadován token nastavení", "actionUpdateSite": "Aktualizovat stránku", + "actionApproveSite": "Schválit stránku", + "actionRejectSite": "Odmítnout stránku", "actionResetSiteBandwidth": "Resetovat šířku pásma organizace", "actionListSiteRoles": "Seznam povolených rolí webu", "actionCreateResource": "Vytvořit zdroj", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "Zadejte kód z vaší autentizační aplikace nebo jeden z vlastních záložních kódů.", "otpAuthSubmit": "Odeslat kód", "idpContinue": "Nebo pokračovat s", + "idpLastUsed": "Naposled použito", "otpAuthBack": "Zpět na heslo", "navbar": "Navigation Menu", "navbarDescription": "Hlavní navigační menu aplikace", From 00f7510195109633cb7c8ff86b3a7ac1065743a9 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:53:57 -0400 Subject: [PATCH 036/494] New translations en-us.json (Danish) [ci skip] --- messages/da-DK.json | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/messages/da-DK.json b/messages/da-DK.json index ce08bff23..2e1376264 100644 --- a/messages/da-DK.json +++ b/messages/da-DK.json @@ -449,8 +449,14 @@ "provisioningManage": "Provisionering", "provisioningDescription": "Administrer provisioneringsnøgler og gennemgå ventende sites som venter på godkendelse.", "pendingSites": "Ventende sites", - "siteApproveSuccess": "Med succes godkendelse af site", + "siteApproveSuccess": "Site og tilknyttede ressourcer godkendt med succes", "siteApproveError": "Fejl ved godkendelse af side", + "siteReject": "Afvis Site", + "siteQuestionReject": "Er du sikker på, at du vil afvise dette site?", + "siteMessageReject": "Dette vil permanent slette sitet og enhver tilknyttet ressource, der stadig er i afventning.", + "siteConfirmReject": "Bekræft Afvis Site", + "siteRejectSuccess": "Site afvist med succes", + "siteRejectError": "Fejl ved afvisning af sitet", "provisioningKeys": "Provisioneringsnøgler", "searchProvisioningKeys": "Søg varer i provisioneringsnøgler...", "provisioningKeysAdd": "Generer provisioneringsnøgle", @@ -467,7 +473,7 @@ "provisioningKeysSaveDescription": "Du kan kun se denne én gang. Kopiér det til et sikkert sted.", "provisioningKeysErrorCreate": "Fejl under oprettelse af provisioneringsnøgle", "provisioningKeysList": "Ny provisioneringsnøgle", - "provisioningKeysMaxBatchSize": "Maks størrelse på bunt", + "provisioningKeysMaxBatchSize": "Maksimum Batch Størrelse", "provisioningKeysUnlimitedBatchSize": "Ubegrænset mengde bunt (ingen begrænsning)", "provisioningKeysMaxBatchUnlimited": "Ubegrænset", "provisioningKeysMaxBatchSizeInvalid": "Angiv en gyldig sjakkstørrelse (1–1 000.000).", @@ -1397,6 +1403,7 @@ "createOrgUser": "Opret organisationsbruger", "actionUpdateOrg": "Opdater organisation", "actionRemoveInvitation": "Fjern invitation", + "actionRemoveUserRole": "Fjern Brugers Rolle", "actionUpdateUser": "Opdater bruger", "actionGetUser": "Hent bruger", "actionGetOrgUser": "Hent organisationsbruger", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "Indtast opsætningstoken fra serverkonsollen.", "setupTokenRequired": "Opsætningstoken er nødvendig", "actionUpdateSite": "Opdater site", + "actionApproveSite": "Godkend Site", + "actionRejectSite": "Afvis Site", "actionResetSiteBandwidth": "Nulstil organisationsbåndbredde", "actionListSiteRoles": "Vis tilladte områderoller", "actionCreateResource": "Opret ressource", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "Indtast koden fra autentiseringsappen din eller en af dine engangs reservekoder.", "otpAuthSubmit": "Send kode", "idpContinue": "Eller fortsæt med", + "idpLastUsed": "Sidst brugt", "otpAuthBack": "Tilbage til adgangskode", "navbar": "Navigationsmenu", "navbarDescription": "Hovednavigasjonsmeny for applikationen", From e6646e5833ded21fd6cd5d6b22ad1dfd15c2847a Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:53:59 -0400 Subject: [PATCH 037/494] New translations en-us.json (German) [ci skip] --- messages/de-DE.json | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index dc7dbb808..3ceac6dc4 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -449,8 +449,14 @@ "provisioningManage": "Bereitstellung", "provisioningDescription": "Bereitstellungsschlüssel verwalten und ausstehende Standorte prüfen, die noch auf Genehmigung warten.", "pendingSites": "Ausstehende Standorte", - "siteApproveSuccess": "Standort erfolgreich freigegeben", + "siteApproveSuccess": "Site und zugehörige Ressourcen erfolgreich genehmigt", "siteApproveError": "Fehler beim Genehmigen des Standorts", + "siteReject": "Site ablehnen", + "siteQuestionReject": "Sind Sie sicher, dass Sie diese Site ablehnen möchten?", + "siteMessageReject": "Dies wird die Site und alle damit verbundenen, noch ausstehenden Ressourcen dauerhaft löschen.", + "siteConfirmReject": "Ablehnung der Site bestätigen", + "siteRejectSuccess": "Site erfolgreich abgelehnt", + "siteRejectError": "Fehler beim Ablehnen der Site", "provisioningKeys": "Bereitstellungsschlüssel", "searchProvisioningKeys": "Bereitstellungsschlüssel suchen...", "provisioningKeysAdd": "Bereitstellungsschlüssel generieren", @@ -467,7 +473,7 @@ "provisioningKeysSaveDescription": "Sie können dies nur einmal sehen. Kopieren Sie es an einen sicheren Ort.", "provisioningKeysErrorCreate": "Fehler beim Erstellen des Bereitstellungsschlüssels", "provisioningKeysList": "Neuer Bereitstellungsschlüssel", - "provisioningKeysMaxBatchSize": "Max. Batch-Größe", + "provisioningKeysMaxBatchSize": "Maximale Batch-Größe", "provisioningKeysUnlimitedBatchSize": "Unbegrenzte Batch-Größe (kein Limit)", "provisioningKeysMaxBatchUnlimited": "Unbegrenzt", "provisioningKeysMaxBatchSizeInvalid": "Geben Sie eine gültige maximale Batchgröße ein (1–1.000.000).", @@ -480,7 +486,7 @@ "provisioningKeysNeverUsed": "Nie", "provisioningKeysEdit": "Bereitstellungsschlüssel bearbeiten", "provisioningKeysEditDescription": "Aktualisieren Sie die maximale Batch-Größe und Ablaufzeit für diesen Schlüssel.", - "provisioningKeysApproveNewSites": "Neuen Standort genehmigen", + "provisioningKeysApproveNewSites": "Neue Sites genehmigen", "provisioningKeysApproveNewSitesDescription": "Sites, die sich mit diesem Schlüssel registrieren, automatisch freigeben.", "provisioningKeysUpdateError": "Fehler beim Aktualisieren des Bereitstellungsschlüssels", "provisioningKeysUpdated": "Bereitstellungsschlüssel aktualisiert", @@ -1397,6 +1403,7 @@ "createOrgUser": "Org Benutzer erstellen", "actionUpdateOrg": "Organisation aktualisieren", "actionRemoveInvitation": "Einladung entfernen", + "actionRemoveUserRole": "Benutzerrolle entfernen", "actionUpdateUser": "Benutzer aktualisieren", "actionGetUser": "Benutzer abrufen", "actionGetOrgUser": "Organisationsbenutzer abrufen", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.", "setupTokenRequired": "Setup-Token ist erforderlich", "actionUpdateSite": "Standorte aktualisieren", + "actionApproveSite": "Site genehmigen", + "actionRejectSite": "Site ablehnen", "actionResetSiteBandwidth": "Organisations-Bandbreite zurücksetzen", "actionListSiteRoles": "Erlaubte Standort-Rollen auflisten", "actionCreateResource": "Ressource erstellen", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "Geben Sie den Code aus Ihrer Authenticator-App oder einen Ihrer einmaligen Backup-Codes ein.", "otpAuthSubmit": "Code absenden", "idpContinue": "Oder weiter mit", + "idpLastUsed": "Zuletzt verwendet", "otpAuthBack": "Zurück zum Passwort", "navbar": "Navigationsmenü", "navbarDescription": "Hauptnavigationsmenü für die Anwendung", From cebbf9ca65c27f7a470c945a2fa47d27816dcab4 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:54:00 -0400 Subject: [PATCH 038/494] New translations en-us.json (Italian) [ci skip] --- messages/it-IT.json | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/messages/it-IT.json b/messages/it-IT.json index ebb398334..5edde2e2e 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -449,8 +449,14 @@ "provisioningManage": "Accantonamento", "provisioningDescription": "Gestire le chiavi di provisioning e rivedere i siti in attesa di approvazione.", "pendingSites": "Siti In Attesa", - "siteApproveSuccess": "Sito approvato con successo", + "siteApproveSuccess": "Sito e risorse associate approvate con successo", "siteApproveError": "Errore nell'approvazione del sito", + "siteReject": "Rifiuta Sito", + "siteQuestionReject": "Sei sicuro di voler rifiutare questo sito?", + "siteMessageReject": "Questo eliminerà permanentemente il sito e tutte le risorse associate ancora in sospeso.", + "siteConfirmReject": "Conferma Rifiuto Sito", + "siteRejectSuccess": "Sito rifiutato con successo", + "siteRejectError": "Errore nel rifiutare il sito", "provisioningKeys": "Chiavi Di Provvedimento", "searchProvisioningKeys": "Cerca le chiavi di provisioning...", "provisioningKeysAdd": "Genera Chiave di provisioning", @@ -466,12 +472,12 @@ "provisioningKeysSave": "Salva la chiave di provisioning", "provisioningKeysSaveDescription": "Sarai in grado di vedere solo una volta. Copiarlo in un posto sicuro.", "provisioningKeysErrorCreate": "Errore nella creazione della chiave di provisioning", - "provisioningKeysList": "Nuova chiave di provisioning", - "provisioningKeysMaxBatchSize": "Dimensione massima batch", + "provisioningKeysList": "Nuova Chiave di Provisioning", + "provisioningKeysMaxBatchSize": "Dimensione Massima Batch", "provisioningKeysUnlimitedBatchSize": "Dimensione illimitata del batch (nessun limite)", "provisioningKeysMaxBatchUnlimited": "Illimitato", "provisioningKeysMaxBatchSizeInvalid": "Inserisci una dimensione massima valida del batch (1–1.000.000).", - "provisioningKeysValidUntil": "Valido fino al", + "provisioningKeysValidUntil": "Valido Fino a", "provisioningKeysValidUntilHint": "Lasciare vuoto per nessuna scadenza.", "provisioningKeysValidUntilInvalid": "Inserisci una data e ora valide.", "provisioningKeysNumUsed": "Volte usate", @@ -480,7 +486,7 @@ "provisioningKeysNeverUsed": "Mai", "provisioningKeysEdit": "Modifica Chiave di provisioning", "provisioningKeysEditDescription": "Aggiorna la dimensione massima del batch e il tempo di scadenza per questa chiave.", - "provisioningKeysApproveNewSites": "Approva nuovi siti", + "provisioningKeysApproveNewSites": "Approva Nuovi Siti", "provisioningKeysApproveNewSitesDescription": "Approvare automaticamente i siti che si registrano con questa chiave.", "provisioningKeysUpdateError": "Errore nell'aggiornamento della chiave di provisioning", "provisioningKeysUpdated": "Chiave di provisioning aggiornata", @@ -1397,6 +1403,7 @@ "createOrgUser": "Crea Utente Org", "actionUpdateOrg": "Aggiorna Organizzazione", "actionRemoveInvitation": "Rimuovi Invito", + "actionRemoveUserRole": "Rimuovi Ruolo Utente", "actionUpdateUser": "Aggiorna Utente", "actionGetUser": "Ottieni Utente", "actionGetOrgUser": "Ottieni Utente Organizzazione", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "Inserisci il token di configurazione dalla console del server.", "setupTokenRequired": "Il token di configurazione è richiesto", "actionUpdateSite": "Aggiorna Sito", + "actionApproveSite": "Approva Sito", + "actionRejectSite": "Rifiuta Sito", "actionResetSiteBandwidth": "Reimposta Larghezza Banda Dell'Organizzazione", "actionListSiteRoles": "Elenca Ruoli Sito Consentiti", "actionCreateResource": "Crea Risorsa", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "Inserisci il codice dalla tua app di autenticazione o uno dei tuoi codici di backup monouso.", "otpAuthSubmit": "Invia Codice", "idpContinue": "O continua con", + "idpLastUsed": "Ultimo Utilizzo", "otpAuthBack": "Torna alla Password", "navbar": "Menu di Navigazione", "navbarDescription": "Menu di navigazione principale dell'applicazione", From 6c1e1bc0ca576fafc311c4cde782d1c7344cec46 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:54:02 -0400 Subject: [PATCH 039/494] New translations en-us.json (Korean) [ci skip] --- messages/ko-KR.json | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index d8c929ead..67c0ee59f 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -449,8 +449,14 @@ "provisioningManage": "프로비저닝", "provisioningDescription": "프로비저닝 키를 관리하고 승인을 기다리는 사이트를 검토합니다.", "pendingSites": "대기중인 사이트", - "siteApproveSuccess": "사이트가 성공적으로 승인되었습니다", + "siteApproveSuccess": "사이트 및 관련 리소스가 성공적으로 승인되었습니다", "siteApproveError": "사이트 승인 오류", + "siteReject": "사이트 거부", + "siteQuestionReject": "이 사이트를 거부하시겠습니까?", + "siteMessageReject": "이렇게 하면 펜딩 중인 사이트 및 관련 리소스가 영구적으로 삭제됩니다.", + "siteConfirmReject": "사이트 거부 확인", + "siteRejectSuccess": "사이트가 성공적으로 거부되었습니다", + "siteRejectError": "사이트 거부 오류", "provisioningKeys": "프로비저닝 키", "searchProvisioningKeys": "프로비저닝 키 검색...", "provisioningKeysAdd": "프로비저닝 키 생성", @@ -480,7 +486,7 @@ "provisioningKeysNeverUsed": "절대", "provisioningKeysEdit": "프로비저닝 키 수정", "provisioningKeysEditDescription": "이 키의 최대 배치 크기 및 만료 시간을 업데이트하세요.", - "provisioningKeysApproveNewSites": "새로운 사이트 승인", + "provisioningKeysApproveNewSites": "새 사이트 승인", "provisioningKeysApproveNewSitesDescription": "이 키를 등록하는 사이트를 자동으로 승인합니다.", "provisioningKeysUpdateError": "프로비저닝 키 업데이트 오류", "provisioningKeysUpdated": "프로비저닝 키가 업데이트되었습니다", @@ -1397,6 +1403,7 @@ "createOrgUser": "조직 사용자 생성", "actionUpdateOrg": "조직 업데이트", "actionRemoveInvitation": "초대 제거", + "actionRemoveUserRole": "사용자 역할 제거", "actionUpdateUser": "사용자 업데이트", "actionGetUser": "사용자 조회", "actionGetOrgUser": "조직 사용자 가져오기", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "서버 콘솔에서 설정 토큰 입력.", "setupTokenRequired": "설정 토큰이 필요합니다", "actionUpdateSite": "사이트 업데이트", + "actionApproveSite": "사이트 승인", + "actionRejectSite": "사이트 거부", "actionResetSiteBandwidth": "조직 대역폭 재설정", "actionListSiteRoles": "허용된 사이트 역할 목록", "actionCreateResource": "리소스 생성", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "인증 앱에서 코드를 입력하거나 단일 사용 백업 코드 중 하나를 입력하세요.", "otpAuthSubmit": "코드 제출", "idpContinue": "또는 계속 진행하십시오.", + "idpLastUsed": "마지막 사용", "otpAuthBack": "비밀번호로 돌아가기", "navbar": "탐색 메뉴", "navbarDescription": "애플리케이션의 주요 탐색 메뉴", From 40e05f903a8ace9d1de4e80c3ac78a1043518305 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:54:04 -0400 Subject: [PATCH 040/494] New translations en-us.json (Dutch) [ci skip] --- messages/nl-NL.json | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 506a4a5b3..9239cd81c 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -449,8 +449,14 @@ "provisioningManage": "Provisie", "provisioningDescription": "Voorzieningssleutels beheren en sites beoordelen in afwachting van goedkeuring.", "pendingSites": "Openstaande sites", - "siteApproveSuccess": "Site succesvol goedgekeurd", + "siteApproveSuccess": "Site en bijbehorende middelen succesvol goedgekeurd", "siteApproveError": "Fout bij goedkeuren website", + "siteReject": "Afwijzen Site", + "siteQuestionReject": "Weet u zeker dat u deze site wilt afwijzen?", + "siteMessageReject": "Hiermee wordt de site en alle bijbehorende middelen die nog in behandeling zijn permanent verwijderd.", + "siteConfirmReject": "Afwijzing van site bevestigen", + "siteRejectSuccess": "Site succesvol afgewezen", + "siteRejectError": "Fout bij het afwijzen van site", "provisioningKeys": "Verhelderende sleutels", "searchProvisioningKeys": "Zoek provisioningsleutels ...", "provisioningKeysAdd": "Genereer Provisioning Sleutel", @@ -466,7 +472,7 @@ "provisioningKeysSave": "Sla de provisioning sleutel op", "provisioningKeysSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een veilige plaats.", "provisioningKeysErrorCreate": "Fout bij aanmaken provisioning sleutel", - "provisioningKeysList": "Nieuwe provisioning sleutel", + "provisioningKeysList": "Nieuwe provisioning-sleutel", "provisioningKeysMaxBatchSize": "Maximale batchgrootte", "provisioningKeysUnlimitedBatchSize": "Onbeperkte batchgrootte (geen limiet)", "provisioningKeysMaxBatchUnlimited": "Onbeperkt", @@ -480,7 +486,7 @@ "provisioningKeysNeverUsed": "Nooit", "provisioningKeysEdit": "Wijzig Provisioning Sleutel", "provisioningKeysEditDescription": "Werk de maximale batchgrootte en verlooptijd voor deze sleutel bij.", - "provisioningKeysApproveNewSites": "Goedkeuren van nieuwe sites", + "provisioningKeysApproveNewSites": "Nieuwe sites goedkeuren", "provisioningKeysApproveNewSitesDescription": "Automatisch sites goedkeuren die zich registreren met deze sleutel.", "provisioningKeysUpdateError": "Fout tijdens bijwerken provisioning sleutel", "provisioningKeysUpdated": "Provisie sleutel bijgewerkt", @@ -1397,6 +1403,7 @@ "createOrgUser": "Org gebruiker aanmaken", "actionUpdateOrg": "Organisatie bijwerken", "actionRemoveInvitation": "Verwijder uitnodiging", + "actionRemoveUserRole": "Gebruikersrol verwijderen", "actionUpdateUser": "Gebruiker bijwerken", "actionGetUser": "Gebruiker ophalen", "actionGetOrgUser": "Krijg organisatie-gebruiker", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.", "setupTokenRequired": "Setup-token is vereist", "actionUpdateSite": "Site bijwerken", + "actionApproveSite": "Verleen site goedkeuring", + "actionRejectSite": "Afwijzen Site", "actionResetSiteBandwidth": "Reset organisatieschandbreedte", "actionListSiteRoles": "Toon toegestane sitenollen", "actionCreateResource": "Bron maken", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "Voer de code van je authenticator-app of een van je reservekopiecodes voor het eenmalig gebruik in.", "otpAuthSubmit": "Code indienen", "idpContinue": "Of ga verder met", + "idpLastUsed": "Laatst gebruikt", "otpAuthBack": "Terug naar wachtwoord", "navbar": "Navigatiemenu", "navbarDescription": "Hoofd navigatie menu voor de applicatie", From 22a5743817effe1d82370fbf9a74ce28f9193de6 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:54:06 -0400 Subject: [PATCH 041/494] New translations en-us.json (Polish) [ci skip] --- messages/pl-PL.json | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 718579932..11df1b353 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -449,8 +449,14 @@ "provisioningManage": "Dostarczanie", "provisioningDescription": "Zarządzaj kluczami rezerwacji i sprawdzaj oczekujące strony oczekujące na zatwierdzenie.", "pendingSites": "Witryny oczekujące", - "siteApproveSuccess": "Witryna została pomyślnie zatwierdzona", + "siteApproveSuccess": "Witryna i powiązane zasoby zostały zatwierdzone pomyślnie", "siteApproveError": "Błąd zatwierdzania witryny", + "siteReject": "Odrzuć witrynę", + "siteQuestionReject": "Czy na pewno chcesz odrzucić tę witrynę?", + "siteMessageReject": "Spowoduje to trwałe usunięcie witryny i wszelkich powiązanych, nadal oczekujących zasobów.", + "siteConfirmReject": "Potwierdź odrzucenie witryny", + "siteRejectSuccess": "Witryna została odrzucona pomyślnie", + "siteRejectError": "Błąd podczas odrzucania witryny", "provisioningKeys": "Klucze Zaopatrzenia", "searchProvisioningKeys": "Szukaj kluczy zaopatrzenia...", "provisioningKeysAdd": "Wygeneruj klucz zaopatrzenia", @@ -466,7 +472,7 @@ "provisioningKeysSave": "Zapisz klucz zaopatrzenia", "provisioningKeysSaveDescription": "Możesz to zobaczyć tylko raz. Skopiuj je do bezpiecznego miejsca.", "provisioningKeysErrorCreate": "Błąd podczas tworzenia klucza zaopatrzenia", - "provisioningKeysList": "Nowy klucz rezerwacji", + "provisioningKeysList": "Nowy klucz aprowizacyjny", "provisioningKeysMaxBatchSize": "Maksymalny rozmiar partii", "provisioningKeysUnlimitedBatchSize": "Nieograniczony rozmiar partii (bez limitu)", "provisioningKeysMaxBatchUnlimited": "Nieograniczona", @@ -1397,6 +1403,7 @@ "createOrgUser": "Utwórz użytkownika Org", "actionUpdateOrg": "Aktualizuj organizację", "actionRemoveInvitation": "Usuń zaproszenie", + "actionRemoveUserRole": "Usuń rolę użytkownika", "actionUpdateUser": "Zaktualizuj użytkownika", "actionGetUser": "Pobierz użytkownika", "actionGetOrgUser": "Pobierz użytkownika organizacji", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "Wprowadź token konfiguracji z konsoli serwera.", "setupTokenRequired": "Wymagany jest token konfiguracji", "actionUpdateSite": "Aktualizuj witrynę", + "actionApproveSite": "Zatwierdź witrynę", + "actionRejectSite": "Odrzuć witrynę", "actionResetSiteBandwidth": "Zresetuj przepustowość organizacji", "actionListSiteRoles": "Lista dozwolonych ról witryny", "actionCreateResource": "Utwórz zasób", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "Wprowadź kod z aplikacji uwierzytelniającej lub jeden z jednorazowych kodów zapasowych.", "otpAuthSubmit": "Wyślij kod", "idpContinue": "Lub kontynuuj z", + "idpLastUsed": "Ostatnio używany", "otpAuthBack": "Powrót do hasła", "navbar": "Menu nawigacyjne", "navbarDescription": "Główne menu nawigacyjne aplikacji", From 4e5fd2f2b069a1f848b189a65cb8571dee07e0de Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:54:08 -0400 Subject: [PATCH 042/494] New translations en-us.json (French) [ci skip] --- messages/fr-FR.json | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 4084ceae8..ec24ec4d1 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -449,8 +449,14 @@ "provisioningManage": "Mise en place", "provisioningDescription": "Gérer les clés de provisioning et examiner les sites en attente d'approbation.", "pendingSites": "Sites en attente", - "siteApproveSuccess": "Site approuvé avec succès", + "siteApproveSuccess": "Site et ressources associées approuvés avec succès", "siteApproveError": "Erreur lors de l'approbation du site", + "siteReject": "Rejeter le site", + "siteQuestionReject": "Êtes-vous sûr de vouloir rejeter ce site ?", + "siteMessageReject": "Cela supprimera définitivement le site et toutes les ressources associées qui sont encore en attente.", + "siteConfirmReject": "Confirmer le rejet du site", + "siteRejectSuccess": "Site rejeté avec succès", + "siteRejectError": "Erreur lors du rejet du site", "provisioningKeys": "Clés de provisionnement", "searchProvisioningKeys": "Recherche des clés de provision...", "provisioningKeysAdd": "Générer une clé de provisioning", @@ -466,12 +472,12 @@ "provisioningKeysSave": "Enregistrer la clé de provisioning", "provisioningKeysSaveDescription": "Vous ne pourrez voir cela qu'une seule fois. Copiez-le dans un endroit sécurisé.", "provisioningKeysErrorCreate": "Erreur lors de la création de la clé de provisioning", - "provisioningKeysList": "Nouvelle clé de provisioning", + "provisioningKeysList": "Nouvelle clé d'approvisionnement", "provisioningKeysMaxBatchSize": "Taille maximale du lot", "provisioningKeysUnlimitedBatchSize": "Taille de lot illimitée (sans limite)", "provisioningKeysMaxBatchUnlimited": "Illimité", "provisioningKeysMaxBatchSizeInvalid": "Entrez une taille de lot maximale valide (1–1 000 000).", - "provisioningKeysValidUntil": "Valable jusqu'au", + "provisioningKeysValidUntil": "Valable jusqu'à", "provisioningKeysValidUntilHint": "Laisser vide pour ne pas expirer.", "provisioningKeysValidUntilInvalid": "Entrez une date et une heure valides.", "provisioningKeysNumUsed": "Nombre de fois utilisées", @@ -1397,6 +1403,7 @@ "createOrgUser": "Créer un utilisateur Org", "actionUpdateOrg": "Mettre à jour l'organisation", "actionRemoveInvitation": "Supprimer l'invitation", + "actionRemoveUserRole": "Supprimer le rôle d'utilisateur", "actionUpdateUser": "Mettre à jour l'utilisateur", "actionGetUser": "Obtenir l'utilisateur", "actionGetOrgUser": "Obtenir l'utilisateur de l'organisation", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "Entrez le jeton de configuration depuis la console du serveur.", "setupTokenRequired": "Le jeton de configuration est requis.", "actionUpdateSite": "Mettre à jour un site", + "actionApproveSite": "Approuver le site", + "actionRejectSite": "Rejeter le site", "actionResetSiteBandwidth": "Réinitialiser la bande passante de l'organisation", "actionListSiteRoles": "Lister les rôles autorisés du site", "actionCreateResource": "Créer une ressource", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "Entrez le code de votre application d'authentification ou l'un de vos codes de secours à usage unique.", "otpAuthSubmit": "Soumettre le code", "idpContinue": "Ou continuer avec", + "idpLastUsed": "Dernière utilisation", "otpAuthBack": "Retour au mot de passe", "navbar": "Menu de navigation", "navbarDescription": "Menu de navigation principal de l'application", From 89f4f4ba6f524b95d04649967419477829f12454 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:54:10 -0400 Subject: [PATCH 043/494] New translations en-us.json (Portuguese) [ci skip] --- messages/pt-PT.json | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index c0d65e0fb..4a37fb52b 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -449,8 +449,14 @@ "provisioningManage": "Provisionamento", "provisioningDescription": "Gerenciar chaves de provisionamento e revisar sites pendentes aguardando aprovação.", "pendingSites": "Sites pendentes", - "siteApproveSuccess": "Site aprovado com sucesso", + "siteApproveSuccess": "Site e recursos associados aprovados com sucesso", "siteApproveError": "Erro ao aprovar site", + "siteReject": "Rejeitar Site", + "siteQuestionReject": "Tem certeza de que deseja rejeitar este site?", + "siteMessageReject": "Isto eliminará permanentemente o site e quaisquer recursos associados que ainda estejam pendentes.", + "siteConfirmReject": "Confirmar Rejeição de Site", + "siteRejectSuccess": "Site rejeitado com sucesso", + "siteRejectError": "Erro ao rejeitar site", "provisioningKeys": "Posicionando chaves", "searchProvisioningKeys": "Pesquisar chaves de provisionamento...", "provisioningKeysAdd": "Gerar chave de provisionamento", @@ -466,12 +472,12 @@ "provisioningKeysSave": "Salvar a chave de provisionamento", "provisioningKeysSaveDescription": "Você só será capaz de ver esta vez. Copiá-lo para um lugar seguro.", "provisioningKeysErrorCreate": "Erro ao criar chave de provisionamento", - "provisioningKeysList": "Nova chave de aprovisionamento", - "provisioningKeysMaxBatchSize": "Tamanho máximo do lote", + "provisioningKeysList": "Nova Chave de Provisionamento", + "provisioningKeysMaxBatchSize": "Tamanho Máximo do Lote", "provisioningKeysUnlimitedBatchSize": "Tamanho ilimitado em lote (sem limite)", "provisioningKeysMaxBatchUnlimited": "Ilimitado", "provisioningKeysMaxBatchSizeInvalid": "Informe um tamanho máximo válido em lote (1–1,000,000).", - "provisioningKeysValidUntil": "Valido ate", + "provisioningKeysValidUntil": "Válido Até", "provisioningKeysValidUntilHint": "Deixe em branco para nenhuma expiração.", "provisioningKeysValidUntilInvalid": "Informe uma data e hora válidas.", "provisioningKeysNumUsed": "Use percentual", @@ -480,7 +486,7 @@ "provisioningKeysNeverUsed": "nunca", "provisioningKeysEdit": "Editar chave de provisionamento", "provisioningKeysEditDescription": "Atualizar o tamanho máximo do lote e tempo de expiração para esta chave.", - "provisioningKeysApproveNewSites": "Aprovar novos sites", + "provisioningKeysApproveNewSites": "Aprovar Novos Sites", "provisioningKeysApproveNewSitesDescription": "Aprovar automaticamente sites que se registram com esta chave.", "provisioningKeysUpdateError": "Erro ao atualizar chave de provisionamento", "provisioningKeysUpdated": "Chave de provisionamento atualizada", @@ -1397,6 +1403,7 @@ "createOrgUser": "Criar utilizador Org", "actionUpdateOrg": "Atualizar Organização", "actionRemoveInvitation": "Remover Convite", + "actionRemoveUserRole": "Remover Função de Utilizador", "actionUpdateUser": "Atualizar Usuário", "actionGetUser": "Obter Usuário", "actionGetOrgUser": "Obter Utilizador da Organização", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "Digite o token de configuração do console do servidor.", "setupTokenRequired": "Token de configuração é necessário", "actionUpdateSite": "Atualizar Site", + "actionApproveSite": "Aprovar Site", + "actionRejectSite": "Rejeitar Site", "actionResetSiteBandwidth": "Redefinir banda da organização", "actionListSiteRoles": "Listar Funções Permitidas do Site", "actionCreateResource": "Criar Recurso", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "Insira o código da sua aplicação de autenticação ou um dos seus códigos de backup de uso único.", "otpAuthSubmit": "Submeter Código", "idpContinue": "Ou continuar com", + "idpLastUsed": "Última Utilização", "otpAuthBack": "Voltar à Palavra-passe", "navbar": "Menu de Navegação", "navbarDescription": "Menu de navegação principal da aplicação", From 5ee406c92ebe0266323879e865e20447193f4526 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:54:12 -0400 Subject: [PATCH 044/494] New translations en-us.json (Russian) [ci skip] --- messages/ru-RU.json | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 53bf7b015..efd5bee64 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -449,8 +449,14 @@ "provisioningManage": "Подготовка", "provisioningDescription": "Управляйте предоставленными ключами и проверять непроверенные сайты, ожидающие утверждения.", "pendingSites": "Ожидающие сайты", - "siteApproveSuccess": "Сайт успешно утвержден", + "siteApproveSuccess": "Сайт и связанные ресурсы успешно одобрены", "siteApproveError": "Ошибка при утверждении сайта", + "siteReject": "Отклонить сайт", + "siteQuestionReject": "Вы уверены, что хотите отклонить этот сайт?", + "siteMessageReject": "Этот процесс окончательно удалит сайт и все связанные с ним ресурсы, которые еще ожидают.", + "siteConfirmReject": "Подтвердите отклонение сайта", + "siteRejectSuccess": "Сайт успешно отклонен", + "siteRejectError": "Ошибка при отклонении сайта", "provisioningKeys": "Ключи подготовки", "searchProvisioningKeys": "Поиск подготовительных ключей...", "provisioningKeysAdd": "Сгенерировать ключ подготовки", @@ -466,8 +472,8 @@ "provisioningKeysSave": "Сохранить ключ подготовки", "provisioningKeysSaveDescription": "Вы сможете увидеть это только один раз. Скопируйте его в безопасное место.", "provisioningKeysErrorCreate": "Ошибка при создании ключа подготовки", - "provisioningKeysList": "Новый подготовительный ключ", - "provisioningKeysMaxBatchSize": "Макс. размер партии", + "provisioningKeysList": "Новый ключ для подготовки", + "provisioningKeysMaxBatchSize": "Максимальный размер партии", "provisioningKeysUnlimitedBatchSize": "Неограниченный размер партии (без ограничений)", "provisioningKeysMaxBatchUnlimited": "Неограниченный", "provisioningKeysMaxBatchSizeInvalid": "Введите максимальный размер пакета (1–1,000,000).", @@ -1397,6 +1403,7 @@ "createOrgUser": "Создать пользователя Org", "actionUpdateOrg": "Обновить организацию", "actionRemoveInvitation": "Удалить приглашение", + "actionRemoveUserRole": "Удалить роль пользователя", "actionUpdateUser": "Обновить пользователя", "actionGetUser": "Получить пользователя", "actionGetOrgUser": "Получить пользователя организации", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "Введите токен настройки из консоли сервера.", "setupTokenRequired": "Токен настройки обязателен", "actionUpdateSite": "Обновить сайт", + "actionApproveSite": "Одобрить сайт", + "actionRejectSite": "Отклонить сайт", "actionResetSiteBandwidth": "Сброс пропускной способности организации", "actionListSiteRoles": "Список разрешенных ролей сайта", "actionCreateResource": "Создать ресурс", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "Введите код из вашего приложения-аутентификатора или один из ваших одноразовых резервных кодов.", "otpAuthSubmit": "Отправить код", "idpContinue": "Или продолжить с", + "idpLastUsed": "Последнее использование", "otpAuthBack": "Назад к паролю", "navbar": "Навигационное меню", "navbarDescription": "Главное навигационное меню приложения", From e8ed8fc7e9c69bbb8ac1f74a24e3d2de3b7d074c Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:54:13 -0400 Subject: [PATCH 045/494] New translations en-us.json (Turkish) [ci skip] --- messages/tr-TR.json | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 6ab5d026d..006180cc0 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -449,8 +449,14 @@ "provisioningManage": "Tedarik", "provisioningDescription": "Tedarik anahtarlarını yönetin ve onay bekleyen siteleri gözden geçirin.", "pendingSites": "Bekleyen Siteler", - "siteApproveSuccess": "Site başarıyla onaylandı", + "siteApproveSuccess": "Site ve ilgili kaynaklar başarıyla onaylandı", "siteApproveError": "Site onaylanırken hata oluştu", + "siteReject": "Siteyi Reddet", + "siteQuestionReject": "Bu siteyi reddetmek istediğinizden emin misiniz?", + "siteMessageReject": "Bu işlem, siteyi ve hala beklemede olan herhangi bir ilgili kaynağı kalıcı olarak silecektir.", + "siteConfirmReject": "Site Reddetmeyi Onayla", + "siteRejectSuccess": "Site başarıyla reddedildi", + "siteRejectError": "Site reddedilirken hata oluştu", "provisioningKeys": "Tedarik Anahtarları", "searchProvisioningKeys": "Tedarik anahtarlarını ara...", "provisioningKeysAdd": "Tedarik Anahtarı Üret", @@ -466,12 +472,12 @@ "provisioningKeysSave": "Tedarik anahtarını kaydet", "provisioningKeysSaveDescription": "Bunu yalnızca bir kez görebileceksiniz. Güvenli bir yere kopyalayın.", "provisioningKeysErrorCreate": "Tedarik anahtarı oluşturulurken hata oluştu", - "provisioningKeysList": "Yeni tedarik anahtarı", - "provisioningKeysMaxBatchSize": "Maksimum toplu iş boyutu", + "provisioningKeysList": "Yeni Sağlama Anahtarı", + "provisioningKeysMaxBatchSize": "Maksimum Toplu İş Boyutu", "provisioningKeysUnlimitedBatchSize": "Sınırsız toplu iş boyutu (sınırlama yok)", "provisioningKeysMaxBatchUnlimited": "Sınırsız", "provisioningKeysMaxBatchSizeInvalid": "Geçerli bir maksimum toplu iş boyutu girin (1–1,000,000).", - "provisioningKeysValidUntil": "Geçerlilik tarihi", + "provisioningKeysValidUntil": "Geçerli Olma Süresi", "provisioningKeysValidUntilHint": "Son kullanım tarihi için boş bırakın.", "provisioningKeysValidUntilInvalid": "Geçerli bir tarih ve saat girin.", "provisioningKeysNumUsed": "Kullanım Sayısı", @@ -480,7 +486,7 @@ "provisioningKeysNeverUsed": "Asla", "provisioningKeysEdit": "Tedarik Anahtarını Düzenle", "provisioningKeysEditDescription": "Bu anahtar için maksimum toplu iş boyutunu ve son kullanma zamanını güncelleyin.", - "provisioningKeysApproveNewSites": "Yeni siteleri onayla", + "provisioningKeysApproveNewSites": "Yeni Siteleri Onayla", "provisioningKeysApproveNewSitesDescription": "Bu anahtar ile kayıt olan siteleri otomatik olarak onayla.", "provisioningKeysUpdateError": "Tedarik anahtarı güncellenirken hata oluştu", "provisioningKeysUpdated": "Tedarik anahtarı güncellendi", @@ -1397,6 +1403,7 @@ "createOrgUser": "Organizasyon Kullanıcısı Oluştur", "actionUpdateOrg": "Kuruluşu Güncelle", "actionRemoveInvitation": "Daveti Kaldır", + "actionRemoveUserRole": "Kullanıcı Rolünü Kaldır", "actionUpdateUser": "Kullanıcıyı Güncelle", "actionGetUser": "Kullanıcıyı Getir", "actionGetOrgUser": "Kuruluş Kullanıcısını Al", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "Sunucu konsolundan kurulum simgesini girin.", "setupTokenRequired": "Kurulum simgesi gerekli", "actionUpdateSite": "Siteyi Güncelle", + "actionApproveSite": "Siteyi Onayla", + "actionRejectSite": "Siteyi Reddet", "actionResetSiteBandwidth": "Organizasyon Bant Genişliğini Sıfırla", "actionListSiteRoles": "İzin Verilen Site Rolleri Listele", "actionCreateResource": "Kaynak Oluştur", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "Authenticator uygulamanızdan veya tek kullanımlık yedek kodlarınızdan birini girin.", "otpAuthSubmit": "Kodu Gönder", "idpContinue": "Veya devam et:", + "idpLastUsed": "Son Kullanılan", "otpAuthBack": "Şifreye Geri Dön", "navbar": "Navigasyon Menüsü", "navbarDescription": "Uygulamanın ana navigasyon menüsü", From d7d31b78f033e030b5a060cbc02877864bebd2fa Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:54:15 -0400 Subject: [PATCH 046/494] New translations en-us.json (Chinese Simplified) [ci skip] --- messages/zh-CN.json | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 3d34fef2e..e5a680d99 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -449,8 +449,14 @@ "provisioningManage": "预配", "provisioningDescription": "管理预配密钥,并审核待批准的站点。", "pendingSites": "待审批站点", - "siteApproveSuccess": "站点批准成功", + "siteApproveSuccess": "站点及相关资源已成功审批", "siteApproveError": "批准站点出错", + "siteReject": "拒绝站点", + "siteQuestionReject": "您确定要拒绝此站点吗?", + "siteMessageReject": "这将永久删除站点及所有尚未处理的相关资源。", + "siteConfirmReject": "确认拒绝站点", + "siteRejectSuccess": "站点已被成功拒绝", + "siteRejectError": "拒绝站点时出错", "provisioningKeys": "置备键", "searchProvisioningKeys": "搜索配备密钥...", "provisioningKeysAdd": "生成预配密钥", @@ -466,8 +472,8 @@ "provisioningKeysSave": "保存预配键", "provisioningKeysSaveDescription": "您只能看到一次。复制它到一个安全的地方。", "provisioningKeysErrorCreate": "创建预配键时出错", - "provisioningKeysList": "新建预配键", - "provisioningKeysMaxBatchSize": "最大批量大小", + "provisioningKeysList": "新预配密钥", + "provisioningKeysMaxBatchSize": "最大批次大小", "provisioningKeysUnlimitedBatchSize": "无限批量大小(无限制)", "provisioningKeysMaxBatchUnlimited": "无限制", "provisioningKeysMaxBatchSizeInvalid": "输入一个有效的最大批处理大小(1-1,000,000)。", @@ -480,7 +486,7 @@ "provisioningKeysNeverUsed": "永不过期", "provisioningKeysEdit": "编辑置备键", "provisioningKeysEditDescription": "更新此密钥的最大批量大小和过期时间。", - "provisioningKeysApproveNewSites": "批准新节点", + "provisioningKeysApproveNewSites": "批准新站点", "provisioningKeysApproveNewSitesDescription": "自动批准使用此密钥注册的节点。", "provisioningKeysUpdateError": "更新预配键时出错", "provisioningKeysUpdated": "置备密钥已更新", @@ -1397,6 +1403,7 @@ "createOrgUser": "创建组织用户", "actionUpdateOrg": "更新组织", "actionRemoveInvitation": "移除邀请", + "actionRemoveUserRole": "移除用户角色", "actionUpdateUser": "更新用户", "actionGetUser": "获取用户", "actionGetOrgUser": "获取组织用户", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "从服务器控制台输入设置令牌。", "setupTokenRequired": "需要设置令牌", "actionUpdateSite": "更新站点", + "actionApproveSite": "批准站点", + "actionRejectSite": "拒绝站点", "actionResetSiteBandwidth": "重置组织带宽", "actionListSiteRoles": "允许站点角色列表", "actionCreateResource": "创建资源", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "从您的身份验证程序中输入代码或您的单次备份代码。", "otpAuthSubmit": "提交代码", "idpContinue": "或者继续", + "idpLastUsed": "上次使用", "otpAuthBack": "回到密码", "navbar": "导航菜单", "navbarDescription": "应用程序的主导航菜单", From 4d9e38d022f7b33cb009bd3a31de35ecc7e31d3a Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jul 2026 20:54:17 -0400 Subject: [PATCH 047/494] New translations en-us.json (Norwegian Bokmal) [ci skip] --- messages/nb-NO.json | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 1e0d6a1a3..9bcca74db 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -449,8 +449,14 @@ "provisioningManage": "Levering", "provisioningDescription": "Administrer foreløpig nøkler og gjennomgå ventende nettsteder som venter på godkjenning.", "pendingSites": "Ventende nettsteder", - "siteApproveSuccess": "Vellykket godkjenning av nettsted", + "siteApproveSuccess": "Område og tilknyttede ressurser godkjent", "siteApproveError": "Feil ved godkjenning av side", + "siteReject": "Avvis Område", + "siteQuestionReject": "Er du sikker på at du vil avvise dette området?", + "siteMessageReject": "Dette vil permanent slette området og eventuelle tilknyttede ressurser som fortsatt venter.", + "siteConfirmReject": "Bekreft Avvisning av Område", + "siteRejectSuccess": "Område avvist", + "siteRejectError": "Feil ved avvisning av området", "provisioningKeys": "Foreløpig nøkler", "searchProvisioningKeys": "Søk varer i lagrings nøkler...", "provisioningKeysAdd": "Generer fremvisende nøkkel", @@ -466,12 +472,12 @@ "provisioningKeysSave": "Lagre den midlertidig nøkkelen", "provisioningKeysSaveDescription": "Du kan bare se denne én gang. Kopier det til et sikkert sted.", "provisioningKeysErrorCreate": "Feil under oppretting av foreløpig nøkkel", - "provisioningKeysList": "Ny provisorisk nøkkel", - "provisioningKeysMaxBatchSize": "Maks størrelse på bunt", + "provisioningKeysList": "Ny Forsyningsnøkkel", + "provisioningKeysMaxBatchSize": "Maks Batch Størrelse", "provisioningKeysUnlimitedBatchSize": "Ubegrenset mengde bunt (ingen begrensning)", "provisioningKeysMaxBatchUnlimited": "Ubegrenset", "provisioningKeysMaxBatchSizeInvalid": "Angi en gyldig sjakkstørrelse (1–1 000.000).", - "provisioningKeysValidUntil": "Gyldig til", + "provisioningKeysValidUntil": "Gyldig Til", "provisioningKeysValidUntilHint": "La stå tomt for ingen utløp.", "provisioningKeysValidUntilInvalid": "Angi en gyldig dato og klokkeslett.", "provisioningKeysNumUsed": "Antall ganger brukt", @@ -480,7 +486,7 @@ "provisioningKeysNeverUsed": "Aldri", "provisioningKeysEdit": "Rediger bestemmelsesnøkkel", "provisioningKeysEditDescription": "Oppdater maksimal størrelse for bunt og utløpstid for denne nøkkelen.", - "provisioningKeysApproveNewSites": "Godkjenn nye nettsteder", + "provisioningKeysApproveNewSites": "Godkjenn Nye Områder", "provisioningKeysApproveNewSitesDescription": "Godkjenn automatisk nettsteder som registrerer deg med denne nøkkelen.", "provisioningKeysUpdateError": "Feil under oppdatering av foreløpig nøkkel", "provisioningKeysUpdated": "Foreslå nøkkel oppdatert", @@ -1397,6 +1403,7 @@ "createOrgUser": "Opprett Org bruker", "actionUpdateOrg": "Oppdater organisasjon", "actionRemoveInvitation": "Fjern invitasjon", + "actionRemoveUserRole": "Fjern Brukerrolle", "actionUpdateUser": "Oppdater bruker", "actionGetUser": "Hent bruker", "actionGetOrgUser": "Hent organisasjonsbruker", @@ -1419,6 +1426,8 @@ "setupTokenDescription": "Skriv inn oppsetttoken fra serverkonsollen.", "setupTokenRequired": "Oppsetttoken er nødvendig", "actionUpdateSite": "Oppdater område", + "actionApproveSite": "Godkjenn Område", + "actionRejectSite": "Avvis Område", "actionResetSiteBandwidth": "Tilbakestill organisasjons-båndbredde", "actionListSiteRoles": "List opp tillatte områderoller", "actionCreateResource": "Opprett ressurs", @@ -1516,6 +1525,7 @@ "otpAuthDescription": "Skriv inn koden fra autentiseringsappen din eller en av dine engangs reservekoder.", "otpAuthSubmit": "Send inn kode", "idpContinue": "Eller fortsett med", + "idpLastUsed": "Sist brukt", "otpAuthBack": "Tilbake til passord", "navbar": "Navigasjonsmeny", "navbarDescription": "Hovednavigasjonsmeny for applikasjonen", From 3fb36c143430e6ab905aa040866e30bf16d93eba Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 15 Jul 2026 22:03:28 -0400 Subject: [PATCH 048/494] add missing translation --- messages/en-US.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index 23195eab0..6893b41fa 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3811,5 +3811,6 @@ "rdpUploadFailed": "Upload failed", "rdpUnicodeKeyboardMode": "Unicode keyboard mode", "sessionToolbarShow": "Show toolbar", - "sessionToolbarHide": "Hide toolbar" + "sessionToolbarHide": "Hide toolbar", + "actionUpdateSiteApprovals": "Update Site Approvals" } From 530a1a53509fe9392f2b1a382eb04f1fbf769ae2 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Jul 2026 17:30:15 -0400 Subject: [PATCH 049/494] Add new column --- server/db/pg/schema/schema.ts | 1 + server/db/sqlite/schema/schema.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index f3375b0d9..12f291604 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -107,6 +107,7 @@ export const sites = pgTable( lastPing: integer("lastPing"), address: varchar("address"), endpoint: varchar("endpoint"), + localEndpoints: varchar("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to publicKey: varchar("publicKey"), lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 3fcf38a41..b8a01af89 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -118,6 +118,7 @@ export const sites = sqliteTable("sites", { // exit node stuff that is how to connect to the site when it has a wg server address: text("address"), // this is the address of the wireguard interface in newt endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config + localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to publicKey: text("publicKey"), // TODO: Fix typo in publicKey lastHolePunch: integer("lastHolePunch"), listenPort: integer("listenPort"), From 7f9c7603807ce9fd5864d6b5e037700ceacb39fa Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Jul 2026 20:45:18 -0400 Subject: [PATCH 050/494] Save the local endpoints --- server/routers/newt/handleNewtRegisterMessage.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 0dc8380c8..bb77fb53f 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -48,7 +48,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { pingResults, newtVersion, backwardsCompatible, - chainId + chainId, + localEndpoints } = message.data; if (!publicKey) { logger.warn("Public key not provided"); @@ -131,7 +132,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .set({ pubKey: publicKey, exitNodeId: exitNodeId, - subnet: newSubnet + subnet: newSubnet, + localEndpoints: localEndpoints || null }) .where(eq(sites.siteId, siteId)) .returning(); @@ -139,7 +141,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { await db .update(sites) .set({ - pubKey: publicKey + pubKey: publicKey, + localEndpoints: localEndpoints || null }) .where(eq(sites.siteId, siteId)) .returning(); From 903e8c0fa10f7488cb32a6e2edcdae244a881f17 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 10:47:24 -0400 Subject: [PATCH 051/494] Send localEndpoints on sites to olm --- .../routers/newt/handleNewtRegisterMessage.ts | 8 +++-- server/routers/olm/buildConfiguration.ts | 4 +++ .../olm/handleOlmServerPeerAddMessage.ts | 32 ++++++------------- server/routers/olm/peers.ts | 2 ++ 4 files changed, 22 insertions(+), 24 deletions(-) diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index bb77fb53f..17a8d955a 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -133,7 +133,9 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { pubKey: publicKey, exitNodeId: exitNodeId, subnet: newSubnet, - localEndpoints: localEndpoints || null + localEndpoints: localEndpoints + ? JSON.stringify(localEndpoints) + : null }) .where(eq(sites.siteId, siteId)) .returning(); @@ -142,7 +144,9 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .update(sites) .set({ pubKey: publicKey, - localEndpoints: localEndpoints || null + localEndpoints: localEndpoints + ? JSON.stringify(localEndpoints) + : null }) .where(eq(sites.siteId, siteId)) .returning(); diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index 0266fa041..37355eae5 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -30,6 +30,7 @@ export async function buildSiteConfigurationForOlmClient( siteId: number; name?: string; endpoint?: string; + localEndpoints?: string[]; publicKey?: string; serverIP?: string | null; serverPort?: number | null; @@ -206,6 +207,9 @@ export async function buildSiteConfigurationForOlmClient( name: site.name, // relayEndpoint: relayEndpoint, // this can be undefined now if not relayed // lets not do this for now because it would conflict with the hole punch testing endpoint: site.endpoint, + localEndpoints: site.localEndpoints + ? JSON.parse(site.localEndpoints) + : undefined, publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort, diff --git a/server/routers/olm/handleOlmServerPeerAddMessage.ts b/server/routers/olm/handleOlmServerPeerAddMessage.ts index 5f46ea84c..332afe082 100644 --- a/server/routers/olm/handleOlmServerPeerAddMessage.ts +++ b/server/routers/olm/handleOlmServerPeerAddMessage.ts @@ -3,24 +3,15 @@ import { db, networks, siteNetworks, - siteResources, + siteResources } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { - clients, - clientSitesAssociationsCache, - Olm, - sites -} from "@server/db"; +import { clients, clientSitesAssociationsCache, Olm, sites } from "@server/db"; import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import logger from "@server/logger"; -import { - generateAliasConfig, -} from "@server/lib/ip"; +import { generateAliasConfig } from "@server/lib/ip"; import { generateRemoteSubnets } from "@server/lib/ip"; -import { - addPeer as newtAddPeer, -} from "@server/routers/newt/peers"; +import { addPeer as newtAddPeer } from "@server/routers/newt/peers"; export const handleOlmServerPeerAddMessage: MessageHandler = async ( context @@ -135,10 +126,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( clientSiteResourcesAssociationsCache.siteResourceId ) ) - .innerJoin( - networks, - eq(siteResources.networkId, networks.networkId) - ) + .innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .innerJoin( siteNetworks, and( @@ -147,10 +135,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( ) ) .where( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ) + eq(clientSiteResourcesAssociationsCache.clientId, client.clientId) ); // Return connect message with all site configurations @@ -161,6 +146,9 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( siteId: site.siteId, name: site.name, endpoint: site.endpoint, + localEndpoints: site.localEndpoints + ? JSON.parse(site.localEndpoints) + : undefined, publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort, @@ -170,7 +158,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( aliases: generateAliasConfig( allSiteResources.map(({ siteResources }) => siteResources) ), - chainId: chainId, + chainId: chainId } }, broadcast: false, diff --git a/server/routers/olm/peers.ts b/server/routers/olm/peers.ts index 962d7367e..960c6ad23 100644 --- a/server/routers/olm/peers.ts +++ b/server/routers/olm/peers.ts @@ -18,6 +18,7 @@ export async function addPeer( serverPort: number | null; remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access aliases: Alias[]; + localEndpoints?: string[]; // optional, list of local endpoints for the peer }, olmId?: string, version?: string | null @@ -44,6 +45,7 @@ export async function addPeer( name: peer.name, publicKey: peer.publicKey, endpoint: peer.endpoint, + localEndpoints: peer.localEndpoints, relayEndpoint: peer.relayEndpoint, serverIP: peer.serverIP, serverPort: peer.serverPort, From 515afc14a5695fa21a70ff8ff1364c90adacecd0 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 11:23:17 -0400 Subject: [PATCH 052/494] Add handlers --- server/routers/olm/handleOlmLocalMessage.ts | 74 +++++++++++++++ server/routers/olm/handleOlmRelayMessage.ts | 4 +- server/routers/olm/handleOlmUnLocalMessage.ts | 95 +++++++++++++++++++ server/routers/olm/handleOlmUnRelayMessage.ts | 4 +- server/routers/olm/index.ts | 2 + server/routers/ws/messageHandlers.ts | 6 +- 6 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 server/routers/olm/handleOlmLocalMessage.ts create mode 100644 server/routers/olm/handleOlmUnLocalMessage.ts diff --git a/server/routers/olm/handleOlmLocalMessage.ts b/server/routers/olm/handleOlmLocalMessage.ts new file mode 100644 index 000000000..83ef8cbf1 --- /dev/null +++ b/server/routers/olm/handleOlmLocalMessage.ts @@ -0,0 +1,74 @@ +import { db, sites } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { clients, Olm } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { updatePeer as newtUpdatePeer } from "../newt/peers"; +import logger from "@server/logger"; + +export const handleOlmLocalMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + logger.info("Handling local olm message!"); + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no client!"); + return; + } + + const clientId = olm.clientId; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + logger.warn("Client not found"); + return; + } + + // make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old + if (!client.pubKey) { + logger.warn("Client has no endpoint or listen port"); + return; + } + + const { siteId, chainId } = message.data; + + // Get the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site || !site.exitNodeId) { + logger.warn("Site not found or has no exit node"); + return; + } + + // update the peer on the newt + await newtUpdatePeer(siteId, client.pubKey, { + endpoint: "" // this removes the endpoint so the newt knows to accept local + }); + + // Just ack the message, we don't keep sending it + return { + message: { + type: "olm/wg/peer/local", + data: { + siteId: siteId, + chainId + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/olm/handleOlmRelayMessage.ts b/server/routers/olm/handleOlmRelayMessage.ts index 7196824d2..406ed7bbb 100644 --- a/server/routers/olm/handleOlmRelayMessage.ts +++ b/server/routers/olm/handleOlmRelayMessage.ts @@ -79,9 +79,9 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => { ) ); - // update the peer on the exit node + // update the peer on the newt await newtUpdatePeer(siteId, client.pubKey, { - endpoint: "" // this removes the endpoint so the exit node knows to relay + endpoint: "" // this removes the endpoint so the newt knows to relay }); return { diff --git a/server/routers/olm/handleOlmUnLocalMessage.ts b/server/routers/olm/handleOlmUnLocalMessage.ts new file mode 100644 index 000000000..55d312815 --- /dev/null +++ b/server/routers/olm/handleOlmUnLocalMessage.ts @@ -0,0 +1,95 @@ +import { db, exitNodes, sites } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { clients, clientSitesAssociationsCache, Olm } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { updatePeer as newtUpdatePeer } from "../newt/peers"; +import logger from "@server/logger"; + +export const handleOlmUnLocalMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + logger.info("Handling unlocal olm message!"); + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no client!"); + return; + } + + const clientId = olm.clientId; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + logger.warn("Client not found"); + return; + } + + // make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old + if (!client.pubKey) { + logger.warn("Client has no endpoint or listen port"); + return; + } + + const { siteId, chainId } = message.data; + + // Get the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site) { + logger.warn("Site not found or has no exit node"); + return; + } + + const [clientSiteAssociation] = await db + .select() + .from(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.clientId, olm.clientId), + eq(clientSitesAssociationsCache.siteId, siteId) + ) + ); + + if (!clientSiteAssociation) { + logger.warn("Client-Site association not found"); + return; + } + + if (!clientSiteAssociation.endpoint) { + logger.warn("Client-Site association has no endpoint, cannot unrelay"); + return; + } + + // update the peer on the newt + await newtUpdatePeer(siteId, client.pubKey, { + endpoint: clientSiteAssociation.isRelayed + ? "" + : clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt + }); + + return { + message: { + type: "olm/wg/peer/unlocal", + data: { + siteId: siteId, + chainId + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/olm/handleOlmUnRelayMessage.ts b/server/routers/olm/handleOlmUnRelayMessage.ts index a7b426023..3f73a5834 100644 --- a/server/routers/olm/handleOlmUnRelayMessage.ts +++ b/server/routers/olm/handleOlmUnRelayMessage.ts @@ -77,9 +77,9 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => { return; } - // update the peer on the exit node + // update the peer on the newt await newtUpdatePeer(siteId, client.pubKey, { - endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the exit node + endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt }); return { diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index 5c151a8cf..e11d4e48e 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -13,3 +13,5 @@ export * from "./recoverOlmWithFingerprint"; export * from "./handleOlmDisconnectingMessage"; export * from "./handleOlmServerInitAddPeerHandshake"; export * from "./offlineChecker"; +export * from "./handleOlmUnLocalMessage"; +export * from "./handleOlmLocalMessage"; diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index f89284389..496002142 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -20,7 +20,9 @@ import { handleOlmServerPeerAddMessage, handleOlmUnRelayMessage, handleOlmDisconnectingMessage, - handleOlmServerInitAddPeerHandshake + handleOlmServerInitAddPeerHandshake, + handleOlmLocalMessage, + handleOlmUnLocalMessage } from "../olm"; import { handleHealthcheckStatusMessage } from "../target"; import { handleRoundTripMessage } from "./handleRoundTripMessage"; @@ -32,6 +34,8 @@ export const messageHandlers: Record = { "olm/wg/register": handleOlmRegisterMessage, "olm/wg/relay": handleOlmRelayMessage, "olm/wg/unrelay": handleOlmUnRelayMessage, + "olm/wg/local": handleOlmLocalMessage, + "olm/wg/unlocal": handleOlmUnLocalMessage, "olm/ping": handleOlmPingMessage, "olm/disconnecting": handleOlmDisconnectingMessage, "newt/disconnecting": handleNewtDisconnectingMessage, From dbfb8e83e8547bba1374ca385af8f3c8f1be766d Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 14:43:45 -0400 Subject: [PATCH 053/494] Local endpoints from the get config --- server/routers/newt/handleNewtGetConfigMessage.ts | 7 +++++-- server/routers/newt/handleNewtRegisterMessage.ts | 13 +++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/server/routers/newt/handleNewtGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts index fd5e2b42e..d99dd89ef 100644 --- a/server/routers/newt/handleNewtGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -29,7 +29,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { return; } - const { publicKey, port, chainId } = message.data; + const { publicKey, port, localEndpoints, chainId } = message.data; const siteId = newt.siteId; // Get the current site data @@ -69,7 +69,10 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { .update(sites) .set({ publicKey, - listenPort: port + listenPort: port, + localEndpoints: localEndpoints + ? JSON.stringify(localEndpoints) + : null }) .where(eq(sites.siteId, siteId)) .returning(); diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 17a8d955a..0dc8380c8 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -48,8 +48,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { pingResults, newtVersion, backwardsCompatible, - chainId, - localEndpoints + chainId } = message.data; if (!publicKey) { logger.warn("Public key not provided"); @@ -132,10 +131,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .set({ pubKey: publicKey, exitNodeId: exitNodeId, - subnet: newSubnet, - localEndpoints: localEndpoints - ? JSON.stringify(localEndpoints) - : null + subnet: newSubnet }) .where(eq(sites.siteId, siteId)) .returning(); @@ -143,10 +139,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { await db .update(sites) .set({ - pubKey: publicKey, - localEndpoints: localEndpoints - ? JSON.stringify(localEndpoints) - : null + pubKey: publicKey }) .where(eq(sites.siteId, siteId)) .returning(); From 55f3807491f5f84a6656d800fd1d4c811d40f878 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 15:10:38 -0400 Subject: [PATCH 054/494] Enforce uniqueness of aliases in blueprints --- server/lib/blueprints/privateResources.ts | 46 +++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 44901e8c8..f3fa7bf5d 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -239,6 +239,31 @@ export async function updatePrivateResources( ); } + if (resourceData.alias) { + const [aliasConflict] = await trx + .select({ + siteResourceId: siteResources.siteResourceId + }) + .from(siteResources) + .where( + and( + eq(siteResources.orgId, orgId), + eq(siteResources.alias, resourceData.alias), + ne( + siteResources.siteResourceId, + existingResource.siteResourceId + ) + ) + ) + .limit(1); + + if (aliasConflict) { + throw new Error( + `Alias ${resourceData.alias} already in use by another site resource in org ${orgId}` + ); + } + } + // Update existing resource const [updatedResource] = await trx .update(siteResources) @@ -480,6 +505,27 @@ export async function updatePrivateResources( ); } + if (resourceData.alias) { + const [aliasConflict] = await trx + .select({ + siteResourceId: siteResources.siteResourceId + }) + .from(siteResources) + .where( + and( + eq(siteResources.orgId, orgId), + eq(siteResources.alias, resourceData.alias) + ) + ) + .limit(1); + + if (aliasConflict) { + throw new Error( + `Alias ${resourceData.alias} already in use by another site resource in org ${orgId}` + ); + } + } + const [network] = await trx .insert(networks) .values({ From a88b79e066c9271675ece2d390cb678c243a9bf6 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 16:09:35 -0400 Subject: [PATCH 055/494] Add timezone offset to fix bad display --- server/lib/statusHistory.ts | 67 ++++++++++++++----- .../routers/healthChecks/getStatusHistory.ts | 9 ++- server/routers/resource/getStatusHistory.ts | 9 ++- server/routers/site/getStatusHistory.ts | 9 ++- src/lib/queries.ts | 17 +++-- 5 files changed, 82 insertions(+), 29 deletions(-) diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 7c5b5c370..d11ea88ef 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -8,26 +8,42 @@ const STATUS_HISTORY_CACHE_TTL = 60; // seconds function statusHistoryCacheKey( entityType: string, entityId: number, - days: number + days: number, + tzOffsetMinutes: number ): string { - return `statusHistory:${entityType}:${entityId}:${days}`; + return `statusHistory:${entityType}:${entityId}:${days}:${tzOffsetMinutes}`; +} + +// Returns the epoch seconds of the most recent local-calendar-day midnight, +// where "local" is defined by tzOffsetMinutes (minutes to ADD to UTC to get +// local time, e.g. Australia/Sydney standard time is 600). Defaults to 0 +// (UTC) so callers that don't pass a timezone keep the original behavior. +function localMidnightSec(tzOffsetMinutes: number): number { + const localNow = new Date(Date.now() + tzOffsetMinutes * 60_000); + localNow.setUTCHours(0, 0, 0, 0); + return Math.floor(localNow.getTime() / 1000) - tzOffsetMinutes * 60; } export async function getCachedStatusHistory( entityType: string, entityId: number, - days: number + days: number, + tzOffsetMinutes: number = 0 ): Promise { - const cacheKey = statusHistoryCacheKey(entityType, entityId, days); + const cacheKey = statusHistoryCacheKey( + entityType, + entityId, + days, + tzOffsetMinutes + ); const cached = await cache.get(cacheKey); if (cached !== undefined) { return cached; } - // Anchor to UTC midnight so the query window aligns with stable calendar days - const utcToday = new Date(); - utcToday.setUTCHours(0, 0, 0, 0); - const todayMidnightSec = Math.floor(utcToday.getTime() / 1000); + // Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query + // window aligns with stable calendar days for the requesting client + const todayMidnightSec = localMidnightSec(tzOffsetMinutes); const startSec = todayMidnightSec - days * 86400; const events = await logsDb @@ -63,7 +79,8 @@ export async function getCachedStatusHistory( const { buckets, totalDowntime } = computeBuckets( events, days, - priorStatus + priorStatus, + tzOffsetMinutes ); const totalWindow = days * 86400; const overallUptime = @@ -99,11 +116,19 @@ export const statusHistoryQuerySchema = z days: z .string() .optional() - .transform((v) => (v ? parseInt(v, 10) : 90)) + .transform((v) => (v ? parseInt(v, 10) : 90)), + // Minutes to add to UTC to get the requesting client's local time + // (e.g. Australia/Sydney standard time is 600). Optional and + // defaults to 0 (UTC) so older clients keep the prior behavior. + tzOffsetMinutes: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 0)) }) .pipe( z.object({ - days: z.number().int().min(1).max(365) + days: z.number().int().min(1).max(365), + tzOffsetMinutes: z.number().int().min(-720).max(840) }) ); @@ -133,15 +158,15 @@ export function computeBuckets( id: number; }[], days: number, - priorStatus: string | null = null + priorStatus: string | null = null, + tzOffsetMinutes: number = 0 ): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } { const nowSec = Math.floor(Date.now() / 1000); - // Anchor bucket boundaries to UTC midnight so dates are stable calendar days - // and don't drift as the cache expires and is recomputed - const utcToday = new Date(); - utcToday.setUTCHours(0, 0, 0, 0); - const todayMidnightSec = Math.floor(utcToday.getTime() / 1000); + // Anchor bucket boundaries to local midnight (UTC when tzOffsetMinutes is + // 0) so dates are stable calendar days for the requesting client and + // don't drift as the cache expires and is recomputed + const todayMidnightSec = localMidnightSec(tzOffsetMinutes); const buckets: StatusHistoryDayBucket[] = []; let totalDowntime = 0; @@ -237,7 +262,13 @@ export function computeBuckets( ) : 100; - const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10); + // Shift by the client's offset before formatting so the label reflects + // their local calendar date rather than the UTC date of dayStartSec + const dateStr = new Date( + (dayStartSec + tzOffsetMinutes * 60) * 1000 + ) + .toISOString() + .slice(0, 10); const hasAnyData = currentStatus !== null || dayEvents.length > 0; diff --git a/server/private/routers/healthChecks/getStatusHistory.ts b/server/private/routers/healthChecks/getStatusHistory.ts index 51a59e2e1..484870886 100644 --- a/server/private/routers/healthChecks/getStatusHistory.ts +++ b/server/private/routers/healthChecks/getStatusHistory.ts @@ -55,9 +55,14 @@ export async function getHealthCheckStatusHistory( const entityType = "health_check"; const entityId = parsedParams.data.healthCheckId; - const { days } = parsedQuery.data; + const { days, tzOffsetMinutes } = parsedQuery.data; - const data = await getCachedStatusHistory(entityType, entityId, days); + const data = await getCachedStatusHistory( + entityType, + entityId, + days, + tzOffsetMinutes + ); return response(res, { data, diff --git a/server/routers/resource/getStatusHistory.ts b/server/routers/resource/getStatusHistory.ts index c3dcf6c88..19e11b78f 100644 --- a/server/routers/resource/getStatusHistory.ts +++ b/server/routers/resource/getStatusHistory.ts @@ -42,9 +42,14 @@ export async function getResourceStatusHistory( const entityType = "resource"; const entityId = parsedParams.data.resourceId; - const { days } = parsedQuery.data; + const { days, tzOffsetMinutes } = parsedQuery.data; - const data = await getCachedStatusHistory(entityType, entityId, days); + const data = await getCachedStatusHistory( + entityType, + entityId, + days, + tzOffsetMinutes + ); return response(res, { data, diff --git a/server/routers/site/getStatusHistory.ts b/server/routers/site/getStatusHistory.ts index 26f1dbbd2..d281961e9 100644 --- a/server/routers/site/getStatusHistory.ts +++ b/server/routers/site/getStatusHistory.ts @@ -42,9 +42,14 @@ export async function getSiteStatusHistory( const entityType = "site"; const entityId = parsedParams.data.siteId; - const { days } = parsedQuery.data; + const { days, tzOffsetMinutes } = parsedQuery.data; - const data = await getCachedStatusHistory(entityType, entityId, days); + const data = await getCachedStatusHistory( + entityType, + entityId, + days, + tzOffsetMinutes + ); return response(res, { data, diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 8fdb1c531..3194e5651 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -650,9 +650,13 @@ export const orgQueries = { queryOptions({ queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const, queryFn: async ({ signal, meta }) => { + const tzOffsetMinutes = -new Date().getTimezoneOffset(); const res = await meta!.api.get< AxiosResponse - >(`/site/${siteId}/status-history?days=${days}`, { signal }); + >( + `/site/${siteId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`, + { signal } + ); return res.data.data; } }), @@ -667,11 +671,13 @@ export const orgQueries = { queryOptions({ queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const, queryFn: async ({ signal, meta }) => { + const tzOffsetMinutes = -new Date().getTimezoneOffset(); const res = await meta!.api.get< AxiosResponse - >(`/resource/${resourceId}/status-history?days=${days}`, { - signal - }); + >( + `/resource/${resourceId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`, + { signal } + ); return res.data.data; } }), @@ -693,10 +699,11 @@ export const orgQueries = { days ] as const, queryFn: async ({ signal, meta }) => { + const tzOffsetMinutes = -new Date().getTimezoneOffset(); const res = await meta!.api.get< AxiosResponse >( - `/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}`, + `/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`, { signal } ); return res.data.data; From 08c5ec2be78673a5b3d231383c513028f03f5f61 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Jul 2026 17:30:15 -0400 Subject: [PATCH 056/494] Add new column --- server/db/pg/schema/schema.ts | 1 + server/db/sqlite/schema/schema.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index f3375b0d9..12f291604 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -107,6 +107,7 @@ export const sites = pgTable( lastPing: integer("lastPing"), address: varchar("address"), endpoint: varchar("endpoint"), + localEndpoints: varchar("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to publicKey: varchar("publicKey"), lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 3fcf38a41..b8a01af89 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -118,6 +118,7 @@ export const sites = sqliteTable("sites", { // exit node stuff that is how to connect to the site when it has a wg server address: text("address"), // this is the address of the wireguard interface in newt endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config + localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to publicKey: text("publicKey"), // TODO: Fix typo in publicKey lastHolePunch: integer("lastHolePunch"), listenPort: integer("listenPort"), From 3ac7ef23ae9d136bbaf132850c3af84286001693 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Jul 2026 20:45:18 -0400 Subject: [PATCH 057/494] Save the local endpoints --- server/routers/newt/handleNewtRegisterMessage.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 0dc8380c8..bb77fb53f 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -48,7 +48,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { pingResults, newtVersion, backwardsCompatible, - chainId + chainId, + localEndpoints } = message.data; if (!publicKey) { logger.warn("Public key not provided"); @@ -131,7 +132,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .set({ pubKey: publicKey, exitNodeId: exitNodeId, - subnet: newSubnet + subnet: newSubnet, + localEndpoints: localEndpoints || null }) .where(eq(sites.siteId, siteId)) .returning(); @@ -139,7 +141,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { await db .update(sites) .set({ - pubKey: publicKey + pubKey: publicKey, + localEndpoints: localEndpoints || null }) .where(eq(sites.siteId, siteId)) .returning(); From d5eb67a8fc6e26dd5e6de90ab50045bb25840a77 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 10:47:24 -0400 Subject: [PATCH 058/494] Send localEndpoints on sites to olm --- .../routers/newt/handleNewtRegisterMessage.ts | 8 +++-- server/routers/olm/buildConfiguration.ts | 4 +++ .../olm/handleOlmServerPeerAddMessage.ts | 32 ++++++------------- server/routers/olm/peers.ts | 2 ++ 4 files changed, 22 insertions(+), 24 deletions(-) diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index bb77fb53f..17a8d955a 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -133,7 +133,9 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { pubKey: publicKey, exitNodeId: exitNodeId, subnet: newSubnet, - localEndpoints: localEndpoints || null + localEndpoints: localEndpoints + ? JSON.stringify(localEndpoints) + : null }) .where(eq(sites.siteId, siteId)) .returning(); @@ -142,7 +144,9 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .update(sites) .set({ pubKey: publicKey, - localEndpoints: localEndpoints || null + localEndpoints: localEndpoints + ? JSON.stringify(localEndpoints) + : null }) .where(eq(sites.siteId, siteId)) .returning(); diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index 0266fa041..37355eae5 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -30,6 +30,7 @@ export async function buildSiteConfigurationForOlmClient( siteId: number; name?: string; endpoint?: string; + localEndpoints?: string[]; publicKey?: string; serverIP?: string | null; serverPort?: number | null; @@ -206,6 +207,9 @@ export async function buildSiteConfigurationForOlmClient( name: site.name, // relayEndpoint: relayEndpoint, // this can be undefined now if not relayed // lets not do this for now because it would conflict with the hole punch testing endpoint: site.endpoint, + localEndpoints: site.localEndpoints + ? JSON.parse(site.localEndpoints) + : undefined, publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort, diff --git a/server/routers/olm/handleOlmServerPeerAddMessage.ts b/server/routers/olm/handleOlmServerPeerAddMessage.ts index 5f46ea84c..332afe082 100644 --- a/server/routers/olm/handleOlmServerPeerAddMessage.ts +++ b/server/routers/olm/handleOlmServerPeerAddMessage.ts @@ -3,24 +3,15 @@ import { db, networks, siteNetworks, - siteResources, + siteResources } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { - clients, - clientSitesAssociationsCache, - Olm, - sites -} from "@server/db"; +import { clients, clientSitesAssociationsCache, Olm, sites } from "@server/db"; import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import logger from "@server/logger"; -import { - generateAliasConfig, -} from "@server/lib/ip"; +import { generateAliasConfig } from "@server/lib/ip"; import { generateRemoteSubnets } from "@server/lib/ip"; -import { - addPeer as newtAddPeer, -} from "@server/routers/newt/peers"; +import { addPeer as newtAddPeer } from "@server/routers/newt/peers"; export const handleOlmServerPeerAddMessage: MessageHandler = async ( context @@ -135,10 +126,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( clientSiteResourcesAssociationsCache.siteResourceId ) ) - .innerJoin( - networks, - eq(siteResources.networkId, networks.networkId) - ) + .innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .innerJoin( siteNetworks, and( @@ -147,10 +135,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( ) ) .where( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ) + eq(clientSiteResourcesAssociationsCache.clientId, client.clientId) ); // Return connect message with all site configurations @@ -161,6 +146,9 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( siteId: site.siteId, name: site.name, endpoint: site.endpoint, + localEndpoints: site.localEndpoints + ? JSON.parse(site.localEndpoints) + : undefined, publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort, @@ -170,7 +158,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( aliases: generateAliasConfig( allSiteResources.map(({ siteResources }) => siteResources) ), - chainId: chainId, + chainId: chainId } }, broadcast: false, diff --git a/server/routers/olm/peers.ts b/server/routers/olm/peers.ts index 962d7367e..960c6ad23 100644 --- a/server/routers/olm/peers.ts +++ b/server/routers/olm/peers.ts @@ -18,6 +18,7 @@ export async function addPeer( serverPort: number | null; remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access aliases: Alias[]; + localEndpoints?: string[]; // optional, list of local endpoints for the peer }, olmId?: string, version?: string | null @@ -44,6 +45,7 @@ export async function addPeer( name: peer.name, publicKey: peer.publicKey, endpoint: peer.endpoint, + localEndpoints: peer.localEndpoints, relayEndpoint: peer.relayEndpoint, serverIP: peer.serverIP, serverPort: peer.serverPort, From 3a616cc80481443b9e8f244547edb32fe313746f Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 11:23:17 -0400 Subject: [PATCH 059/494] Add handlers --- server/routers/olm/handleOlmLocalMessage.ts | 74 +++++++++++++++ server/routers/olm/handleOlmRelayMessage.ts | 4 +- server/routers/olm/handleOlmUnLocalMessage.ts | 95 +++++++++++++++++++ server/routers/olm/handleOlmUnRelayMessage.ts | 4 +- server/routers/olm/index.ts | 2 + server/routers/ws/messageHandlers.ts | 6 +- 6 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 server/routers/olm/handleOlmLocalMessage.ts create mode 100644 server/routers/olm/handleOlmUnLocalMessage.ts diff --git a/server/routers/olm/handleOlmLocalMessage.ts b/server/routers/olm/handleOlmLocalMessage.ts new file mode 100644 index 000000000..83ef8cbf1 --- /dev/null +++ b/server/routers/olm/handleOlmLocalMessage.ts @@ -0,0 +1,74 @@ +import { db, sites } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { clients, Olm } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { updatePeer as newtUpdatePeer } from "../newt/peers"; +import logger from "@server/logger"; + +export const handleOlmLocalMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + logger.info("Handling local olm message!"); + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no client!"); + return; + } + + const clientId = olm.clientId; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + logger.warn("Client not found"); + return; + } + + // make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old + if (!client.pubKey) { + logger.warn("Client has no endpoint or listen port"); + return; + } + + const { siteId, chainId } = message.data; + + // Get the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site || !site.exitNodeId) { + logger.warn("Site not found or has no exit node"); + return; + } + + // update the peer on the newt + await newtUpdatePeer(siteId, client.pubKey, { + endpoint: "" // this removes the endpoint so the newt knows to accept local + }); + + // Just ack the message, we don't keep sending it + return { + message: { + type: "olm/wg/peer/local", + data: { + siteId: siteId, + chainId + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/olm/handleOlmRelayMessage.ts b/server/routers/olm/handleOlmRelayMessage.ts index 7196824d2..406ed7bbb 100644 --- a/server/routers/olm/handleOlmRelayMessage.ts +++ b/server/routers/olm/handleOlmRelayMessage.ts @@ -79,9 +79,9 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => { ) ); - // update the peer on the exit node + // update the peer on the newt await newtUpdatePeer(siteId, client.pubKey, { - endpoint: "" // this removes the endpoint so the exit node knows to relay + endpoint: "" // this removes the endpoint so the newt knows to relay }); return { diff --git a/server/routers/olm/handleOlmUnLocalMessage.ts b/server/routers/olm/handleOlmUnLocalMessage.ts new file mode 100644 index 000000000..55d312815 --- /dev/null +++ b/server/routers/olm/handleOlmUnLocalMessage.ts @@ -0,0 +1,95 @@ +import { db, exitNodes, sites } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { clients, clientSitesAssociationsCache, Olm } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { updatePeer as newtUpdatePeer } from "../newt/peers"; +import logger from "@server/logger"; + +export const handleOlmUnLocalMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + logger.info("Handling unlocal olm message!"); + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no client!"); + return; + } + + const clientId = olm.clientId; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + logger.warn("Client not found"); + return; + } + + // make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old + if (!client.pubKey) { + logger.warn("Client has no endpoint or listen port"); + return; + } + + const { siteId, chainId } = message.data; + + // Get the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site) { + logger.warn("Site not found or has no exit node"); + return; + } + + const [clientSiteAssociation] = await db + .select() + .from(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.clientId, olm.clientId), + eq(clientSitesAssociationsCache.siteId, siteId) + ) + ); + + if (!clientSiteAssociation) { + logger.warn("Client-Site association not found"); + return; + } + + if (!clientSiteAssociation.endpoint) { + logger.warn("Client-Site association has no endpoint, cannot unrelay"); + return; + } + + // update the peer on the newt + await newtUpdatePeer(siteId, client.pubKey, { + endpoint: clientSiteAssociation.isRelayed + ? "" + : clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt + }); + + return { + message: { + type: "olm/wg/peer/unlocal", + data: { + siteId: siteId, + chainId + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/olm/handleOlmUnRelayMessage.ts b/server/routers/olm/handleOlmUnRelayMessage.ts index a7b426023..3f73a5834 100644 --- a/server/routers/olm/handleOlmUnRelayMessage.ts +++ b/server/routers/olm/handleOlmUnRelayMessage.ts @@ -77,9 +77,9 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => { return; } - // update the peer on the exit node + // update the peer on the newt await newtUpdatePeer(siteId, client.pubKey, { - endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the exit node + endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt }); return { diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index 5c151a8cf..e11d4e48e 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -13,3 +13,5 @@ export * from "./recoverOlmWithFingerprint"; export * from "./handleOlmDisconnectingMessage"; export * from "./handleOlmServerInitAddPeerHandshake"; export * from "./offlineChecker"; +export * from "./handleOlmUnLocalMessage"; +export * from "./handleOlmLocalMessage"; diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index f89284389..496002142 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -20,7 +20,9 @@ import { handleOlmServerPeerAddMessage, handleOlmUnRelayMessage, handleOlmDisconnectingMessage, - handleOlmServerInitAddPeerHandshake + handleOlmServerInitAddPeerHandshake, + handleOlmLocalMessage, + handleOlmUnLocalMessage } from "../olm"; import { handleHealthcheckStatusMessage } from "../target"; import { handleRoundTripMessage } from "./handleRoundTripMessage"; @@ -32,6 +34,8 @@ export const messageHandlers: Record = { "olm/wg/register": handleOlmRegisterMessage, "olm/wg/relay": handleOlmRelayMessage, "olm/wg/unrelay": handleOlmUnRelayMessage, + "olm/wg/local": handleOlmLocalMessage, + "olm/wg/unlocal": handleOlmUnLocalMessage, "olm/ping": handleOlmPingMessage, "olm/disconnecting": handleOlmDisconnectingMessage, "newt/disconnecting": handleNewtDisconnectingMessage, From 6135f2b72713407d4a7ac4471ad7d5ce662e0746 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 14:43:45 -0400 Subject: [PATCH 060/494] Local endpoints from the get config --- server/routers/newt/handleNewtGetConfigMessage.ts | 7 +++++-- server/routers/newt/handleNewtRegisterMessage.ts | 13 +++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/server/routers/newt/handleNewtGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts index fd5e2b42e..d99dd89ef 100644 --- a/server/routers/newt/handleNewtGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -29,7 +29,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { return; } - const { publicKey, port, chainId } = message.data; + const { publicKey, port, localEndpoints, chainId } = message.data; const siteId = newt.siteId; // Get the current site data @@ -69,7 +69,10 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { .update(sites) .set({ publicKey, - listenPort: port + listenPort: port, + localEndpoints: localEndpoints + ? JSON.stringify(localEndpoints) + : null }) .where(eq(sites.siteId, siteId)) .returning(); diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 17a8d955a..0dc8380c8 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -48,8 +48,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { pingResults, newtVersion, backwardsCompatible, - chainId, - localEndpoints + chainId } = message.data; if (!publicKey) { logger.warn("Public key not provided"); @@ -132,10 +131,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .set({ pubKey: publicKey, exitNodeId: exitNodeId, - subnet: newSubnet, - localEndpoints: localEndpoints - ? JSON.stringify(localEndpoints) - : null + subnet: newSubnet }) .where(eq(sites.siteId, siteId)) .returning(); @@ -143,10 +139,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { await db .update(sites) .set({ - pubKey: publicKey, - localEndpoints: localEndpoints - ? JSON.stringify(localEndpoints) - : null + pubKey: publicKey }) .where(eq(sites.siteId, siteId)) .returning(); From 3c9f0946d2df6db8199fb654fd1b2c50a1839464 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 16 Jul 2026 17:56:31 -0400 Subject: [PATCH 061/494] add site provisioning keys to integration api --- messages/en-US.json | 4 + server/middlewares/integration/index.ts | 1 + .../verifyApiKeySiteProvisioningKeyAccess.ts | 111 ++++++++++++++++++ server/openApi.ts | 1 + server/private/routers/integration.ts | 44 +++++++ .../createSiteProvisioningKey.ts | 60 +++++++++- .../deleteSiteProvisioningKey.ts | 33 +++++- .../listSiteProvisioningKeys.ts | 56 +++++++-- .../updateSiteProvisioningKey.ts | 61 ++++++++-- src/components/PermissionsSelectBox.tsx | 7 ++ 10 files changed, 355 insertions(+), 23 deletions(-) create mode 100644 server/middlewares/integration/verifyApiKeySiteProvisioningKeyAccess.ts diff --git a/messages/en-US.json b/messages/en-US.json index 6893b41fa..678c73ebd 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "List Invitations", "actionExportLogs": "Export Logs", "actionViewLogs": "View Logs", + "actionCreateSiteProvisioningKey": "Create Site Provisioning Key", + "actionListSiteProvisioningKeys": "List Site Provisioning Keys", + "actionUpdateSiteProvisioningKey": "Update Site Provisioning Key", + "actionDeleteSiteProvisioningKey": "Delete Site Provisioning Key", "noneSelected": "None selected", "orgNotFound2": "No organizations found.", "search": "Search…", diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index d43854eab..be9d400cf 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -17,3 +17,4 @@ export * from "./verifyApiKeySiteResourceAccess"; export * from "./verifyApiKeyIdpAccess"; export * from "./verifyApiKeyDomainAccess"; export * from "./verifyApiKeyResourcePolicyAccess"; +export * from "./verifyApiKeySiteProvisioningKeyAccess"; diff --git a/server/middlewares/integration/verifyApiKeySiteProvisioningKeyAccess.ts b/server/middlewares/integration/verifyApiKeySiteProvisioningKeyAccess.ts new file mode 100644 index 000000000..d1982ad3c --- /dev/null +++ b/server/middlewares/integration/verifyApiKeySiteProvisioningKeyAccess.ts @@ -0,0 +1,111 @@ +import { Request, Response, NextFunction } from "express"; +import { + db, + siteProvisioningKeys, + siteProvisioningKeyOrg, + apiKeyOrg +} from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyApiKeySiteProvisioningKeyAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const siteProvisioningKeyId = + getFirstString(req.params.siteProvisioningKeyId) || + getFirstString(req.body.siteProvisioningKeyId) || + getFirstString(req.query.siteProvisioningKeyId); + const orgId = getFirstString(req.params.orgId); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!siteProvisioningKeyId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") + ); + } + + if (apiKey.isRoot) { + // Root keys can access any site provisioning key in any org + return next(); + } + + const [row] = await db + .select() + .from(siteProvisioningKeys) + .innerJoin( + siteProvisioningKeyOrg, + and( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyOrg.siteProvisioningKeyId + ), + eq(siteProvisioningKeyOrg.orgId, orgId) + ) + ) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ) + .limit(1); + + if (!row?.siteProvisioningKeys || !row.siteProvisioningKeyOrg) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site provisioning key with ID ${siteProvisioningKeyId} not found for organization ${orgId}` + ) + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, row.siteProvisioningKeyOrg.orgId) + ) + ) + .limit(1); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying site provisioning key access" + ) + ); + } +} diff --git a/server/openApi.ts b/server/openApi.ts index 7132a0327..63625192d 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -18,6 +18,7 @@ export enum OpenAPITags { OrgIdp = "Identity Provider (Organization Only)", Client = "Client", ApiKey = "API Key", + SiteProvisioningKey = "Site Provisioning Key", Domain = "Domain", Blueprint = "Blueprint", Ssh = "SSH", diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 820a843f0..b613b2f27 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -16,6 +16,7 @@ import * as org from "#private/routers/org"; import * as logs from "#private/routers/auditLogs"; import * as alertEvents from "#private/routers/alertEvents"; import * as certificates from "#private/routers/certificates"; +import * as siteProvisioning from "#private/routers/siteProvisioning"; import { verifyApiKeyHasAction, @@ -24,6 +25,7 @@ import { verifyApiKeyIdpAccess, verifyApiKeyRoleAccess, verifyApiKeyUserAccess, + verifyApiKeySiteProvisioningKeyAccess, verifyLimits } from "@server/middlewares"; import * as user from "#private/routers/user"; @@ -215,3 +217,45 @@ authenticated.delete( logActionAudit(ActionsEnum.removeUserRole), user.removeUserRole ); + +authenticated.put( + "/org/:orgId/site-provisioning-key", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createSiteProvisioningKey), + logActionAudit(ActionsEnum.createSiteProvisioningKey), + siteProvisioning.createSiteProvisioningKey +); + +authenticated.get( + "/org/:orgId/site-provisioning-keys", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listSiteProvisioningKeys), + siteProvisioning.listSiteProvisioningKeys +); + +authenticated.delete( + "/org/:orgId/site-provisioning-key/:siteProvisioningKeyId", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyApiKeyOrgAccess, + verifyApiKeySiteProvisioningKeyAccess, + verifyApiKeyHasAction(ActionsEnum.deleteSiteProvisioningKey), + logActionAudit(ActionsEnum.deleteSiteProvisioningKey), + siteProvisioning.deleteSiteProvisioningKey +); + +authenticated.patch( + "/org/:orgId/site-provisioning-key/:siteProvisioningKeyId", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyApiKeyOrgAccess, + verifyApiKeySiteProvisioningKeyAccess, + verifyApiKeyHasAction(ActionsEnum.updateSiteProvisioningKey), + logActionAudit(ActionsEnum.updateSiteProvisioningKey), + siteProvisioning.updateSiteProvisioningKey +); diff --git a/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts index f5b64c0f3..b810ebe4f 100644 --- a/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts @@ -26,6 +26,8 @@ import { import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; import type { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; +import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; +import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.object({ orgId: z.string().nonempty() @@ -34,10 +36,17 @@ const paramsSchema = z.object({ const bodySchema = z .strictObject({ name: z.string().min(1).max(255), - maxBatchSize: z.union([ - z.null(), - z.coerce.number().int().positive().max(1_000_000) - ]), + maxBatchSize: z + .union([ + z.null(), + z.coerce.number().int().positive().max(1_000_000) + ]) + .openapi({ + type: "number", + default: null, + description: + "Maximum number of sites that can be provisioned in a single batch. If null, there is no limit." + }), validUntil: z.string().max(255).optional(), approveNewSites: z.boolean().optional().default(true) }) @@ -57,6 +66,49 @@ const bodySchema = z export type CreateSiteProvisioningKeyBody = z.infer; +const CreateSiteProvisioningKeyResponseDataSchema = z.object({ + siteProvisioningKeyId: z.string(), + orgId: z.string(), + name: z.string(), + siteProvisioningKey: z.string(), + lastChars: z.string(), + createdAt: z.string(), + lastUsed: z.string().nullable(), + maxBatchSize: z.number().nullable(), + numUsed: z.number(), + validUntil: z.string().nullable(), + approveNewSites: z.boolean() +}); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/site-provisioning-key", + description: "Create a new site provisioning key for the organization.", + tags: [OpenAPITags.SiteProvisioningKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 201: { + description: "Successful response", + content: { + "application/json": { + schema: createApiResponseSchema( + CreateSiteProvisioningKeyResponseDataSchema + ) + } + } + } + } +}); + export async function createSiteProvisioningKey( req: Request, res: Response, diff --git a/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts index 61fa0a850..9c77c93ca 100644 --- a/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts @@ -13,23 +13,46 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { - db, - siteProvisioningKeyOrg, - siteProvisioningKeys -} from "@server/db"; +import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.object({ siteProvisioningKeyId: z.string().nonempty(), orgId: z.string().nonempty() }); +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/site-provisioning-key/{siteProvisioningKeyId}", + description: "Delete a site provisioning key.", + tags: [OpenAPITags.SiteProvisioningKey], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function deleteSiteProvisioningKey( req: Request, res: Response, diff --git a/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts index 0e9838b24..63ab4429a 100644 --- a/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts +++ b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts @@ -11,11 +11,7 @@ * This file is not licensed under the AGPLv3. */ -import { - db, - siteProvisioningKeyOrg, - siteProvisioningKeys -} from "@server/db"; +import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; @@ -25,6 +21,8 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; import type { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; +import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; +import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.object({ orgId: z.string().nonempty() @@ -45,11 +43,55 @@ const querySchema = z.object({ .pipe(z.int().nonnegative()) }); +const ListSiteProvisioningKeysResponseDataSchema = z.object({ + siteProvisioningKeys: z.array( + z.object({ + siteProvisioningKeyId: z.string(), + orgId: z.string(), + lastChars: z.string(), + createdAt: z.string(), + name: z.string(), + lastUsed: z.string().nullable(), + maxBatchSize: z.number().nullable(), + numUsed: z.number(), + validUntil: z.string().nullable(), + approveNewSites: z.boolean() + }) + ), + pagination: z.object({ + total: z.number(), + limit: z.number(), + offset: z.number() + }) +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/site-provisioning-keys", + description: "List all site provisioning keys for an organization.", + tags: [OpenAPITags.SiteProvisioningKey], + request: { + params: paramsSchema, + query: querySchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: createApiResponseSchema( + ListSiteProvisioningKeysResponseDataSchema + ) + } + } + } + } +}); + function querySiteProvisioningKeys(orgId: string) { return db .select({ - siteProvisioningKeyId: - siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId: siteProvisioningKeys.siteProvisioningKeyId, orgId: siteProvisioningKeyOrg.orgId, lastChars: siteProvisioningKeys.lastChars, createdAt: siteProvisioningKeys.createdAt, diff --git a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts index cee40212a..f97389b2b 100644 --- a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts @@ -13,11 +13,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { - db, - siteProvisioningKeyOrg, - siteProvisioningKeys -} from "@server/db"; +import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -25,6 +21,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import type { UpdateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; +import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; +import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.object({ siteProvisioningKeyId: z.string().nonempty(), @@ -38,7 +36,13 @@ const bodySchema = z z.null(), z.coerce.number().int().positive().max(1_000_000) ]) - .optional(), + .optional() + .openapi({ + type: "number", + default: null, + description: + "Maximum number of sites that can be provisioned in a single batch. If null, there is no limit." + }), validUntil: z.string().max(255).optional(), approveNewSites: z.boolean().optional() }) @@ -50,7 +54,8 @@ const bodySchema = z ) { ctx.addIssue({ code: "custom", - message: "Provide maxBatchSize and/or validUntil and/or approveNewSites", + message: + "Provide maxBatchSize and/or validUntil and/or approveNewSites", path: ["maxBatchSize"] }); } @@ -69,6 +74,48 @@ const bodySchema = z export type UpdateSiteProvisioningKeyBody = z.infer; +const UpdateSiteProvisioningKeyResponseDataSchema = z.object({ + siteProvisioningKeyId: z.string(), + orgId: z.string(), + name: z.string(), + lastChars: z.string(), + createdAt: z.string(), + lastUsed: z.string().nullable(), + maxBatchSize: z.number().nullable(), + numUsed: z.number(), + validUntil: z.string().nullable(), + approveNewSites: z.boolean() +}); + +registry.registerPath({ + method: "patch", + path: "/org/{orgId}/site-provisioning-key/{siteProvisioningKeyId}", + description: "Update a site provisioning key.", + tags: [OpenAPITags.SiteProvisioningKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: createApiResponseSchema( + UpdateSiteProvisioningKeyResponseDataSchema + ) + } + } + } + } +}); + export async function updateSiteProvisioningKey( req: Request, res: Response, diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 48a492c31..48868722e 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -143,6 +143,13 @@ function getActionsCategories(root: boolean) { Logs: { [t("actionExportLogs")]: "exportLogs", [t("actionViewLogs")]: "viewLogs" + }, + + "Site Provisioning Key": { + [t("actionCreateSiteProvisioningKey")]: "createSiteProvisioningKey", + [t("actionListSiteProvisioningKeys")]: "listSiteProvisioningKeys", + [t("actionUpdateSiteProvisioningKey")]: "updateSiteProvisioningKey", + [t("actionDeleteSiteProvisioningKey")]: "deleteSiteProvisioningKey" } }; From a30119f5e875604e050105fcb37a8aeb787e1d58 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 16 Jul 2026 21:28:36 -0400 Subject: [PATCH 062/494] encode email in url param --- server/routers/auth/requestPasswordReset.ts | 2 +- .../user/adminGeneratePasswordResetCode.ts | 2 +- server/routers/user/inviteUser.ts | 4 +-- src/components/InviteStatusCard.tsx | 28 +++++++++++++------ 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index 42b53d24d..aca435c7b 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -99,7 +99,7 @@ export async function requestPasswordReset( }); }); - const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`; + const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${encodeURIComponent(email)}&token=${token}`; if (!config.getRawConfig().email) { logger.info( diff --git a/server/routers/user/adminGeneratePasswordResetCode.ts b/server/routers/user/adminGeneratePasswordResetCode.ts index 562a459e1..7ba8ce9df 100644 --- a/server/routers/user/adminGeneratePasswordResetCode.ts +++ b/server/routers/user/adminGeneratePasswordResetCode.ts @@ -94,7 +94,7 @@ export async function adminGeneratePasswordResetCode( }); }); - const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${existingUser[0].email}&token=${token}`; + const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${encodeURIComponent(existingUser[0].email!)}&token=${token}`; logger.info( `Admin generated password reset code for user ${existingUser[0].email} (${userId})` diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 7445ee910..7b7ae9250 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -287,7 +287,7 @@ export async function inviteUser( ) ); - const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`; + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; if (doEmail) { await sendEmail( @@ -341,7 +341,7 @@ export async function inviteUser( .values(uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))); }); - const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`; + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; if (doEmail) { await sendEmail( diff --git a/src/components/InviteStatusCard.tsx b/src/components/InviteStatusCard.tsx index 40b7123e7..790249904 100644 --- a/src/components/InviteStatusCard.tsx +++ b/src/components/InviteStatusCard.tsx @@ -93,14 +93,20 @@ export default function InviteStatusCard({ setType(type); if (!user && type === "user_does_not_exist") { + const inviteRedirect = encodeURIComponent( + `/invite?token=${tokenParam}` + ); const redirectUrl = email - ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}` - : `/auth/signup?redirect=/invite?token=${tokenParam}`; + ? `/auth/signup?redirect=${inviteRedirect}&email=${encodeURIComponent(email)}` + : `/auth/signup?redirect=${inviteRedirect}`; router.push(redirectUrl); } else if (!user && type === "not_logged_in") { + const inviteRedirect = encodeURIComponent( + `/invite?token=${tokenParam}` + ); const redirectUrl = email - ? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}` - : `/auth/login?redirect=/invite?token=${tokenParam}`; + ? `/auth/login?redirect=${inviteRedirect}&user=${encodeURIComponent(email)}` + : `/auth/login?redirect=${inviteRedirect}`; router.push(redirectUrl); } else { setLoading(false); @@ -112,17 +118,23 @@ export default function InviteStatusCard({ async function goToLogin() { await api.post("/auth/logout", {}); + const inviteRedirect = encodeURIComponent( + `/invite?token=${tokenParam}` + ); const redirectUrl = email - ? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}` - : `/auth/login?redirect=/invite?token=${tokenParam}`; + ? `/auth/login?redirect=${inviteRedirect}&user=${encodeURIComponent(email)}` + : `/auth/login?redirect=${inviteRedirect}`; router.push(redirectUrl); } async function goToSignup() { await api.post("/auth/logout", {}); + const inviteRedirect = encodeURIComponent( + `/invite?token=${tokenParam}` + ); const redirectUrl = email - ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}` - : `/auth/signup?redirect=/invite?token=${tokenParam}`; + ? `/auth/signup?redirect=${inviteRedirect}&email=${encodeURIComponent(email)}` + : `/auth/signup?redirect=${inviteRedirect}`; router.push(redirectUrl); } From 9e50735800d6535f4b97eeb9432af89f91834005 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:29 -0400 Subject: [PATCH 063/494] New translations en-us.json (French) [ci skip] --- messages/fr-FR.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index ec24ec4d1..5a931fa70 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "Lister les invitations", "actionExportLogs": "Exporter les journaux", "actionViewLogs": "Voir les logs", + "actionCreateSiteProvisioningKey": "Créer une clé de provisionnement de site", + "actionListSiteProvisioningKeys": "Lister les clés de provisionnement de site", + "actionUpdateSiteProvisioningKey": "Mettre à jour la clé de provisionnement de site", + "actionDeleteSiteProvisioningKey": "Supprimer la clé de provisionnement de site", "noneSelected": "Aucune sélection", "orgNotFound2": "Aucune organisation trouvée.", "search": "Rechercher…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "Échec du téléchargement", "rdpUnicodeKeyboardMode": "Mode clavier Unicode", "sessionToolbarShow": "Afficher la barre d'outils", - "sessionToolbarHide": "Masquer la barre d'outils" + "sessionToolbarHide": "Masquer la barre d'outils", + "actionUpdateSiteApprovals": "Mettre à jour les approbations de site" } From 6df804ab3247c86667a0af8e4ae9c302a175f01e Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:31 -0400 Subject: [PATCH 064/494] New translations en-us.json (Spanish) [ci skip] --- messages/es-ES.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/es-ES.json b/messages/es-ES.json index d7453592a..e677252a0 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "Listar invitaciones", "actionExportLogs": "Exportar registros", "actionViewLogs": "Ver registros", + "actionCreateSiteProvisioningKey": "Crear clave de aprovisionamiento del sitio", + "actionListSiteProvisioningKeys": "Listado de claves de aprovisionamiento del sitio", + "actionUpdateSiteProvisioningKey": "Actualizar clave de aprovisionamiento del sitio", + "actionDeleteSiteProvisioningKey": "Eliminar clave de aprovisionamiento del sitio", "noneSelected": "Ninguno seleccionado", "orgNotFound2": "No se encontraron organizaciones.", "search": "Buscar…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "Error de subida", "rdpUnicodeKeyboardMode": "Modo teclado Unicode", "sessionToolbarShow": "Mostrar barra de herramientas", - "sessionToolbarHide": "Ocultar barra de herramientas" + "sessionToolbarHide": "Ocultar barra de herramientas", + "actionUpdateSiteApprovals": "Actualizar aprobaciones del sitio" } From f9bcb25eaaa3ffc274647d4289850a7ce085cefa Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:33 -0400 Subject: [PATCH 065/494] New translations en-us.json (Bulgarian) [ci skip] --- messages/bg-BG.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 25e192fec..4bf0d9ab6 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "Списък с покани", "actionExportLogs": "Експортиране на дневници", "actionViewLogs": "Преглед на дневници", + "actionCreateSiteProvisioningKey": "Създаване на ключ за предоставяне на сайт", + "actionListSiteProvisioningKeys": "Списък на ключовете за предоставяне на сайт", + "actionUpdateSiteProvisioningKey": "Актуализиране на ключ за предоставяне на сайт", + "actionDeleteSiteProvisioningKey": "Изтриване на ключ за предоставяне на сайт", "noneSelected": "Нищо не е избрано", "orgNotFound2": "Няма намерени организации.", "search": "Търси…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "Качването неуспешно", "rdpUnicodeKeyboardMode": "Режим на unicode клавиатура", "sessionToolbarShow": "Показване на лентата с инструменти", - "sessionToolbarHide": "Скриване на лентата с инструменти" + "sessionToolbarHide": "Скриване на лентата с инструменти", + "actionUpdateSiteApprovals": "Обновяване на одобренията на сайта" } From 61a7dda29a287b8e1b0c787b6bdcec6bad42ba49 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:35 -0400 Subject: [PATCH 066/494] New translations en-us.json (Czech) [ci skip] --- messages/cs-CZ.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index f440a8e38..0d96a80cd 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "Seznam pozvánek", "actionExportLogs": "Exportovat protokoly", "actionViewLogs": "Zobrazit logy", + "actionCreateSiteProvisioningKey": "Vytvořit klíč pro zřízení webu", + "actionListSiteProvisioningKeys": "Seznam klíčů pro zřízení webu", + "actionUpdateSiteProvisioningKey": "Aktualizovat klíč pro zřízení webu", + "actionDeleteSiteProvisioningKey": "Smazat klíč pro zřízení webu", "noneSelected": "Není vybráno", "orgNotFound2": "Nebyly nalezeny žádné organizace.", "search": "Vyhledávání…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "Nahrání selhalo", "rdpUnicodeKeyboardMode": "Režim Unicode klávesnice", "sessionToolbarShow": "Zobrazit panel nástrojů", - "sessionToolbarHide": "Skrýt panel nástrojů" + "sessionToolbarHide": "Skrýt panel nástrojů", + "actionUpdateSiteApprovals": "Aktualizovat schválení webu" } From 7853f2b096e2fa7ced4761de9f17f4dd0aa57e0e Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:36 -0400 Subject: [PATCH 067/494] New translations en-us.json (Danish) [ci skip] --- messages/da-DK.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/da-DK.json b/messages/da-DK.json index 2e1376264..f88ffa474 100644 --- a/messages/da-DK.json +++ b/messages/da-DK.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "Vis invitationer", "actionExportLogs": "Eksportér logs", "actionViewLogs": "Vis logs", + "actionCreateSiteProvisioningKey": "Opret provisioneringsnøgle til site", + "actionListSiteProvisioningKeys": "Vis provisioneringsnøgler til site", + "actionUpdateSiteProvisioningKey": "Opdater provisioneringsnøgle til site", + "actionDeleteSiteProvisioningKey": "Slet provisioneringsnøgle til site", "noneSelected": "Ingen valgt", "orgNotFound2": "Ingen organisationer fundet.", "search": "Søg…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "Uploaden mislykkedes", "rdpUnicodeKeyboardMode": "Unicode tastaturtilstand", "sessionToolbarShow": "Vis værktøjslinje", - "sessionToolbarHide": "Skjul værktøjslinje" + "sessionToolbarHide": "Skjul værktøjslinje", + "actionUpdateSiteApprovals": "Opdater godkendelser på site" } From 672555f05130322d31d90de2d8e3ab3e5945b213 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:38 -0400 Subject: [PATCH 068/494] New translations en-us.json (German) [ci skip] --- messages/de-DE.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 3ceac6dc4..615759bf1 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "Einladungen auflisten", "actionExportLogs": "Logs exportieren", "actionViewLogs": "Logs anzeigen", + "actionCreateSiteProvisioningKey": "Bereitstellungsschlüssel für Standort erstellen", + "actionListSiteProvisioningKeys": "Liste der Bereitstellungsschlüssel für Standorte", + "actionUpdateSiteProvisioningKey": "Bereitstellungsschlüssel für Standort aktualisieren", + "actionDeleteSiteProvisioningKey": "Bereitstellungsschlüssel für Standort löschen", "noneSelected": "Keine ausgewählt", "orgNotFound2": "Keine Organisationen gefunden.", "search": "Suche…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "Upload fehlgeschlagen", "rdpUnicodeKeyboardMode": "Unicode-Tastaturmodus", "sessionToolbarShow": "Werkzeugleiste zeigen", - "sessionToolbarHide": "Werkzeugleiste ausblenden" + "sessionToolbarHide": "Werkzeugleiste ausblenden", + "actionUpdateSiteApprovals": "Standortgenehmigungen aktualisieren" } From 714e3a8fb1af9bc4505713145318cf072fda9447 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:40 -0400 Subject: [PATCH 069/494] New translations en-us.json (Italian) [ci skip] --- messages/it-IT.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/it-IT.json b/messages/it-IT.json index 5edde2e2e..794eda30f 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "Elenco Inviti", "actionExportLogs": "Esporta Log", "actionViewLogs": "Visualizza Log", + "actionCreateSiteProvisioningKey": "Crea Chiave di Provisioning del Sito", + "actionListSiteProvisioningKeys": "Elenca Chiavi di Provisioning del Sito", + "actionUpdateSiteProvisioningKey": "Aggiorna Chiave di Provisioning del Sito", + "actionDeleteSiteProvisioningKey": "Elimina Chiave di Provisioning del Sito", "noneSelected": "Nessuna selezione", "orgNotFound2": "Nessuna organizzazione trovata.", "search": "Cerca…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "Caricamento fallito", "rdpUnicodeKeyboardMode": "Modalità tastiera Unicode", "sessionToolbarShow": "Mostra barra degli strumenti", - "sessionToolbarHide": "Nascondi barra degli strumenti" + "sessionToolbarHide": "Nascondi barra degli strumenti", + "actionUpdateSiteApprovals": "Aggiorna Approvazioni del Sito" } From 5f2ee77a73a6566b7ebde44f18f1dbba04e372c1 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:41 -0400 Subject: [PATCH 070/494] New translations en-us.json (Korean) [ci skip] --- messages/ko-KR.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 67c0ee59f..8505e50e0 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "초대 목록", "actionExportLogs": "로그 내보내기", "actionViewLogs": "로그 보기", + "actionCreateSiteProvisioningKey": "사이트 프로비저닝 키 생성", + "actionListSiteProvisioningKeys": "사이트 프로비저닝 키 목록", + "actionUpdateSiteProvisioningKey": "사이트 프로비저닝 키 업데이트", + "actionDeleteSiteProvisioningKey": "사이트 프로비저닝 키 삭제", "noneSelected": "선택된 항목 없음", "orgNotFound2": "조직이 없습니다.", "search": "검색…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "업로드 실패", "rdpUnicodeKeyboardMode": "유니코드 키보드 모드", "sessionToolbarShow": "툴바 보기", - "sessionToolbarHide": "툴바 숨기기" + "sessionToolbarHide": "툴바 숨기기", + "actionUpdateSiteApprovals": "사이트 승인 업데이트" } From 86670c1d6089d7806a9e196fe9f604b7e8d4c956 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:43 -0400 Subject: [PATCH 071/494] New translations en-us.json (Dutch) [ci skip] --- messages/nl-NL.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 9239cd81c..f914a3c80 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "Toon uitnodigingen", "actionExportLogs": "Logboeken exporteren", "actionViewLogs": "Logboeken bekijken", + "actionCreateSiteProvisioningKey": "Een sitevoorzie-ningssleutel aanmaken", + "actionListSiteProvisioningKeys": "Sitevoorzie-ningssleutels weergeven", + "actionUpdateSiteProvisioningKey": "Sitevoorzie-ningssleutel bijwerken", + "actionDeleteSiteProvisioningKey": "Sitevoorzie-ningssleutel verwijderen", "noneSelected": "Niet geselecteerd", "orgNotFound2": "Geen organisaties gevonden.", "search": "Zoeken…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "Upload mislukt", "rdpUnicodeKeyboardMode": "Unicode toetsenbordmodus", "sessionToolbarShow": "Toon werkbalk", - "sessionToolbarHide": "Verberg werkbalk" + "sessionToolbarHide": "Verberg werkbalk", + "actionUpdateSiteApprovals": "Sitegoedkeuringen bijwerken" } From 3d9c413bbb70acc12cb3246a609889caf3edaea9 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:45 -0400 Subject: [PATCH 072/494] New translations en-us.json (Polish) [ci skip] --- messages/pl-PL.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 11df1b353..1e998dcdd 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "Lista zaproszeń", "actionExportLogs": "Eksportuj dzienniki", "actionViewLogs": "Zobacz dzienniki", + "actionCreateSiteProvisioningKey": "Utwórz klucz konfiguracji strony", + "actionListSiteProvisioningKeys": "Lista kluczy konfiguracji strony", + "actionUpdateSiteProvisioningKey": "Zaktualizuj klucz konfiguracji strony", + "actionDeleteSiteProvisioningKey": "Usuń klucz konfiguracji strony", "noneSelected": "Nie wybrano", "orgNotFound2": "Nie znaleziono organizacji.", "search": "Szukaj…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "Niepowodzenie przesyłania", "rdpUnicodeKeyboardMode": "Tryb klawiatury Unicode", "sessionToolbarShow": "Pokaż pasek narzędzi", - "sessionToolbarHide": "Ukryj pasek narzędzi" + "sessionToolbarHide": "Ukryj pasek narzędzi", + "actionUpdateSiteApprovals": "Zaktualizuj zgody na stronę" } From 8e09070bc1c2383b250b5bbace3b2e22482ea2c1 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:46 -0400 Subject: [PATCH 073/494] New translations en-us.json (Portuguese) [ci skip] --- messages/pt-PT.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 4a37fb52b..9d3dd1320 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "Listar Convites", "actionExportLogs": "Exportar logs", "actionViewLogs": "Visualizar registros", + "actionCreateSiteProvisioningKey": "Criar Chave de Provisionamento do Site", + "actionListSiteProvisioningKeys": "Listar Chaves de Provisionamento do Site", + "actionUpdateSiteProvisioningKey": "Atualizar Chave de Provisionamento do Site", + "actionDeleteSiteProvisioningKey": "Excluir Chave de Provisionamento do Site", "noneSelected": "Nenhum selecionado", "orgNotFound2": "Nenhuma organização encontrada.", "search": "Pesquisar…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "Falha no upload", "rdpUnicodeKeyboardMode": "Modo de teclado Unicode", "sessionToolbarShow": "Mostrar barra de ferramentas", - "sessionToolbarHide": "Ocultar barra de ferramentas" + "sessionToolbarHide": "Ocultar barra de ferramentas", + "actionUpdateSiteApprovals": "Atualizar Aprovações do Site" } From e5b9dfee661ce5b844165cb86cfe225c062ca07f Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:48 -0400 Subject: [PATCH 074/494] New translations en-us.json (Russian) [ci skip] --- messages/ru-RU.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index efd5bee64..287e61497 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "Список приглашений", "actionExportLogs": "Экспорт журналов", "actionViewLogs": "Просмотр журналов", + "actionCreateSiteProvisioningKey": "Создать ключ конфигурации сайта", + "actionListSiteProvisioningKeys": "Список ключей конфигурации сайтов", + "actionUpdateSiteProvisioningKey": "Обновить ключ конфигурации сайта", + "actionDeleteSiteProvisioningKey": "Удалить ключ конфигурации сайта", "noneSelected": "Ничего не выбрано", "orgNotFound2": "Организации не найдены.", "search": "Поиск…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "Ошибка загрузки", "rdpUnicodeKeyboardMode": "Режим клавиатуры Unicode", "sessionToolbarShow": "Показать панель инструментов", - "sessionToolbarHide": "Скрыть панель инструментов" + "sessionToolbarHide": "Скрыть панель инструментов", + "actionUpdateSiteApprovals": "Обновить утверждения сайта" } From 50821e972d15c8711f6f0615b8df63e0fdd59654 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:50 -0400 Subject: [PATCH 075/494] New translations en-us.json (Turkish) [ci skip] --- messages/tr-TR.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 006180cc0..55f09d447 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "Davetiyeleri Listele", "actionExportLogs": "Kayıtları Dışa Aktar", "actionViewLogs": "Kayıtları Görüntüle", + "actionCreateSiteProvisioningKey": "Site Sağlama Anahtarı Oluştur", + "actionListSiteProvisioningKeys": "Site Sağlama Anahtarlarını Listele", + "actionUpdateSiteProvisioningKey": "Site Sağlama Anahtarını Güncelle", + "actionDeleteSiteProvisioningKey": "Site Sağlama Anahtarını Sil", "noneSelected": "Hiçbiri seçili değil", "orgNotFound2": "Hiçbir organizasyon bulunamadı.", "search": "Ara…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "Yükleme başarısız", "rdpUnicodeKeyboardMode": "Unicode klavye modu", "sessionToolbarShow": "Araç çubuğunu göster", - "sessionToolbarHide": "Araç çubuğunu gizle" + "sessionToolbarHide": "Araç çubuğunu gizle", + "actionUpdateSiteApprovals": "Site Onaylarını Güncelle" } From aa9de4d2acde5edbe61dc8e03efe6f84115211b4 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:52 -0400 Subject: [PATCH 076/494] New translations en-us.json (Chinese Simplified) [ci skip] --- messages/zh-CN.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index e5a680d99..2f9b8805c 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "邀请列表", "actionExportLogs": "导出日志", "actionViewLogs": "查看日志", + "actionCreateSiteProvisioningKey": "创建站点预配密钥", + "actionListSiteProvisioningKeys": "列出站点预配密钥", + "actionUpdateSiteProvisioningKey": "更新站点预配密钥", + "actionDeleteSiteProvisioningKey": "删除站点预配密钥", "noneSelected": "未选择", "orgNotFound2": "未找到组织。", "search": "搜索…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "上传失败", "rdpUnicodeKeyboardMode": "Unicode 键盘模式", "sessionToolbarShow": "显示工具栏", - "sessionToolbarHide": "隐藏工具栏" + "sessionToolbarHide": "隐藏工具栏", + "actionUpdateSiteApprovals": "更新站点审批" } From a5808200f0840224c6fe14081767423cc5b2ae6f Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 16 Jul 2026 21:34:54 -0400 Subject: [PATCH 077/494] New translations en-us.json (Norwegian Bokmal) [ci skip] --- messages/nb-NO.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 9bcca74db..8dd687d09 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "Liste invitasjoner", "actionExportLogs": "Eksportlogger", "actionViewLogs": "Vis logger", + "actionCreateSiteProvisioningKey": "Opprett Klargjøringsnøkkel for Sted", + "actionListSiteProvisioningKeys": "List Klargjøringsnøkler for Sted", + "actionUpdateSiteProvisioningKey": "Oppdater Klargjøringsnøkkel for Sted", + "actionDeleteSiteProvisioningKey": "Slett Klargjøringsnøkkel for Sted", "noneSelected": "Ingen valgt", "orgNotFound2": "Ingen organisasjoner funnet.", "search": "Søk…", @@ -3811,5 +3815,6 @@ "rdpUploadFailed": "Opplastningen mislyktes", "rdpUnicodeKeyboardMode": "Unicode tastaturmodus", "sessionToolbarShow": "Vis verktøylinje", - "sessionToolbarHide": "Skjul verktøylinje" + "sessionToolbarHide": "Skjul verktøylinje", + "actionUpdateSiteApprovals": "Oppdater Stedsgodkjenninger" } From 2d48adc9f9238c94152017156b5494245d7a7f71 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 16 Jul 2026 22:06:43 -0400 Subject: [PATCH 078/494] use proxy for logout on org auth page session expire --- server/routers/auth/logout.ts | 25 ++++++++++++--------- src/actions/server.ts | 33 ++++++++++++++++++++++++++++ src/components/OrgPolicyRequired.tsx | 27 +++++++++++++---------- 3 files changed, 63 insertions(+), 22 deletions(-) diff --git a/server/routers/auth/logout.ts b/server/routers/auth/logout.ts index b9a1431aa..b36d0c73a 100644 --- a/server/routers/auth/logout.ts +++ b/server/routers/auth/logout.ts @@ -16,18 +16,26 @@ export async function logout( next: NextFunction ): Promise { const { user, session } = await verifySession(req); + const isSecure = req.protocol === "https"; + + // Always clear the session cookie so logout is idempotent, even when + // the session is already missing or invalid + res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); + if (!user || !session) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( - `Log out failed because missing or invalid session. IP: ${req.ip}.` + `Log out with missing or invalid session. IP: ${req.ip}.` ); } - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "You must be logged in to sign out" - ) - ); + + return response(res, { + data: null, + success: true, + error: false, + message: "Logged out successfully", + status: HttpCode.OK + }); } try { @@ -37,9 +45,6 @@ export async function logout( logger.error("Failed to invalidate session", error); } - const isSecure = req.protocol === "https"; - res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); - return response(res, { data: null, success: true, diff --git a/src/actions/server.ts b/src/actions/server.ts index 2759e6213..cd75c2c57 100644 --- a/src/actions/server.ts +++ b/src/actions/server.ts @@ -248,6 +248,39 @@ export async function loginProxy( return await makeApiRequest(url, "POST", request); } +export async function logoutProxy(): Promise> { + const env = pullEnv(); + const serverPort = process.env.SERVER_EXTERNAL_PORT; + const url = `http://localhost:${serverPort}/api/v1/auth/logout`; + + const result = await makeApiRequest(url, "POST"); + + try { + const headersList = await reqHeaders(); + const host = headersList.get("host")?.split(":")[0]; + const allCookies = await cookies(); + const clearOptions = { + httpOnly: true, + secure: true, + sameSite: "lax" as const, + path: "/", + maxAge: 0 + }; + // Clear both host-only and domain-scoped variants. + allCookies.set(env.server.sessionCookieName, "", clearOptions); + if (host) { + allCookies.set(env.server.sessionCookieName, "", { + ...clearOptions, + domain: host + }); + } + } catch (cookieError) { + console.error("Failed to clear session cookie:", cookieError); + } + + return result; +} + export async function securityKeyStartProxy( request: SecurityKeyStartRequest, forceLogin?: boolean diff --git a/src/components/OrgPolicyRequired.tsx b/src/components/OrgPolicyRequired.tsx index 3765cd1bf..6d28ddbf5 100644 --- a/src/components/OrgPolicyRequired.tsx +++ b/src/components/OrgPolicyRequired.tsx @@ -12,8 +12,8 @@ import { Shield, ArrowRight } from "lucide-react"; import Link from "next/link"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useState } from "react"; +import { logoutProxy } from "@app/actions/server"; type OrgPolicyRequiredProps = { orgId: string; @@ -40,21 +40,23 @@ export default function OrgPolicyRequired({ }: OrgPolicyRequiredProps) { const t = useTranslations(); const router = useRouter(); - - const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); const sessionExpired = policies?.maxSessionLength && policies.maxSessionLength.compliant === false; - function reauthenticate() { - api.post("/auth/logout") - .catch(() => {}) - .then(() => { - const destination = redirectAfterAuth ?? `/${orgId}`; - router.push(destination); - router.refresh(); - }); + async function reauthenticate() { + setLoading(true); + try { + await logoutProxy(); + } catch (error) { + console.error("Error during logout:", error); + } finally { + const destination = redirectAfterAuth ?? `/${orgId}`; + router.push(destination); + router.refresh(); + } } if (sessionExpired) { @@ -76,6 +78,7 @@ export default function OrgPolicyRequired({ + + + + + +

+ {t( + "shareAssociateUserDescription" + )} +

+ +
@@ -437,6 +504,34 @@ export default function CreateShareLinkForm({
+
+ + setPersistSession( + val as boolean + ) + } + className="mt-0.5" + /> +
+ +

+ {t( + "sharePersistSessionDescription" + )} +

+
+
+

{t("shareExpireDescription")}

diff --git a/src/components/ShareLinksTable.tsx b/src/components/ShareLinksTable.tsx index 8394edf89..b4df1966f 100644 --- a/src/components/ShareLinksTable.tsx +++ b/src/components/ShareLinksTable.tsx @@ -35,6 +35,7 @@ import moment from "moment"; import CreateShareLinkForm from "@app/components/CreateShareLinkForm"; import { constructShareLink } from "@app/lib/shareLinks"; import { useTranslations } from "next-intl"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; export type ShareLinkRow = { accessTokenId: string; @@ -44,6 +45,10 @@ export type ShareLinkRow = { title: string | null; createdAt: number; expiresAt: number | null; + userId?: string | null; + userName?: string | null; + username?: string | null; + userEmail?: string | null; }; type ShareLinksTableProps = { @@ -155,6 +160,41 @@ export default function ShareLinksTable({ ); } }, + { + accessorKey: "userId", + friendlyName: t("user"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const r = row.original; + if (!r.userId) { + return -; + } + return ( + + + + ); + } + }, // { // accessorKey: "domain", // header: "Link", diff --git a/src/components/user-selector.tsx b/src/components/user-selector.tsx new file mode 100644 index 000000000..f8ad85eea --- /dev/null +++ b/src/components/user-selector.tsx @@ -0,0 +1,106 @@ +import { orgQueries } from "@app/lib/queries"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { useQuery } from "@tanstack/react-query"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "./ui/command"; +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { CheckIcon } from "lucide-react"; +import { cn } from "@app/lib/cn"; +import { useDebounce } from "use-debounce"; +import type { SelectedUser } from "./users-selector"; + +export type { SelectedUser }; + +export type UserSelectorProps = { + orgId: string; + selectedUser?: SelectedUser | null; + onSelectUser: (user: SelectedUser | null) => void; + allowClear?: boolean; +}; + +export function UserSelector({ + orgId, + selectedUser, + onSelectUser, + allowClear = true +}: UserSelectorProps) { + const t = useTranslations(); + const [userSearchQuery, setUserSearchQuery] = useState(""); + const [debouncedValue] = useDebounce(userSearchQuery, 150); + + const { data: users = [] } = useQuery( + orgQueries.users({ orgId, perPage: 10, query: debouncedValue }) + ); + + const usersShown = useMemo(() => { + const allUsers: Array = users.map((u) => ({ + id: u.id, + text: getUserDisplayName(u) + })); + if ( + debouncedValue.trim().length === 0 && + selectedUser && + !allUsers.find((user) => user.id === selectedUser.id) + ) { + allUsers.unshift(selectedUser); + } + return allUsers; + }, [users, selectedUser, debouncedValue]); + + return ( + + + + {t("usersNotFound")} + + {allowClear && ( + { + onSelectUser(null); + }} + > + + {t("none")} + + )} + {usersShown.map((user) => ( + { + onSelectUser(user); + }} + > + + {user.text} + + ))} + + + + ); +} From 6e8283957ce982eb477e9121d9ac6f002e6dfbdb Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 17 Jul 2026 14:06:35 -0400 Subject: [PATCH 081/494] update remote node routes to support access token updates --- messages/en-US.json | 2 +- server/private/routers/hybrid.ts | 319 ++++++++++++++++++++++++++++++- 2 files changed, 318 insertions(+), 3 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index bdd884d45..59b9f86d1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -203,7 +203,7 @@ "expireIn": "Expire In", "neverExpire": "Never expire", "sharePersistSession": "Persist session after first use", - "sharePersistSessionDescription": "When enabled, the first request with this token sets a session cookie so later requests do not need the token. Leave off for API clients that should send the token on every request.", + "sharePersistSessionDescription": "When enabled, the first request with this token via a query param or header sets a session cookie so later requests do not need the token. Leave off for API clients that should send the token on every request.", "shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.", "shareSeeOnce": "You will only be able to see this link once. Make sure to copy it.", "shareAccessHint": "Anyone with this link can access the resource. Share it with care.", diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 124db587a..06799bcd6 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -58,7 +58,8 @@ import { resourceRules, resourcePolicyRules, userOrgRoles, - roles + roles, + resourceAccessToken } from "@server/db"; import { eq, and, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm"; import { alias } from "@server/db"; @@ -81,11 +82,16 @@ import config from "@server/lib/config"; import { exchangeSession } from "@server/routers/badger"; import { ResourceSessionValidationResult, + createResourceSession, + serializeResourceSessionCookie, validateResourceSessionToken } from "@server/auth/sessions/resource"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; import { maxmindLookup } from "@server/db/maxmind"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; +import { generateSessionToken } from "@server/auth/sessions/app"; +import { logAccessAudit } from "#private/lib/logAccessAudit"; +import { getUserOrgRoles } from "@server/lib/userOrgRoles"; import semver from "semver"; import { maxmindAsnLookup } from "@server/db/maxmindAsn"; import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy"; @@ -178,6 +184,73 @@ const validateResourceAccessTokenBodySchema = z.strictObject({ accessToken: z.string() }); +const createAccessTokenSessionParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +const createAccessTokenSessionBodySchema = z.strictObject({ + accessTokenId: z.string().min(1) +}); + +const getAccessTokenParamsSchema = z.strictObject({ + accessTokenId: z.string().min(1) +}); + +const logAccessAuditBodySchema = z.strictObject({ + action: z.boolean(), + type: z.string(), + orgId: z.string(), + resourceId: z.number().optional(), + siteResourceId: z.number().optional(), + user: z + .object({ + username: z.string(), + userId: z.string() + }) + .optional(), + apiKey: z + .object({ + name: z.string().nullable(), + apiKeyId: z.string() + }) + .optional(), + metadata: z.any().optional(), + userAgent: z.string().optional(), + requestIp: z.string().optional() +}); + +type AccessTokenUserData = { + userId: string; + username: string; + email: string | null; + name: string | null; + role: string | null; +}; + +async function resolveAccessTokenUserData( + userId: string, + orgId: string +): Promise { + const [user] = await db + .select() + .from(users) + .where(eq(users.userId, userId)) + .limit(1); + + if (!user) { + return undefined; + } + + const userOrgRoles = await getUserOrgRoles(user.userId, orgId); + return { + userId: user.userId, + username: user.username, + email: user.email, + name: user.name, + role: userOrgRoles.map((r) => r.roleName).join(", ") || null + }; +} + // Certificates by domains query validation const getCertificatesByDomainsQuerySchema = z.strictObject({ // Accept domains as string or array (domains or domains[]) @@ -1829,8 +1902,23 @@ hybridRouter.post( resourceId }); + let userData: AccessTokenUserData | undefined; + if ( + result.valid && + result.tokenItem?.userId && + result.tokenItem.orgId + ) { + userData = await resolveAccessTokenUserData( + result.tokenItem.userId, + result.tokenItem.orgId + ); + } + return response(res, { - data: result, + data: { + ...result, + userData + }, success: true, error: false, message: result.valid @@ -1850,6 +1938,233 @@ hybridRouter.post( } ); +// Create a resource session from a valid access token (for remote nodes) +hybridRouter.post( + "/resource/:resourceId/session/create-access-token", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = createAccessTokenSessionParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = createAccessTokenSessionBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const { accessTokenId } = parsedBody.data; + + const [tokenItem] = await db + .select() + .from(resourceAccessToken) + .where(eq(resourceAccessToken.accessTokenId, accessTokenId)) + .limit(1); + + if (!tokenItem || tokenItem.resourceId !== resourceId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Access token not found" + ) + ); + } + + if (!tokenItem.persistSession) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Access token does not allow session persistence" + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource || !resource.fullDomain) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const token = generateSessionToken(); + const sess = await createResourceSession({ + resourceId: resource.resourceId, + token, + accessTokenId: tokenItem.accessTokenId, + sessionLength: tokenItem.sessionLength, + expiresAt: tokenItem.expiresAt, + doNotExtend: tokenItem.expiresAt ? true : false + }); + + const cookieName = config.getRawConfig().server.session_cookie_name; + const cookie = serializeResourceSessionCookie( + cookieName, + resource.fullDomain, + token, + !resource.ssl, + new Date(sess.expiresAt) + ); + + return response(res, { + data: { cookie }, + success: true, + error: false, + message: "Access token session created successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create access token session" + ) + ); + } + } +); + +// Resolve access token metadata for remote nodes (cookie session path) +hybridRouter.get( + "/resource/access-token/:accessTokenId", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getAccessTokenParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { accessTokenId } = parsedParams.data; + + const [tokenItem] = await db + .select() + .from(resourceAccessToken) + .where(eq(resourceAccessToken.accessTokenId, accessTokenId)) + .limit(1); + + if (!tokenItem) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Access token not found" + ) + ); + } + + let userData: AccessTokenUserData | undefined; + if (tokenItem.userId) { + userData = await resolveAccessTokenUserData( + tokenItem.userId, + tokenItem.orgId + ); + } + + return response(res, { + data: { tokenItem, userData }, + success: true, + error: false, + message: "Access token retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get access token" + ) + ); + } + } +); + +// Access audit log from remote nodes +hybridRouter.post( + "/logs/access", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedBody = logAccessAuditBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const remoteExitNode = req.remoteExitNode; + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if ( + await checkExitNodeOrg( + remoteExitNode.exitNodeId, + parsedBody.data.orgId + ) + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + await logAccessAudit(parsedBody.data); + + return response(res, { + data: null, + success: true, + error: false, + message: "Access audit log saved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to save access audit log" + ) + ); + } + } +); + const geoIpLookupParamsSchema = z.object({ ip: z.union([z.ipv4(), z.ipv6()]) }); From eb3a3eac98b680a72c04bf3358e1d5f096cedeac Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 17 Jul 2026 15:58:13 -0400 Subject: [PATCH 082/494] add 1.21 migrations --- server/lib/consts.ts | 2 +- server/setup/migrationsPg.ts | 4 +- server/setup/migrationsSqlite.ts | 4 +- server/setup/scriptsPg/1.21.0.ts | 46 ++++++++++++++++++++++ server/setup/scriptsSqlite/1.21.0.ts | 57 ++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 server/setup/scriptsPg/1.21.0.ts create mode 100644 server/setup/scriptsSqlite/1.21.0.ts diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 57a4836b8..9ee4ec4c3 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.20.0"; +export const APP_VERSION = "1.21.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index a271ef96d..4a6d4861c 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -27,6 +27,7 @@ import m18 from "./scriptsPg/1.18.3"; import m19 from "./scriptsPg/1.18.4"; import m20 from "./scriptsPg/1.19.0"; import m21 from "./scriptsPg/1.20.0"; +import m22 from "./scriptsPg/1.21.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -53,7 +54,8 @@ const migrations = [ { version: "1.18.3", run: m18 }, { version: "1.18.4", run: m19 }, { version: "1.19.0", run: m20 }, - { version: "1.20.0", run: m21 } + { version: "1.20.0", run: m21 }, + { version: "1.21.0", run: m22 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index a8f6f9e33..c6b4debef 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -46,6 +46,7 @@ import m40 from "./scriptsSqlite/1.18.4"; import m41 from "./scriptsSqlite/1.19.0"; import m42 from "./scriptsSqlite/1.19.1"; import m43 from "./scriptsSqlite/1.20.0"; +import m44 from "./scriptsSqlite/1.21.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -89,7 +90,8 @@ const migrations = [ { version: "1.18.4", run: m40 }, { version: "1.19.0", run: m41 }, { version: "1.19.1", run: m42 }, - { version: "1.20.0", run: m43 } + { version: "1.20.0", run: m43 }, + { version: "1.21.0", run: m44 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.21.0.ts b/server/setup/scriptsPg/1.21.0.ts new file mode 100644 index 000000000..3a6538f8c --- /dev/null +++ b/server/setup/scriptsPg/1.21.0.ts @@ -0,0 +1,46 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.21.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + ALTER TABLE "resourceAccessToken" ADD COLUMN "userId" varchar; + `); + + await db.execute(sql` + ALTER TABLE "resourceAccessToken" ADD COLUMN "persistSession" boolean DEFAULT false NOT NULL; + `); + + await db.execute(sql` + ALTER TABLE "resources" ADD COLUMN "status" varchar DEFAULT 'approved'; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "status" varchar DEFAULT 'approved'; + `); + + await db.execute(sql` + ALTER TABLE "sites" ADD COLUMN "localEndpoints" varchar; + `); + + await db.execute(sql` + ALTER TABLE "resourceAccessToken" ADD CONSTRAINT "resourceAccessToken_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.21.0.ts b/server/setup/scriptsSqlite/1.21.0.ts new file mode 100644 index 000000000..8fb635044 --- /dev/null +++ b/server/setup/scriptsSqlite/1.21.0.ts @@ -0,0 +1,57 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.21.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + db.prepare( + ` + ALTER TABLE 'resourceAccessToken' ADD 'userId' text REFERENCES user(id); + ` + ).run(); + + db.prepare( + ` + ALTER TABLE 'resourceAccessToken' ADD 'persistSession' integer DEFAULT false NOT NULL; + ` + ).run(); + + db.prepare( + ` + ALTER TABLE 'resources' ADD 'status' text DEFAULT 'approved'; + ` + ).run(); + + db.prepare( + ` + ALTER TABLE 'siteResources' ADD 'status' text DEFAULT 'approved'; + ` + ).run(); + + db.prepare( + ` + ALTER TABLE 'sites' ADD 'localEndpoints' text; + ` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + console.log("Migrated database"); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} From a0b9ea76a3ee5495511f76c3640f95d7da0651b3 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 17 Jul 2026 16:22:15 -0400 Subject: [PATCH 083/494] add badger to migration --- server/setup/scriptsPg/1.21.0.ts | 49 ++++++++++++++++++++++++++++ server/setup/scriptsSqlite/1.21.0.ts | 47 ++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/server/setup/scriptsPg/1.21.0.ts b/server/setup/scriptsPg/1.21.0.ts index 3a6538f8c..d5195c382 100644 --- a/server/setup/scriptsPg/1.21.0.ts +++ b/server/setup/scriptsPg/1.21.0.ts @@ -1,5 +1,11 @@ import { db } from "@server/db/pg/driver"; +import { APP_PATH } from "@server/lib/consts"; import { sql } from "drizzle-orm"; +import fs from "fs"; +import yaml from "js-yaml"; +import path from "path"; +import z from "zod"; +import { fromZodError } from "zod-validation-error"; const version = "1.21.0"; @@ -42,5 +48,48 @@ export default async function migration() { throw e; } + try { + const traefikPath = path.join( + APP_PATH, + "traefik", + "traefik_config.yml" + ); + + const schema = z.object({ + experimental: z.object({ + plugins: z.object({ + badger: z.object({ + moduleName: z.string(), + version: z.string() + }) + }) + }) + }); + + const traefikFileContents = fs.readFileSync(traefikPath, "utf8"); + const traefikConfig = yaml.load(traefikFileContents) as any; + + const parsedConfig = schema.safeParse(traefikConfig); + + if (!parsedConfig.success) { + throw new Error(fromZodError(parsedConfig.error).toString()); + } + + traefikConfig.experimental.plugins.badger.version = "v1.5.0"; + + const updatedTraefikYaml = yaml.dump(traefikConfig); + + fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); + + console.log( + "Updated the version of Badger in your Traefik configuration to v1.5.0" + ); + } catch (e) { + console.log( + "We were unable to update the version of Badger in your Traefik configuration. Please update it manually. Check the release notes for this version for more information." + ); + console.error(e); + } + console.log(`${version} migration complete`); } diff --git a/server/setup/scriptsSqlite/1.21.0.ts b/server/setup/scriptsSqlite/1.21.0.ts index 8fb635044..b710c783b 100644 --- a/server/setup/scriptsSqlite/1.21.0.ts +++ b/server/setup/scriptsSqlite/1.21.0.ts @@ -1,6 +1,10 @@ import { APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; +import fs from "fs"; +import yaml from "js-yaml"; import path from "path"; +import z from "zod"; +import { fromZodError } from "zod-validation-error"; const version = "1.21.0"; @@ -53,5 +57,48 @@ export default async function migration() { throw e; } + try { + const traefikPath = path.join( + APP_PATH, + "traefik", + "traefik_config.yml" + ); + + const schema = z.object({ + experimental: z.object({ + plugins: z.object({ + badger: z.object({ + moduleName: z.string(), + version: z.string() + }) + }) + }) + }); + + const traefikFileContents = fs.readFileSync(traefikPath, "utf8"); + const traefikConfig = yaml.load(traefikFileContents) as any; + + const parsedConfig = schema.safeParse(traefikConfig); + + if (!parsedConfig.success) { + throw new Error(fromZodError(parsedConfig.error).toString()); + } + + traefikConfig.experimental.plugins.badger.version = "v1.5.0"; + + const updatedTraefikYaml = yaml.dump(traefikConfig); + + fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); + + console.log( + "Updated the version of Badger in your Traefik configuration to v1.5.0" + ); + } catch (e) { + console.log( + "We were unable to update the version of Badger in your Traefik configuration. Please update it manually. Check the release notes for this version for more information." + ); + console.error(e); + } + console.log(`${version} migration complete`); } From 02fe1f3abdff200a1167f9bd99376f67891231f4 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 18 Jul 2026 11:53:38 -0400 Subject: [PATCH 084/494] Claify that this is only for the cloud --- .../emails/templates/NotifyTrialExpiring.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/server/emails/templates/NotifyTrialExpiring.tsx b/server/emails/templates/NotifyTrialExpiring.tsx index 7c712e278..a4c09e152 100644 --- a/server/emails/templates/NotifyTrialExpiring.tsx +++ b/server/emails/templates/NotifyTrialExpiring.tsx @@ -30,14 +30,14 @@ export const NotifyTrialExpiring = ({ const isLastDay = daysRemaining === 1; const previewText = hasEnded - ? `Your trial for ${orgName} has ended.` + ? `Your cloud trial for ${orgName} has ended.` : isLastDay - ? `Your trial for ${orgName} ends tomorrow.` - : `Your trial for ${orgName} ends in ${daysRemaining} days.`; + ? `Your cloud trial for ${orgName} ends tomorrow.` + : `Your cloud trial for ${orgName} ends in ${daysRemaining} days.`; const heading = hasEnded - ? "Your Trial Ended" - : "Your Trial is Ending Soon"; + ? "Your Cloud Trial Ended" + : "Your Cloud Trial is Ending Soon"; return ( @@ -55,7 +55,7 @@ export const NotifyTrialExpiring = ({ {hasEnded ? ( <> - Your free trial for{" "} + Your cloud free trial for{" "} {orgName} ended on{" "} {trialEndsAt}. Your account has been moved to the free plan, which @@ -64,10 +64,11 @@ export const NotifyTrialExpiring = ({ Some features and resources may now be - restricted. To restore full - access and continue using all the features - you had during your trial, please upgrade to - a paid plan. + restricted. To restore full access and + continue using all the features you had + during your trial, please upgrade to a paid + plan. This does not effect any self hosted + licenses. @@ -93,7 +94,8 @@ export const NotifyTrialExpiring = ({ After your trial ends, your account will be moved to the free plan and some - functionality may be restricted. + functionality may be restricted. This does + not effect any self hosted licenses. From 9e2ec72ced565578ccc4f916bc4917871adfa97e Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 18 Jul 2026 16:15:30 -0400 Subject: [PATCH 085/494] Fix missing resource id in cache --- server/routers/badger/verifySession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 901c881f2..33c75b101 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -418,7 +418,7 @@ export async function verifyResourceSession( } // check for HTTP Basic Auth header - const clientHeaderAuthKey = `headerAuth:${clientHeaderAuth}`; + const clientHeaderAuthKey = `headerAuth:${resource.resourceId}:${clientHeaderAuth}`; if (headerAuth && clientHeaderAuth) { if (localCache.get(clientHeaderAuthKey)) { logger.debug( From a2e1c7b751f660983ac46641ad8a74a9d8c418f2 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 18 Jul 2026 16:30:29 -0400 Subject: [PATCH 086/494] Go to domains if no domains --- messages/en-US.json | 3 +++ src/components/DomainPicker.tsx | 26 +++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index 59b9f86d1..2ca25d850 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1995,6 +1995,9 @@ "domainPickerSubdomain": "Subdomain: {subdomain}", "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Show More", + "domainPickerNoDomainsAvailableTitle": "No domains available", + "domainPickerNoDomainsAvailableDescription": "You don't have any domains set up yet. Create a domain to continue.", + "domainPickerNoDomainsAvailableAction": "Go to Domains", "regionSelectorTitle": "Select Region", "domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.", "regionSelectorInfo": "Selecting a region helps us provide better performance for your location. You do not have to be in the same region as your server.", diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index daacf892b..e06c3e57d 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -1,6 +1,6 @@ "use client"; -import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { @@ -43,10 +43,12 @@ import { CheckCircle2, ChevronsUpDown, ExternalLink, + Globe, KeyRound, Zap } from "lucide-react"; import { useTranslations } from "next-intl"; +import Link from "next/link"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@/hooks/usePaidStatus"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -494,6 +496,28 @@ export default function DomainPicker({ const hasMoreProvided = sortedAvailableOptions.length > providedDomainsShown; + const noDomainsAvailable = + !loadingDomains && + organizationDomains.length === 0 && + (build === "oss" || hideFreeDomain || requiresPaywall); + + if (noDomainsAvailable) { + return ( + + + {t("domainPickerNoDomainsAvailableTitle")} + +

{t("domainPickerNoDomainsAvailableDescription")}

+ +
+
+ ); + } + return (
From 0275f44056f215dc7cd983c7d441f0e18aeda4a8 Mon Sep 17 00:00:00 2001 From: Shubham Singh Date: Sun, 19 Jul 2026 13:45:33 +0530 Subject: [PATCH 087/494] Fix Punycode domain validation --- server/lib/validators.test.ts | 19 +++++++++++++++++++ server/lib/validators.ts | 5 +++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/server/lib/validators.test.ts b/server/lib/validators.test.ts index ac95184a5..5ce95f45c 100644 --- a/server/lib/validators.test.ts +++ b/server/lib/validators.test.ts @@ -1,10 +1,29 @@ import { getResourceRuleValueValidationError, + isValidDomain, isValidUrlGlobPattern } from "./validators"; import { assertEquals } from "@test/assert"; function runTests() { + console.log("Running domain validation tests..."); + + assertEquals( + isValidDomain("example.com"), + true, + "Standard ASCII domain should be valid" + ); + assertEquals( + isValidDomain("xn--e1afmkfd.xn--p1ai"), + true, + "Punycode IDN domain should be valid" + ); + assertEquals( + isValidDomain("example.invalid-tld"), + false, + "Domain with unknown TLD should be invalid" + ); + console.log("Running URL pattern validation tests..."); // Test valid patterns diff --git a/server/lib/validators.ts b/server/lib/validators.ts index ff0c5fb3d..872ced221 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -171,9 +171,10 @@ export function isValidDomain(domain: string): boolean { if (!/^[a-zA-Z0-9-]+$/.test(label)) return false; } - // TLD should be at least 2 characters and contain only letters + // TLD should be at least 2 characters. Punycode TLDs can contain digits + // and hyphens, so validity is ultimately enforced by the TLD allowlist. const tld = labels[labels.length - 1]; - if (tld.length < 2 || !/^[a-zA-Z]+$/.test(tld)) return false; + if (tld.length < 2) return false; // Check if TLD is in the list of valid TLDs if (!validTlds.includes(tld.toUpperCase())) return false; From 9561d23f1e77cdea5d69db820639c0ebc62f03e1 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 19 Jul 2026 14:41:07 -0400 Subject: [PATCH 088/494] Resovle endcoding issue --- server/lib/pathMatch.ts | 37 +++++++++++++++++++-- server/routers/badger/verifySession.test.ts | 32 ++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/server/lib/pathMatch.ts b/server/lib/pathMatch.ts index a007f9ec3..8ef579607 100644 --- a/server/lib/pathMatch.ts +++ b/server/lib/pathMatch.ts @@ -15,10 +15,41 @@ function getSegmentRegex(patternPart: string): RegExp { return regex; } +// Decodes percent-encoding (so an encoded slash like `%2F` is treated as a +// real path separator, matching what most backends will do) and then +// resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/` +// or `/public/../admin/` is matched as `/admin/`, not as a literal segment +// or a wildcard-swallowed sequence under `/public/*`. +function decodeAndResolvePath(p: string): string[] { + const rawParts = p.split("/").filter(Boolean); + + const resolved: string[] = []; + for (const rawPart of rawParts) { + let part: string; + try { + part = decodeURIComponent(rawPart); + } catch { + part = rawPart; + } + + // an encoded slash can turn one raw segment into several real ones + for (const segment of part.split("/").filter(Boolean)) { + if (segment === ".") { + continue; + } else if (segment === "..") { + resolved.pop(); + } else { + resolved.push(segment); + } + } + } + + return resolved; +} + export function isPathAllowed(pattern: string, path: string): boolean { - const normalize = (p: string) => p.split("/").filter(Boolean); - const patternParts = normalize(pattern); - const pathParts = normalize(path); + const patternParts = pattern.split("/").filter(Boolean); + const pathParts = decodeAndResolvePath(path); function matchSegments( patternIndex: number, diff --git a/server/routers/badger/verifySession.test.ts b/server/routers/badger/verifySession.test.ts index 681717f9c..c91805f8e 100644 --- a/server/routers/badger/verifySession.test.ts +++ b/server/routers/badger/verifySession.test.ts @@ -236,6 +236,38 @@ function runTests() { "Root path should not match non-root path" ); + // Path traversal / encoded-slash bypass regression tests + assertEquals( + isPathAllowed("public/*", "public/../admin"), + false, + "Literal .. traversal out of an allowed prefix must not match" + ); + assertEquals( + isPathAllowed("public/*", "public%2F..%2Fadmin"), + false, + "Encoded-slash traversal out of an allowed prefix must not match" + ); + assertEquals( + isPathAllowed("public/*", "public%2f..%2fadmin%2ffile"), + false, + "Encoded-slash traversal is case-insensitively decoded before matching" + ); + assertEquals( + isPathAllowed("admin/*", "public/../admin/secret"), + true, + ".. traversal INTO a restricted path should still be caught by its own rule" + ); + assertEquals( + isPathAllowed("public/*", "public%2Ffoo"), + true, + "Encoded slash without traversal should still resolve and match normally" + ); + assertEquals( + isPathAllowed("public/*", "public/./foo"), + true, + "Single-dot segments are a no-op and should not affect matching" + ); + console.log("All path matching tests passed!"); } From 4d8cb7e2311f3bf4987bc40b1bb15dab652bcc58 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Jul 2026 20:23:19 -0400 Subject: [PATCH 089/494] Add aliaes for public/private resources --- .../accessToken/generateAccessToken.ts | 33 +++ .../routers/accessToken/listAccessTokens.ts | 29 +++ server/routers/integration.ts | 223 +++++++++++++----- server/routers/policy/getResourcePolicy.ts | 13 + .../policy/setResourcePolicyAccessControl.ts | 19 ++ .../policy/setResourcePolicyHeaderAuth.ts | 19 ++ .../policy/setResourcePolicyPassword.ts | 19 ++ .../policy/setResourcePolicyPincode.ts | 19 ++ .../routers/policy/setResourcePolicyRules.ts | 19 ++ .../policy/setResourcePolicyWhitelist.ts | 19 ++ server/routers/policy/updateResourcePolicy.ts | 18 ++ .../resource/addEmailToResourceWhitelist.ts | 33 +++ server/routers/resource/addRoleToResource.ts | 34 +++ server/routers/resource/addUserToResource.ts | 34 +++ server/routers/resource/createResource.ts | 33 +++ server/routers/resource/createResourceRule.ts | 33 +++ server/routers/resource/deleteResource.ts | 26 ++ server/routers/resource/deleteResourceRule.ts | 26 ++ server/routers/resource/getResource.ts | 28 +++ .../routers/resource/getResourcePolicies.ts | 11 + .../routers/resource/getResourceWhitelist.ts | 26 ++ .../routers/resource/listAllResourceNames.ts | 28 +++ server/routers/resource/listResourceRoles.ts | 26 ++ server/routers/resource/listResourceRules.ts | 27 +++ server/routers/resource/listResourceUsers.ts | 26 ++ server/routers/resource/listResources.ts | 29 +++ .../removeEmailFromResourceWhitelist.ts | 33 +++ .../resource/removeRoleFromResource.ts | 33 +++ .../resource/removeUserFromResource.ts | 33 +++ .../routers/resource/setResourceHeaderAuth.ts | 34 +++ .../routers/resource/setResourcePassword.ts | 34 +++ server/routers/resource/setResourcePincode.ts | 34 +++ server/routers/resource/setResourceRoles.ts | 34 +++ server/routers/resource/setResourceUsers.ts | 34 +++ .../routers/resource/setResourceWhitelist.ts | 34 +++ server/routers/resource/updateResource.ts | 36 +++ server/routers/resource/updateResourceRule.ts | 33 +++ .../siteResource/addClientToSiteResource.ts | 34 +++ .../siteResource/addRoleToSiteResource.ts | 33 +++ .../siteResource/addUserToSiteResource.ts | 33 +++ .../batchAddClientToSiteResources.ts | 33 +++ .../siteResource/createSiteResource.ts | 33 +++ .../siteResource/deleteSiteResource.ts | 26 ++ .../routers/siteResource/getSiteResource.ts | 30 +++ .../siteResource/listAllSiteResourcesByOrg.ts | 27 +++ .../siteResource/listSiteResourceClients.ts | 26 ++ .../siteResource/listSiteResourceRoles.ts | 26 ++ .../siteResource/listSiteResourceUsers.ts | 26 ++ .../routers/siteResource/listSiteResources.ts | 27 +++ .../removeClientFromSiteResource.ts | 34 +++ .../removeRoleFromSiteResource.ts | 33 +++ .../removeUserFromSiteResource.ts | 33 +++ .../siteResource/setSiteResourceClients.ts | 34 +++ .../siteResource/setSiteResourceRoles.ts | 34 +++ .../siteResource/setSiteResourceUsers.ts | 34 +++ .../siteResource/updateSiteResource.ts | 33 +++ server/routers/target/createTarget.ts | 33 +++ server/routers/target/listTargets.ts | 27 +++ 58 files changed, 1812 insertions(+), 59 deletions(-) diff --git a/server/routers/accessToken/generateAccessToken.ts b/server/routers/accessToken/generateAccessToken.ts index 5b259af70..c46e94162 100644 --- a/server/routers/accessToken/generateAccessToken.ts +++ b/server/routers/accessToken/generateAccessToken.ts @@ -74,6 +74,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/access-token", + description: "Generate a new access token for a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.AccessToken], + request: { + params: generateAccssTokenParamsSchema, + body: { + content: { + "application/json": { + schema: generateAccessTokenBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function generateAccessToken( req: Request, res: Response, diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index a5ec3b540..0068e459a 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -184,6 +184,35 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/access-tokens", + description: "List all access tokens for a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.AccessToken], + request: { + params: z.object({ + resourceId: z.number() + }), + query: listAccessTokensSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function listAccessTokens( req: Request, res: Response, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 567c3060e..d16efdb63 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -163,7 +163,7 @@ authenticated.get( // Site Resource endpoints authenticated.put( - "/org/:orgId/site-resource", + ["/org/:orgId/site-resource", "/org/:orgId/private-resource"], verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createSiteResource), @@ -172,7 +172,10 @@ authenticated.put( ); authenticated.get( - "/org/:orgId/site/:siteId/resources", + [ + "/org/:orgId/site/:siteId/resources", + "/org/:orgId/site/:siteId/private-resources" + ], verifyApiKeyOrgAccess, verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.listSiteResources), @@ -180,21 +183,21 @@ authenticated.get( ); authenticated.get( - "/org/:orgId/site-resources", + ["/org/:orgId/site-resources", "/org/:orgId/private-resources"], verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listSiteResources), siteResource.listAllSiteResourcesByOrg ); authenticated.get( - "/site-resource/:siteResourceId", + ["/site-resource/:siteResourceId", "/private-resource/:siteResourceId"], verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.getSiteResource), siteResource.getSiteResource ); authenticated.post( - "/site-resource/:siteResourceId", + ["/site-resource/:siteResourceId", "/private-resource/:siteResourceId"], verifyApiKeySiteResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateSiteResource), @@ -203,7 +206,7 @@ authenticated.post( ); authenticated.delete( - "/site-resource/:siteResourceId", + ["/site-resource/:siteResourceId", "/private-resource/:siteResourceId"], verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteSiteResource), logActionAudit(ActionsEnum.deleteSiteResource), @@ -211,28 +214,40 @@ authenticated.delete( ); authenticated.get( - "/site-resource/:siteResourceId/roles", + [ + "/site-resource/:siteResourceId/roles", + "/private-resource/:siteResourceId/roles" + ], verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceRoles), siteResource.listSiteResourceRoles ); authenticated.get( - "/site-resource/:siteResourceId/users", + [ + "/site-resource/:siteResourceId/users", + "/private-resource/:siteResourceId/users" + ], verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceUsers), siteResource.listSiteResourceUsers ); authenticated.get( - "/site-resource/:siteResourceId/clients", + [ + "/site-resource/:siteResourceId/clients", + "/private-resource/:siteResourceId/clients" + ], verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceUsers), siteResource.listSiteResourceClients ); authenticated.post( - "/site-resource/:siteResourceId/roles", + [ + "/site-resource/:siteResourceId/roles", + "/private-resource/:siteResourceId/roles" + ], verifyApiKeySiteResourceAccess, verifyApiKeyRoleAccess, verifyLimits, @@ -242,7 +257,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/users", + [ + "/site-resource/:siteResourceId/users", + "/private-resource/:siteResourceId/users" + ], verifyApiKeySiteResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, @@ -252,7 +270,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/roles/add", + [ + "/site-resource/:siteResourceId/roles/add", + "/private-resource/:siteResourceId/roles/add" + ], verifyApiKeySiteResourceAccess, verifyApiKeyRoleAccess, verifyLimits, @@ -262,7 +283,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/roles/remove", + [ + "/site-resource/:siteResourceId/roles/remove", + "/private-resource/:siteResourceId/roles/remove" + ], verifyApiKeySiteResourceAccess, verifyApiKeyRoleAccess, verifyLimits, @@ -272,7 +296,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/users/add", + [ + "/site-resource/:siteResourceId/users/add", + "/private-resource/:siteResourceId/users/add" + ], verifyApiKeySiteResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, @@ -282,7 +309,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/users/remove", + [ + "/site-resource/:siteResourceId/users/remove", + "/private-resource/:siteResourceId/users/remove" + ], verifyApiKeySiteResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, @@ -292,7 +322,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/clients", + [ + "/site-resource/:siteResourceId/clients", + "/private-resource/:siteResourceId/clients" + ], verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, verifyLimits, @@ -302,7 +335,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/clients/add", + [ + "/site-resource/:siteResourceId/clients/add", + "/private-resource/:siteResourceId/clients/add" + ], verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, verifyLimits, @@ -312,7 +348,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/clients/remove", + [ + "/site-resource/:siteResourceId/clients/remove", + "/private-resource/:siteResourceId/clients/remove" + ], verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, verifyLimits, @@ -322,7 +361,7 @@ authenticated.post( ); authenticated.post( - "/client/:clientId/site-resources", + ["/client/:clientId/site-resources", "/client/:clientId/private-resources"], verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), @@ -330,7 +369,7 @@ authenticated.post( ); authenticated.put( - "/org/:orgId/resource", + ["/org/:orgId/resource", "/org/:orgId/public-resource"], verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createResource), @@ -339,7 +378,10 @@ authenticated.put( ); authenticated.put( - "/org/:orgId/site/:siteId/resource", + [ + "/org/:orgId/site/:siteId/resource", + "/org/:orgId/site/:siteId/public-resource" + ], verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createResource), @@ -348,14 +390,14 @@ authenticated.put( ); authenticated.get( - "/site/:siteId/resources", + ["/site/:siteId/resources", "/site/:siteId/public-resources"], verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.listResources), resource.listResources ); authenticated.get( - "/org/:orgId/resources", + ["/org/:orgId/resources", "/org/:orgId/public-resources"], verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listResources), resource.listResources @@ -443,42 +485,45 @@ authenticated.delete( ); authenticated.get( - "/resource/:resourceId/roles", + ["/resource/:resourceId/roles", "/public-resource/:resourceId/roles"], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceRoles), resource.listResourceRoles ); authenticated.get( - "/resource/:resourceId/users", + ["/resource/:resourceId/users", "/public-resource/:resourceId/users"], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceUsers), resource.listResourceUsers ); authenticated.get( - "/resource/:resourceId", + ["/resource/:resourceId", "/public-resource/:resourceId"], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.getResource), resource.getResource ); authenticated.get( - "/resource-policy/:resourcePolicyId", + [ + "/resource-policy/:resourcePolicyId", + "/public-resource-policy/:resourcePolicyId" + ], verifyApiKeyResourcePolicyAccess, verifyApiKeyHasAction(ActionsEnum.getResourcePolicy), policy.getResourcePolicy ); authenticated.get( - "/resource/:resourceId/policies", + ["/resource/:resourceId/policies", "/public-resource/:resourceId/policies"], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.getResourcePolicy), resource.getResourcePolicies ); authenticated.post( - "/resource/:resourceId", + ["/resource/:resourceId", "/public-resource/:resourceId"], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateResource), @@ -487,14 +532,17 @@ authenticated.post( ); authenticated.put( - "/resource-policy/:resourcePolicyId", + [ + "/resource-policy/:resourcePolicyId", + "/public-resource-policy/:resourcePolicyId" + ], verifyApiKeyResourcePolicyAccess, verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy), policy.updateResourcePolicy ); authenticated.delete( - "/resource/:resourceId", + ["/resource/:resourceId", "/public-resource/:resourceId"], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResource), logActionAudit(ActionsEnum.deleteResource), @@ -502,7 +550,7 @@ authenticated.delete( ); authenticated.put( - "/resource/:resourceId/target", + ["/resource/:resourceId/target", "/public-resource/:resourceId/target"], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createTarget), @@ -511,14 +559,14 @@ authenticated.put( ); authenticated.get( - "/resource/:resourceId/targets", + ["/resource/:resourceId/targets", "/public-resource/:resourceId/targets"], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listTargets), target.listTargets ); authenticated.put( - "/resource/:resourceId/rule", + ["/resource/:resourceId/rule", "/public-resource/:resourceId/rule"], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createResourceRule), @@ -527,14 +575,17 @@ authenticated.put( ); authenticated.get( - "/resource/:resourceId/rules", + ["/resource/:resourceId/rules", "/public-resource/:resourceId/rules"], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceRules), resource.listResourceRules ); authenticated.post( - "/resource/:resourceId/rule/:ruleId", + [ + "/resource/:resourceId/rule/:ruleId", + "/public-resource/:resourceId/rule/:ruleId" + ], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateResourceRule), @@ -543,7 +594,10 @@ authenticated.post( ); authenticated.delete( - "/resource/:resourceId/rule/:ruleId", + [ + "/resource/:resourceId/rule/:ruleId", + "/public-resource/:resourceId/rule/:ruleId" + ], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResourceRule), logActionAudit(ActionsEnum.deleteResourceRule), @@ -625,7 +679,7 @@ authenticated.post( ); authenticated.post( - "/resource/:resourceId/roles", + ["/resource/:resourceId/roles", "/public-resource/:resourceId/roles"], verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyLimits, @@ -635,7 +689,7 @@ authenticated.post( ); authenticated.post( - "/resource/:resourceId/users", + ["/resource/:resourceId/users", "/public-resource/:resourceId/users"], verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, @@ -645,7 +699,10 @@ authenticated.post( ); authenticated.put( - "/resource-policy/:resourcePolicyId/access-control", + [ + "/resource-policy/:resourcePolicyId/access-control", + "/public-resource-policy/:resourcePolicyId/access-control" + ], verifyApiKeyResourcePolicyAccess, verifyApiKeyRoleAccess, verifyLimits, @@ -657,7 +714,10 @@ authenticated.put( ); authenticated.put( - "/resource-policy/:resourcePolicyId/password", + [ + "/resource-policy/:resourcePolicyId/password", + "/public-resource-policy/:resourcePolicyId/password" + ], verifyApiKeyResourcePolicyAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPassword), @@ -666,7 +726,10 @@ authenticated.put( ); authenticated.put( - "/resource-policy/:resourcePolicyId/pincode", + [ + "/resource-policy/:resourcePolicyId/pincode", + "/public-resource-policy/:resourcePolicyId/pincode" + ], verifyApiKeyResourcePolicyAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPincode), @@ -675,7 +738,10 @@ authenticated.put( ); authenticated.put( - "/resource-policy/:resourcePolicyId/header-auth", + [ + "/resource-policy/:resourcePolicyId/header-auth", + "/public-resource-policy/:resourcePolicyId/header-auth" + ], verifyApiKeyResourcePolicyAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePolicyHeaderAuth), @@ -684,7 +750,10 @@ authenticated.put( ); authenticated.put( - "/resource-policy/:resourcePolicyId/whitelist", + [ + "/resource-policy/:resourcePolicyId/whitelist", + "/public-resource-policy/:resourcePolicyId/whitelist" + ], verifyApiKeyResourcePolicyAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePolicyWhitelist), @@ -693,7 +762,10 @@ authenticated.put( ); authenticated.put( - "/resource-policy/:resourcePolicyId/rules", + [ + "/resource-policy/:resourcePolicyId/rules", + "/public-resource-policy/:resourcePolicyId/rules" + ], verifyApiKeyResourcePolicyAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRules), @@ -702,7 +774,10 @@ authenticated.put( ); authenticated.post( - "/resource/:resourceId/roles/add", + [ + "/resource/:resourceId/roles/add", + "/public-resource/:resourceId/roles/add" + ], verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyLimits, @@ -712,7 +787,10 @@ authenticated.post( ); authenticated.post( - "/resource/:resourceId/roles/remove", + [ + "/resource/:resourceId/roles/remove", + "/public-resource/:resourceId/roles/remove" + ], verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyLimits, @@ -722,7 +800,10 @@ authenticated.post( ); authenticated.post( - "/resource/:resourceId/users/add", + [ + "/resource/:resourceId/users/add", + "/public-resource/:resourceId/users/add" + ], verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, @@ -732,7 +813,10 @@ authenticated.post( ); authenticated.post( - "/resource/:resourceId/users/remove", + [ + "/resource/:resourceId/users/remove", + "/public-resource/:resourceId/users/remove" + ], verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, @@ -742,7 +826,7 @@ authenticated.post( ); authenticated.post( - `/resource/:resourceId/password`, + [`/resource/:resourceId/password`, `/public-resource/:resourceId/password`], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePassword), @@ -751,7 +835,7 @@ authenticated.post( ); authenticated.post( - `/resource/:resourceId/pincode`, + [`/resource/:resourceId/pincode`, `/public-resource/:resourceId/pincode`], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePincode), @@ -760,7 +844,10 @@ authenticated.post( ); authenticated.post( - `/resource/:resourceId/header-auth`, + [ + `/resource/:resourceId/header-auth`, + `/public-resource/:resourceId/header-auth` + ], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth), @@ -769,7 +856,10 @@ authenticated.post( ); authenticated.post( - `/resource/:resourceId/whitelist`, + [ + `/resource/:resourceId/whitelist`, + `/public-resource/:resourceId/whitelist` + ], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), @@ -778,7 +868,10 @@ authenticated.post( ); authenticated.post( - `/resource/:resourceId/whitelist/add`, + [ + `/resource/:resourceId/whitelist/add`, + `/public-resource/:resourceId/whitelist/add` + ], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), @@ -786,7 +879,10 @@ authenticated.post( ); authenticated.post( - `/resource/:resourceId/whitelist/remove`, + [ + `/resource/:resourceId/whitelist/remove`, + `/public-resource/:resourceId/whitelist/remove` + ], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), @@ -794,14 +890,20 @@ authenticated.post( ); authenticated.get( - `/resource/:resourceId/whitelist`, + [ + `/resource/:resourceId/whitelist`, + `/public-resource/:resourceId/whitelist` + ], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.getResourceWhitelist), resource.getResourceWhitelist ); authenticated.post( - `/resource/:resourceId/access-token`, + [ + `/resource/:resourceId/access-token`, + `/public-resource/:resourceId/access-token` + ], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.generateAccessToken), @@ -825,7 +927,10 @@ authenticated.get( ); authenticated.get( - `/resource/:resourceId/access-tokens`, + [ + `/resource/:resourceId/access-tokens`, + `/public-resource/:resourceId/access-tokens` + ], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listAccessTokens), accessToken.listAccessTokens @@ -1155,7 +1260,7 @@ authenticated.get( ); authenticated.get( - "/org/:orgId/resource-names", + ["/org/:orgId/resource-names", "/org/:orgId/public-resource-names"], verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listResources), resource.listAllResourceNames diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts index d7513d58d..fa9102082 100644 --- a/server/routers/policy/getResourcePolicy.ts +++ b/server/routers/policy/getResourcePolicy.ts @@ -191,6 +191,19 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "get", + path: "/public-resource-policy/{resourcePolicyId}", + description: "Get a resource policy by its resourcePolicyId.", + tags: [OpenAPITags.Policy], + request: { + params: z.object({ + resourcePolicyId: z.number() + }) + }, + responses: {} +}); + export async function getResourcePolicy( req: Request, res: Response, diff --git a/server/routers/policy/setResourcePolicyAccessControl.ts b/server/routers/policy/setResourcePolicyAccessControl.ts index 6c0e19b68..c59390df1 100644 --- a/server/routers/policy/setResourcePolicyAccessControl.ts +++ b/server/routers/policy/setResourcePolicyAccessControl.ts @@ -54,6 +54,25 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "post", + path: "/public-resource-policy/{resourceId}/access-control", + description: + "Set access control users for a resource policy, including SSO, users, roles, Identity provider.", + tags: [OpenAPITags.Policy, OpenAPITags.User], + request: { + params: setResourcePolicyAccessControlParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyAcccessControlBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourcePolicyAccessControl( req: Request, res: Response, diff --git a/server/routers/policy/setResourcePolicyHeaderAuth.ts b/server/routers/policy/setResourcePolicyHeaderAuth.ts index 6dba0b8a7..d47dd7239 100644 --- a/server/routers/policy/setResourcePolicyHeaderAuth.ts +++ b/server/routers/policy/setResourcePolicyHeaderAuth.ts @@ -43,6 +43,25 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/header-auth", + description: + "Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication.", + tags: [OpenAPITags.Policy], + request: { + params: setResourcePolicyHeaderAuthParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyHeaderAuthBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourcePolicyHeaderAuth( req: Request, res: Response, diff --git a/server/routers/policy/setResourcePolicyPassword.ts b/server/routers/policy/setResourcePolicyPassword.ts index dd436a7bf..1ab2e549a 100644 --- a/server/routers/policy/setResourcePolicyPassword.ts +++ b/server/routers/policy/setResourcePolicyPassword.ts @@ -38,6 +38,25 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/password", + description: + "Set the password for a resource policy. Setting the password to null will remove it.", + tags: [OpenAPITags.Policy], + request: { + params: setResourcePolicyPasswordParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyPasswordBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourcePolicyPassword( req: Request, res: Response, diff --git a/server/routers/policy/setResourcePolicyPincode.ts b/server/routers/policy/setResourcePolicyPincode.ts index f99691275..c6849cc87 100644 --- a/server/routers/policy/setResourcePolicyPincode.ts +++ b/server/routers/policy/setResourcePolicyPincode.ts @@ -41,6 +41,25 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/pincode", + description: + "Set the PIN code for a resource policy. Setting the PIN code to null will remove it.", + tags: [OpenAPITags.Policy], + request: { + params: setResourcePolicyPincodeParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyPincodeBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourcePolicyPincode( req: Request, res: Response, diff --git a/server/routers/policy/setResourcePolicyRules.ts b/server/routers/policy/setResourcePolicyRules.ts index dfcfb7cdb..4e0ef6e86 100644 --- a/server/routers/policy/setResourcePolicyRules.ts +++ b/server/routers/policy/setResourcePolicyRules.ts @@ -61,6 +61,25 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/rules", + description: + "Set all rules for a resource policy at once. This will replace all existing rules.", + tags: [OpenAPITags.Policy], + request: { + params: setResourcePolicyRulesParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyRulesBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourcePolicyRules( req: Request, res: Response, diff --git a/server/routers/policy/setResourcePolicyWhitelist.ts b/server/routers/policy/setResourcePolicyWhitelist.ts index 0bffacec2..f7b6cfc82 100644 --- a/server/routers/policy/setResourcePolicyWhitelist.ts +++ b/server/routers/policy/setResourcePolicyWhitelist.ts @@ -46,6 +46,25 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/whitelist", + description: + "Set email whitelist for a resource policy. This will replace all existing emails.", + tags: [OpenAPITags.Policy], + request: { + params: setResourcePolicyWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyWhitelistBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourcePolicyWhitelist( req: Request, res: Response, diff --git a/server/routers/policy/updateResourcePolicy.ts b/server/routers/policy/updateResourcePolicy.ts index ad8b19639..29f9112f4 100644 --- a/server/routers/policy/updateResourcePolicy.ts +++ b/server/routers/policy/updateResourcePolicy.ts @@ -36,6 +36,24 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}", + description: "Update a resource policy.", + tags: [OpenAPITags.Org, OpenAPITags.Policy], + request: { + params: updateResourcePolicyParamsSchema, + body: { + content: { + "application/json": { + schema: updateResourcePolicyBodySchema + } + } + } + }, + responses: {} +}); + export async function updateResourcePolicy( req: Request, res: Response, diff --git a/server/routers/resource/addEmailToResourceWhitelist.ts b/server/routers/resource/addEmailToResourceWhitelist.ts index 6d5ef22cd..06227aa47 100644 --- a/server/routers/resource/addEmailToResourceWhitelist.ts +++ b/server/routers/resource/addEmailToResourceWhitelist.ts @@ -63,6 +63,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/whitelist/add", + description: "Add a single email to the resource whitelist.", + tags: [OpenAPITags.PublicResource], + request: { + params: addEmailToResourceWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: addEmailToResourceWhitelistBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function addEmailToResourceWhitelist( req: Request, res: Response, diff --git a/server/routers/resource/addRoleToResource.ts b/server/routers/resource/addRoleToResource.ts index a1d493631..fd5867aba 100644 --- a/server/routers/resource/addRoleToResource.ts +++ b/server/routers/resource/addRoleToResource.ts @@ -59,6 +59,40 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/roles/add", + description: + "Add a single role to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the role is added to the inline policy instead of directly to the resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Role], + request: { + params: addRoleToResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addRoleToResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function addRoleToResource( req: Request, res: Response, diff --git a/server/routers/resource/addUserToResource.ts b/server/routers/resource/addUserToResource.ts index ebe43fccd..124645742 100644 --- a/server/routers/resource/addUserToResource.ts +++ b/server/routers/resource/addUserToResource.ts @@ -59,6 +59,40 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/users/add", + description: + "Add a single user to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the user is added to the inline policy instead of directly to the resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.User], + request: { + params: addUserToResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addUserToResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function addUserToResource( req: Request, res: Response, diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 81c5950fb..a7af2d674 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -182,6 +182,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "put", + path: "/org/{orgId}/public-resource", + description: "Create a resource.", + tags: [OpenAPITags.PublicResource], + request: { + params: createResourceParamsSchema, + body: { + content: { + "application/json": { + schema: createHttpResourceSchema.or(createRawResourceSchema) + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function createResource( req: Request, res: Response, diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index 9d2261338..9fb71c29a 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -61,6 +61,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "put", + path: "/public-resource/{resourceId}/rule", + description: "Create a resource rule.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], + request: { + params: createResourceRuleParamsSchema, + body: { + content: { + "application/json": { + schema: createResourceRuleSchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function createResourceRule( req: Request, res: Response, diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index d80817360..6398e8b9f 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -44,6 +44,32 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "delete", + path: "/public-resource/{resourceId}", + description: "Delete a resource.", + tags: [OpenAPITags.PublicResource], + request: { + params: deleteResourceSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function deleteResource( req: Request, res: Response, diff --git a/server/routers/resource/deleteResourceRule.ts b/server/routers/resource/deleteResourceRule.ts index c619a693e..1b9168836 100644 --- a/server/routers/resource/deleteResourceRule.ts +++ b/server/routers/resource/deleteResourceRule.ts @@ -41,6 +41,32 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "delete", + path: "/public-resource/{resourceId}/rule/{ruleId}", + description: "Delete a resource rule.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], + request: { + params: deleteResourceRuleSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function deleteResourceRule( req: Request, res: Response, diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index e139ee58f..45b4dc308 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -116,6 +116,34 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}", + description: "Get a resource by resourceId.", + tags: [OpenAPITags.PublicResource], + request: { + params: z.object({ + resourceId: z.number() + }) + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function getResource( req: Request, res: Response, diff --git a/server/routers/resource/getResourcePolicies.ts b/server/routers/resource/getResourcePolicies.ts index 6742a0bd5..7593473c1 100644 --- a/server/routers/resource/getResourcePolicies.ts +++ b/server/routers/resource/getResourcePolicies.ts @@ -33,6 +33,17 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/policies", + description: "Get the inline and shared policies associated with a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Policy], + request: { + params: getResourcePoliciesParamsSchema + }, + responses: {} +}); + export async function getResourcePolicies( req: Request, res: Response, diff --git a/server/routers/resource/getResourceWhitelist.ts b/server/routers/resource/getResourceWhitelist.ts index 0bebe6a71..4b33d788d 100644 --- a/server/routers/resource/getResourceWhitelist.ts +++ b/server/routers/resource/getResourceWhitelist.ts @@ -66,6 +66,32 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/whitelist", + description: "Get the whitelist of emails for a specific resource.", + tags: [OpenAPITags.PublicResource], + request: { + params: getResourceWhitelistSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function getResourceWhitelist( req: Request, res: Response, diff --git a/server/routers/resource/listAllResourceNames.ts b/server/routers/resource/listAllResourceNames.ts index e38a3fd69..61878ea2e 100644 --- a/server/routers/resource/listAllResourceNames.ts +++ b/server/routers/resource/listAllResourceNames.ts @@ -57,6 +57,34 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/org/{orgId}/public-resource-names", + description: "List all resource names for an organization.", + tags: [OpenAPITags.PublicResource], + request: { + params: z.object({ + orgId: z.string() + }) + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function listAllResourceNames( req: Request, res: Response, diff --git a/server/routers/resource/listResourceRoles.ts b/server/routers/resource/listResourceRoles.ts index ea4d07f64..19879c51c 100644 --- a/server/routers/resource/listResourceRoles.ts +++ b/server/routers/resource/listResourceRoles.ts @@ -70,6 +70,32 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/roles", + description: "List all roles for a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Role], + request: { + params: listResourceRolesSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function listResourceRoles( req: Request, res: Response, diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index ec4cc332e..97edb07c2 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -94,6 +94,33 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/rules", + description: "List rules for a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], + request: { + params: listResourceRulesParamsSchema, + query: listResourceRulesSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function listResourceRules( req: Request, res: Response, diff --git a/server/routers/resource/listResourceUsers.ts b/server/routers/resource/listResourceUsers.ts index c99dbf0ad..5cc756aeb 100644 --- a/server/routers/resource/listResourceUsers.ts +++ b/server/routers/resource/listResourceUsers.ts @@ -60,6 +60,32 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/users", + description: "List all users for a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.User], + request: { + params: listResourceUsersSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function listResourceUsers( req: Request, res: Response, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index e0baa49b1..e8f0f64f9 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -434,6 +434,35 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/org/{orgId}/public-resources", + description: "List resources for an organization.", + tags: [OpenAPITags.PublicResource], + request: { + params: z.object({ + orgId: z.string() + }), + query: listResourcesSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function listResources( req: Request, res: Response, diff --git a/server/routers/resource/removeEmailFromResourceWhitelist.ts b/server/routers/resource/removeEmailFromResourceWhitelist.ts index 03d961335..a2e27bc89 100644 --- a/server/routers/resource/removeEmailFromResourceWhitelist.ts +++ b/server/routers/resource/removeEmailFromResourceWhitelist.ts @@ -63,6 +63,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/whitelist/remove", + description: "Remove a single email from the resource whitelist.", + tags: [OpenAPITags.PublicResource], + request: { + params: removeEmailFromResourceWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: removeEmailFromResourceWhitelistBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function removeEmailFromResourceWhitelist( req: Request, res: Response, diff --git a/server/routers/resource/removeRoleFromResource.ts b/server/routers/resource/removeRoleFromResource.ts index 0c4d811e2..a373ba33e 100644 --- a/server/routers/resource/removeRoleFromResource.ts +++ b/server/routers/resource/removeRoleFromResource.ts @@ -58,6 +58,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/roles/remove", + description: "Remove a single role from a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Role], + request: { + params: removeRoleFromResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeRoleFromResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function removeRoleFromResource( req: Request, res: Response, diff --git a/server/routers/resource/removeUserFromResource.ts b/server/routers/resource/removeUserFromResource.ts index 43a22c904..100036497 100644 --- a/server/routers/resource/removeUserFromResource.ts +++ b/server/routers/resource/removeUserFromResource.ts @@ -58,6 +58,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/users/remove", + description: "Remove a single user from a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.User], + request: { + params: removeUserFromResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeUserFromResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function removeUserFromResource( req: Request, res: Response, diff --git a/server/routers/resource/setResourceHeaderAuth.ts b/server/routers/resource/setResourceHeaderAuth.ts index 6c5ee1788..8906515d0 100644 --- a/server/routers/resource/setResourceHeaderAuth.ts +++ b/server/routers/resource/setResourceHeaderAuth.ts @@ -60,6 +60,40 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/header-auth", + description: + "Set or update the header authentication for a resource. If user and password is not provided, it will remove the header authentication.", + tags: [OpenAPITags.PublicResource], + request: { + params: setResourceAuthMethodsParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceAuthMethodsBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function setResourceHeaderAuth( req: Request, res: Response, diff --git a/server/routers/resource/setResourcePassword.ts b/server/routers/resource/setResourcePassword.ts index b52ebfe2c..5e0f7c1bf 100644 --- a/server/routers/resource/setResourcePassword.ts +++ b/server/routers/resource/setResourcePassword.ts @@ -58,6 +58,40 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/password", + description: + "Set the password for a resource. Setting the password to null will remove it.", + tags: [OpenAPITags.PublicResource], + request: { + params: setResourceAuthMethodsParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceAuthMethodsBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function setResourcePassword( req: Request, res: Response, diff --git a/server/routers/resource/setResourcePincode.ts b/server/routers/resource/setResourcePincode.ts index 577b11dbf..cf8c75ea4 100644 --- a/server/routers/resource/setResourcePincode.ts +++ b/server/routers/resource/setResourcePincode.ts @@ -58,6 +58,40 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/pincode", + description: + "Set the PIN code for a resource. Setting the PIN code to null will remove it.", + tags: [OpenAPITags.PublicResource], + request: { + params: setResourceAuthMethodsParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceAuthMethodsBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function setResourcePincode( req: Request, res: Response, diff --git a/server/routers/resource/setResourceRoles.ts b/server/routers/resource/setResourceRoles.ts index 681e00650..f640fa5d7 100644 --- a/server/routers/resource/setResourceRoles.ts +++ b/server/routers/resource/setResourceRoles.ts @@ -52,6 +52,40 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/roles", + description: + "Set roles for a resource. This will replace all existing roles. When the resource has an inline policy defined (no shared resource policy assigned), roles are set on the inline policy instead of directly on the resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Role], + request: { + params: setResourceRolesParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceRolesBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function setResourceRoles( req: Request, res: Response, diff --git a/server/routers/resource/setResourceUsers.ts b/server/routers/resource/setResourceUsers.ts index d292ccba2..4f7877d7c 100644 --- a/server/routers/resource/setResourceUsers.ts +++ b/server/routers/resource/setResourceUsers.ts @@ -52,6 +52,40 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/users", + description: + "Set users for a resource. This will replace all existing users. When the resource has an inline policy defined (no shared resource policy assigned), users are set on the inline policy instead of directly on the resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.User], + request: { + params: setUserResourcesParamsSchema, + body: { + content: { + "application/json": { + schema: setUserResourcesBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function setResourceUsers( req: Request, res: Response, diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index 10bf5b480..2b883bfad 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -66,6 +66,40 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/whitelist", + description: + "Set email whitelist for a resource. This will replace all existing emails.", + tags: [OpenAPITags.PublicResource], + request: { + params: setResourceWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceWhitelistBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function setResourceWhitelist( req: Request, res: Response, diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 35995d517..6b055b860 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -273,6 +273,42 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}", + description: + "Update a resource. Policy fields (sso, mfa, pincode, password, whitelist) update the inline policy when no shared resource policy is assigned; when a shared policy is assigned those fields override the shared policy for this resource only.", + tags: [OpenAPITags.PublicResource], + request: { + params: updateResourceParamsSchema, + body: { + content: { + "application/json": { + schema: updateHttpResourceBodySchema.and( + updateRawResourceBodySchema + ) + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function updateResource( req: Request, res: Response, diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index c724d00d2..c9f394730 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -78,6 +78,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/rule/{ruleId}", + description: "Update a resource rule.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], + request: { + params: updateResourceRuleParamsSchema, + body: { + content: { + "application/json": { + schema: updateResourceRuleSchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function updateResourceRule( req: Request, res: Response, diff --git a/server/routers/siteResource/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts index 5e81cf8f7..da585d2c6 100644 --- a/server/routers/siteResource/addClientToSiteResource.ts +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -62,6 +62,40 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/clients/add", + description: + "Add a single client to a site resource. Clients with a userId cannot be added.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], + request: { + params: addClientToSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addClientToSiteResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function addClientToSiteResource( req: Request, res: Response, diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts index d3dd164cc..9f4922b63 100644 --- a/server/routers/siteResource/addRoleToSiteResource.ts +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -62,6 +62,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/roles/add", + description: "Add a single role to a site resource.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], + request: { + params: addRoleToSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addRoleToSiteResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function addRoleToSiteResource( req: Request, res: Response, diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts index 0a93868ae..83476b189 100644 --- a/server/routers/siteResource/addUserToSiteResource.ts +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -62,6 +62,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/users/add", + description: "Add a single user to a site resource.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.User], + request: { + params: addUserToSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addUserToSiteResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function addUserToSiteResource( req: Request, res: Response, diff --git a/server/routers/siteResource/batchAddClientToSiteResources.ts b/server/routers/siteResource/batchAddClientToSiteResources.ts index 3eb282310..558eaee32 100644 --- a/server/routers/siteResource/batchAddClientToSiteResources.ts +++ b/server/routers/siteResource/batchAddClientToSiteResources.ts @@ -67,6 +67,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/client/{clientId}/private-resources", + description: "Add a machine client to multiple site resources at once.", + tags: [OpenAPITags.Client], + request: { + params: batchAddClientToSiteResourcesParamsSchema, + body: { + content: { + "application/json": { + schema: batchAddClientToSiteResourcesBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function batchAddClientToSiteResources( req: Request, res: Response, diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 365053cf1..420c14beb 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -236,6 +236,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "put", + path: "/org/{orgId}/private-resource", + description: "Create a new site resource.", + tags: [OpenAPITags.PrivateResource], + request: { + params: createSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: createSiteResourceSchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function createSiteResource( req: Request, res: Response, diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index 63479c399..f84672acd 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -49,6 +49,32 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "delete", + path: "/private-resource/{siteResourceId}", + description: "Delete a site resource.", + tags: [OpenAPITags.PrivateResource], + request: { + params: deleteSiteResourceParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function deleteSiteResource( req: Request, res: Response, diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts index f25723d4b..d2fd0385d 100644 --- a/server/routers/siteResource/getSiteResource.ts +++ b/server/routers/siteResource/getSiteResource.ts @@ -83,6 +83,36 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/private-resource/{siteResourceId}", + description: "Get a specific site resource by siteResourceId.", + tags: [OpenAPITags.PrivateResource], + request: { + params: z.object({ + siteResourceId: z.number(), + siteId: z.number(), + orgId: z.string() + }) + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + registry.registerPath({ method: "get", path: "/org/{orgId}/site/{siteId}/resource/nice/{niceId}", diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 2cbbc2c4e..fbfba510e 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -253,6 +253,33 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/org/{orgId}/private-resources", + description: "List all site resources for an organization.", + tags: [OpenAPITags.PrivateResource], + request: { + params: listAllSiteResourcesByOrgParamsSchema, + query: listAllSiteResourcesByOrgQuerySchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function listAllSiteResourcesByOrg( req: Request, res: Response, diff --git a/server/routers/siteResource/listSiteResourceClients.ts b/server/routers/siteResource/listSiteResourceClients.ts index 5ae6f6f8d..90255e8cb 100644 --- a/server/routers/siteResource/listSiteResourceClients.ts +++ b/server/routers/siteResource/listSiteResourceClients.ts @@ -61,6 +61,32 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/private-resource/{siteResourceId}/clients", + description: "List all clients for a site resource.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], + request: { + params: listSiteResourceClientsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function listSiteResourceClients( req: Request, res: Response, diff --git a/server/routers/siteResource/listSiteResourceRoles.ts b/server/routers/siteResource/listSiteResourceRoles.ts index d430c8c43..51a34d7c0 100644 --- a/server/routers/siteResource/listSiteResourceRoles.ts +++ b/server/routers/siteResource/listSiteResourceRoles.ts @@ -62,6 +62,32 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/private-resource/{siteResourceId}/roles", + description: "List all roles for a site resource.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], + request: { + params: listSiteResourceRolesSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function listSiteResourceRoles( req: Request, res: Response, diff --git a/server/routers/siteResource/listSiteResourceUsers.ts b/server/routers/siteResource/listSiteResourceUsers.ts index d13a58d3b..00f86c588 100644 --- a/server/routers/siteResource/listSiteResourceUsers.ts +++ b/server/routers/siteResource/listSiteResourceUsers.ts @@ -65,6 +65,32 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/private-resource/{siteResourceId}/users", + description: "List all users for a site resource.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.User], + request: { + params: listSiteResourceUsersSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function listSiteResourceUsers( req: Request, res: Response, diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index c97900bec..30d1bece8 100644 --- a/server/routers/siteResource/listSiteResources.ts +++ b/server/routers/siteResource/listSiteResources.ts @@ -90,6 +90,33 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/org/{orgId}/site/{siteId}/private-resources", + description: "List site resources for a site.", + tags: [OpenAPITags.PrivateResource], + request: { + params: listSiteResourcesParamsSchema, + query: listSiteResourcesQuerySchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function listSiteResources( req: Request, res: Response, diff --git a/server/routers/siteResource/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts index e955dd5c6..26d735c6e 100644 --- a/server/routers/siteResource/removeClientFromSiteResource.ts +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -62,6 +62,40 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/clients/remove", + description: + "Remove a single client from a site resource. Clients with a userId cannot be removed.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], + request: { + params: removeClientFromSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeClientFromSiteResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function removeClientFromSiteResource( req: Request, res: Response, diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts index 509088597..872452e48 100644 --- a/server/routers/siteResource/removeRoleFromSiteResource.ts +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -62,6 +62,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/roles/remove", + description: "Remove a single role from a site resource.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], + request: { + params: removeRoleFromSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeRoleFromSiteResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function removeRoleFromSiteResource( req: Request, res: Response, diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts index 3d8e1fd97..6ad275508 100644 --- a/server/routers/siteResource/removeUserFromSiteResource.ts +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -62,6 +62,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/users/remove", + description: "Remove a single user from a site resource.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.User], + request: { + params: removeUserFromSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeUserFromSiteResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function removeUserFromSiteResource( req: Request, res: Response, diff --git a/server/routers/siteResource/setSiteResourceClients.ts b/server/routers/siteResource/setSiteResourceClients.ts index 59dff6ef9..43f61666f 100644 --- a/server/routers/siteResource/setSiteResourceClients.ts +++ b/server/routers/siteResource/setSiteResourceClients.ts @@ -62,6 +62,40 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/clients", + description: + "Set clients for a site resource. This will replace all existing clients. Clients with a userId cannot be added.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], + request: { + params: setSiteResourceClientsParamsSchema, + body: { + content: { + "application/json": { + schema: setSiteResourceClientsBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function setSiteResourceClients( req: Request, res: Response, diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts index 277ef437e..cc733cc8e 100644 --- a/server/routers/siteResource/setSiteResourceRoles.ts +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -63,6 +63,40 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/roles", + description: + "Set roles for a site resource. This will replace all existing roles.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], + request: { + params: setSiteResourceRolesParamsSchema, + body: { + content: { + "application/json": { + schema: setSiteResourceRolesBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function setSiteResourceRoles( req: Request, res: Response, diff --git a/server/routers/siteResource/setSiteResourceUsers.ts b/server/routers/siteResource/setSiteResourceUsers.ts index 4a423bf06..0ad912831 100644 --- a/server/routers/siteResource/setSiteResourceUsers.ts +++ b/server/routers/siteResource/setSiteResourceUsers.ts @@ -64,6 +64,40 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/users", + description: + "Set users for a site resource. This will replace all existing users.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.User], + request: { + params: setSiteResourceUsersParamsSchema, + body: { + content: { + "application/json": { + schema: setSiteResourceUsersBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function setSiteResourceUsers( req: Request, res: Response, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 0ccf29908..6c3c23e3f 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -242,6 +242,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}", + description: "Update a site resource.", + tags: [OpenAPITags.PrivateResource], + request: { + params: updateSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: updateSiteResourceSchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function updateSiteResource( req: Request, res: Response, diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 289f47c76..8d4ed3696 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -122,6 +122,39 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "put", + path: "/public-resource/{resourceId}/target", + description: "Create a target for a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Target], + request: { + params: createTargetParamsSchema, + body: { + content: { + "application/json": { + schema: createTargetSchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function createTarget( req: Request, res: Response, diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index 68f80197a..b22476023 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -115,6 +115,33 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/targets", + description: "List targets for a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Target], + request: { + params: listTargetsParamsSchema, + query: listTargetsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + export async function listTargets( req: Request, res: Response, From 17e03457e1ead9ab68ac28b97f2fe6cd0e85f8c4 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 15:52:37 -0400 Subject: [PATCH 090/494] Recategorize --- server/openApi.ts | 2 ++ .../routers/resource/addEmailToResourceWhitelist.ts | 7 ++----- server/routers/resource/createResource.ts | 2 +- server/routers/resource/deleteResource.ts | 2 +- server/routers/resource/getResource.ts | 4 ++-- server/routers/resource/getResourceWhitelist.ts | 2 +- server/routers/resource/listAllResourceNames.ts | 2 +- server/routers/resource/listResources.ts | 2 +- .../resource/removeEmailFromResourceWhitelist.ts | 12 +++--------- server/routers/resource/setResourceHeaderAuth.ts | 2 +- server/routers/resource/setResourcePassword.ts | 2 +- server/routers/resource/setResourcePincode.ts | 2 +- server/routers/resource/setResourceWhitelist.ts | 2 +- server/routers/resource/updateResource.ts | 2 +- server/routers/siteResource/createSiteResource.ts | 2 +- server/routers/siteResource/deleteSiteResource.ts | 2 +- server/routers/siteResource/getSiteResource.ts | 2 +- .../siteResource/listAllSiteResourcesByOrg.ts | 2 +- server/routers/siteResource/listSiteResources.ts | 2 +- server/routers/siteResource/updateSiteResource.ts | 2 +- 20 files changed, 25 insertions(+), 32 deletions(-) diff --git a/server/openApi.ts b/server/openApi.ts index 63625192d..cb9ad5dc8 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -6,7 +6,9 @@ export enum OpenAPITags { Site = "Site", Org = "Organization", PublicResource = "Public Resource", + PublicResourceLegacy = "Public Resource (Legacy)", PrivateResource = "Private Resource", + PrivateResourceLegacy = "Private Resource (Legacy)", Policy = "Policy", Role = "Role", User = "User", diff --git a/server/routers/resource/addEmailToResourceWhitelist.ts b/server/routers/resource/addEmailToResourceWhitelist.ts index 06227aa47..97eee29b8 100644 --- a/server/routers/resource/addEmailToResourceWhitelist.ts +++ b/server/routers/resource/addEmailToResourceWhitelist.ts @@ -34,7 +34,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/whitelist/add", description: "Add a single email to the resource whitelist.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: addEmailToResourceWhitelistParamsSchema, body: { @@ -177,10 +177,7 @@ export async function addEmailToResourceWhitelist( .from(resourcePolicyWhiteList) .where( and( - eq( - resourcePolicyWhiteList.resourcePolicyId, - policyId - ), + eq(resourcePolicyWhiteList.resourcePolicyId, policyId), eq(resourcePolicyWhiteList.email, email) ) ); diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index a7af2d674..ab758cc1c 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -153,7 +153,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/resource", description: "Create a resource.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: createResourceParamsSchema, body: { diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index 6398e8b9f..5d42761de 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -22,7 +22,7 @@ registry.registerPath({ method: "delete", path: "/resource/{resourceId}", description: "Delete a resource.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: deleteResourceSchema }, diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index 45b4dc308..b1f4108b3 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -63,7 +63,7 @@ registry.registerPath({ path: "/org/{orgId}/resource/{niceId}", description: "Get a resource by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: z.object({ orgId: z.string(), @@ -92,7 +92,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}", description: "Get a resource by resourceId.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: z.object({ resourceId: z.number() diff --git a/server/routers/resource/getResourceWhitelist.ts b/server/routers/resource/getResourceWhitelist.ts index 4b33d788d..ca8b5d9ee 100644 --- a/server/routers/resource/getResourceWhitelist.ts +++ b/server/routers/resource/getResourceWhitelist.ts @@ -44,7 +44,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/whitelist", description: "Get the whitelist of emails for a specific resource.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: getResourceWhitelistSchema }, diff --git a/server/routers/resource/listAllResourceNames.ts b/server/routers/resource/listAllResourceNames.ts index 61878ea2e..ba52a2f88 100644 --- a/server/routers/resource/listAllResourceNames.ts +++ b/server/routers/resource/listAllResourceNames.ts @@ -33,7 +33,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/resources-names", description: "List all resource names for an organization.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: z.object({ orgId: z.string() diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index e8f0f64f9..cc075f23b 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -409,7 +409,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/resources", description: "List resources for an organization.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: z.object({ orgId: z.string() diff --git a/server/routers/resource/removeEmailFromResourceWhitelist.ts b/server/routers/resource/removeEmailFromResourceWhitelist.ts index a2e27bc89..58631a5f3 100644 --- a/server/routers/resource/removeEmailFromResourceWhitelist.ts +++ b/server/routers/resource/removeEmailFromResourceWhitelist.ts @@ -34,7 +34,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/whitelist/remove", description: "Remove a single email from the resource whitelist.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: removeEmailFromResourceWhitelistParamsSchema, body: { @@ -176,10 +176,7 @@ export async function removeEmailFromResourceWhitelist( .from(resourcePolicyWhiteList) .where( and( - eq( - resourcePolicyWhiteList.resourcePolicyId, - policyId - ), + eq(resourcePolicyWhiteList.resourcePolicyId, policyId), eq(resourcePolicyWhiteList.email, email) ) ); @@ -197,10 +194,7 @@ export async function removeEmailFromResourceWhitelist( .delete(resourcePolicyWhiteList) .where( and( - eq( - resourcePolicyWhiteList.resourcePolicyId, - policyId - ), + eq(resourcePolicyWhiteList.resourcePolicyId, policyId), eq(resourcePolicyWhiteList.email, email) ) ); diff --git a/server/routers/resource/setResourceHeaderAuth.ts b/server/routers/resource/setResourceHeaderAuth.ts index 8906515d0..c618a2902 100644 --- a/server/routers/resource/setResourceHeaderAuth.ts +++ b/server/routers/resource/setResourceHeaderAuth.ts @@ -31,7 +31,7 @@ registry.registerPath({ path: "/resource/{resourceId}/header-auth", description: "Set or update the header authentication for a resource. If user and password is not provided, it will remove the header authentication.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: setResourceAuthMethodsParamsSchema, body: { diff --git a/server/routers/resource/setResourcePassword.ts b/server/routers/resource/setResourcePassword.ts index 5e0f7c1bf..8a74823a9 100644 --- a/server/routers/resource/setResourcePassword.ts +++ b/server/routers/resource/setResourcePassword.ts @@ -29,7 +29,7 @@ registry.registerPath({ path: "/resource/{resourceId}/password", description: "Set the password for a resource. Setting the password to null will remove it.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: setResourceAuthMethodsParamsSchema, body: { diff --git a/server/routers/resource/setResourcePincode.ts b/server/routers/resource/setResourcePincode.ts index cf8c75ea4..033b226e1 100644 --- a/server/routers/resource/setResourcePincode.ts +++ b/server/routers/resource/setResourcePincode.ts @@ -29,7 +29,7 @@ registry.registerPath({ path: "/resource/{resourceId}/pincode", description: "Set the PIN code for a resource. Setting the PIN code to null will remove it.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: setResourceAuthMethodsParamsSchema, body: { diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index 2b883bfad..e6df96de8 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -37,7 +37,7 @@ registry.registerPath({ path: "/resource/{resourceId}/whitelist", description: "Set email whitelist for a resource. This will replace all existing emails.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: setResourceWhitelistParamsSchema, body: { diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 6b055b860..f3e0c3909 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -242,7 +242,7 @@ registry.registerPath({ path: "/resource/{resourceId}", description: "Update a resource. Policy fields (sso, mfa, pincode, password, whitelist) update the inline policy when no shared resource policy is assigned; when a shared policy is assigned those fields override the shared policy for this resource only.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: updateResourceParamsSchema, body: { diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 420c14beb..d3e32babd 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -207,7 +207,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/site-resource", description: "Create a new site resource.", - tags: [OpenAPITags.PrivateResource], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: createSiteResourceParamsSchema, body: { diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index f84672acd..cdb438736 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -27,7 +27,7 @@ registry.registerPath({ method: "delete", path: "/site-resource/{siteResourceId}", description: "Delete a site resource.", - tags: [OpenAPITags.PrivateResource], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: deleteSiteResourceParamsSchema }, diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts index d2fd0385d..47a89d1b1 100644 --- a/server/routers/siteResource/getSiteResource.ts +++ b/server/routers/siteResource/getSiteResource.ts @@ -57,7 +57,7 @@ registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}", description: "Get a specific site resource by siteResourceId.", - tags: [OpenAPITags.PrivateResource], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: z.object({ siteResourceId: z.number(), diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index fbfba510e..50acbeb4f 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -230,7 +230,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/site-resources", description: "List all site resources for an organization.", - tags: [OpenAPITags.PrivateResource], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: listAllSiteResourcesByOrgParamsSchema, query: listAllSiteResourcesByOrgQuerySchema diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index 30d1bece8..da0ba9a49 100644 --- a/server/routers/siteResource/listSiteResources.ts +++ b/server/routers/siteResource/listSiteResources.ts @@ -67,7 +67,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/site/{siteId}/resources", description: "List site resources for a site.", - tags: [OpenAPITags.PrivateResource], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: listSiteResourcesParamsSchema, query: listSiteResourcesQuerySchema diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 6c3c23e3f..7b0ef0783 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -213,7 +213,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}", description: "Update a site resource.", - tags: [OpenAPITags.PrivateResource], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: updateSiteResourceParamsSchema, body: { From 70e2fe1e4e632145082141f67210234f71fb0351 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 16:01:22 -0400 Subject: [PATCH 091/494] Just add ordering --- server/integrationApiServer.ts | 5 +++-- server/openApi.ts | 21 +++++++++++++-------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/server/integrationApiServer.ts b/server/integrationApiServer.ts index 104e02d5c..56a3d47b0 100644 --- a/server/integrationApiServer.ts +++ b/server/integrationApiServer.ts @@ -12,7 +12,7 @@ import { logIncomingMiddleware } from "./middlewares/logIncoming"; import helmet from "helmet"; import swaggerUi from "swagger-ui-express"; import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; -import { registry } from "./openApi"; +import { registry, openApiTags } from "./openApi"; import fs from "fs"; import path from "path"; import { APP_PATH } from "./lib/consts"; @@ -181,7 +181,8 @@ function getOpenApiDocumentation() { version: "v1", title: "Pangolin Integration API" }, - servers: [{ url: "/v1" }] + servers: [{ url: "/v1" }], + tags: openApiTags }); if (!process.env.DISABLE_GEN_OPENAPI) { diff --git a/server/openApi.ts b/server/openApi.ts index cb9ad5dc8..935b64f8d 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -4,25 +4,30 @@ export const registry = new OpenAPIRegistry(); export enum OpenAPITags { Site = "Site", - Org = "Organization", PublicResource = "Public Resource", - PublicResourceLegacy = "Public Resource (Legacy)", + Target = "Resource Target", PrivateResource = "Private Resource", - PrivateResourceLegacy = "Private Resource (Legacy)", + Client = "Client", + Org = "Organization", + Domain = "Domain", Policy = "Policy", Role = "Role", User = "User", - Invitation = "User Invitation", - Target = "Resource Target", Rule = "Rule", + Invitation = "User Invitation", AccessToken = "Access Token", GlobalIdp = "Identity Provider (Global)", OrgIdp = "Identity Provider (Organization Only)", - Client = "Client", ApiKey = "API Key", SiteProvisioningKey = "Site Provisioning Key", - Domain = "Domain", Blueprint = "Blueprint", Ssh = "SSH", - Logs = "Logs" + Logs = "Logs", + PublicResourceLegacy = "Public Resource (Legacy)", + PrivateResourceLegacy = "Private Resource (Legacy)" } + +// Order here controls the order tags are displayed in Swagger UI +export const openApiTags = Object.values(OpenAPITags).map((name) => ({ + name +})); From 75afe6ece2ecabb015081b3ecaa4bdc93fd06a78 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 16:46:16 -0400 Subject: [PATCH 092/494] Order more api routes --- server/routers/accessToken/generateAccessToken.ts | 2 +- server/routers/accessToken/listAccessTokens.ts | 2 +- server/routers/resource/addRoleToResource.ts | 2 +- server/routers/resource/addUserToResource.ts | 2 +- server/routers/resource/createResourceRule.ts | 2 +- server/routers/resource/deleteResourceRule.ts | 2 +- server/routers/resource/getResourcePolicies.ts | 8 +++++--- server/routers/resource/listResourceRoles.ts | 2 +- server/routers/resource/listResourceRules.ts | 2 +- server/routers/resource/listResourceUsers.ts | 2 +- server/routers/resource/removeRoleFromResource.ts | 2 +- server/routers/resource/removeUserFromResource.ts | 2 +- server/routers/resource/setResourceRoles.ts | 2 +- server/routers/resource/setResourceUsers.ts | 2 +- server/routers/resource/updateResourceRule.ts | 2 +- server/routers/target/createTarget.ts | 2 +- server/routers/target/listTargets.ts | 2 +- 17 files changed, 21 insertions(+), 19 deletions(-) diff --git a/server/routers/accessToken/generateAccessToken.ts b/server/routers/accessToken/generateAccessToken.ts index c46e94162..79eecc59a 100644 --- a/server/routers/accessToken/generateAccessToken.ts +++ b/server/routers/accessToken/generateAccessToken.ts @@ -45,7 +45,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/access-token", description: "Generate a new access token for a resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.AccessToken], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: generateAccssTokenParamsSchema, body: { diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index 0068e459a..9a8796e0b 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -159,7 +159,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/access-tokens", description: "List all access tokens for a resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.AccessToken], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: z.object({ resourceId: z.number() diff --git a/server/routers/resource/addRoleToResource.ts b/server/routers/resource/addRoleToResource.ts index fd5867aba..1fac3b7db 100644 --- a/server/routers/resource/addRoleToResource.ts +++ b/server/routers/resource/addRoleToResource.ts @@ -30,7 +30,7 @@ registry.registerPath({ path: "/resource/{resourceId}/roles/add", description: "Add a single role to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the role is added to the inline policy instead of directly to the resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.Role], + tags: [OpenAPITags.PublicResourceLegacy, OpenAPITags.Role], request: { params: addRoleToResourceParamsSchema, body: { diff --git a/server/routers/resource/addUserToResource.ts b/server/routers/resource/addUserToResource.ts index 124645742..e43a1e324 100644 --- a/server/routers/resource/addUserToResource.ts +++ b/server/routers/resource/addUserToResource.ts @@ -30,7 +30,7 @@ registry.registerPath({ path: "/resource/{resourceId}/users/add", description: "Add a single user to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the user is added to the inline policy instead of directly to the resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.User], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: addUserToResourceParamsSchema, body: { diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index 9fb71c29a..f75922722 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -32,7 +32,7 @@ registry.registerPath({ method: "put", path: "/resource/{resourceId}/rule", description: "Create a resource rule.", - tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: createResourceRuleParamsSchema, body: { diff --git a/server/routers/resource/deleteResourceRule.ts b/server/routers/resource/deleteResourceRule.ts index 1b9168836..d7b7c2a65 100644 --- a/server/routers/resource/deleteResourceRule.ts +++ b/server/routers/resource/deleteResourceRule.ts @@ -19,7 +19,7 @@ registry.registerPath({ method: "delete", path: "/resource/{resourceId}/rule/{ruleId}", description: "Delete a resource rule.", - tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: deleteResourceRuleSchema }, diff --git a/server/routers/resource/getResourcePolicies.ts b/server/routers/resource/getResourcePolicies.ts index 7593473c1..30c9e95fc 100644 --- a/server/routers/resource/getResourcePolicies.ts +++ b/server/routers/resource/getResourcePolicies.ts @@ -25,8 +25,9 @@ export type GetResourcePoliciesResponse = { registry.registerPath({ method: "get", path: "/resource/{resourceId}/policies", - description: "Get the inline and shared policies associated with a resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.Policy], + description: + "Get the inline and shared policies associated with a resource.", + tags: [OpenAPITags.PublicResourceLegacy], request: { params: getResourcePoliciesParamsSchema }, @@ -36,7 +37,8 @@ registry.registerPath({ registry.registerPath({ method: "get", path: "/public-resource/{resourceId}/policies", - description: "Get the inline and shared policies associated with a resource.", + description: + "Get the inline and shared policies associated with a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Policy], request: { params: getResourcePoliciesParamsSchema diff --git a/server/routers/resource/listResourceRoles.ts b/server/routers/resource/listResourceRoles.ts index 19879c51c..f5b84cbf9 100644 --- a/server/routers/resource/listResourceRoles.ts +++ b/server/routers/resource/listResourceRoles.ts @@ -48,7 +48,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/roles", description: "List all roles for a resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.Role], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: listResourceRolesSchema }, diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index 97edb07c2..6b32f82bf 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -71,7 +71,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/rules", description: "List rules for a resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: listResourceRulesParamsSchema, query: listResourceRulesSchema diff --git a/server/routers/resource/listResourceUsers.ts b/server/routers/resource/listResourceUsers.ts index 5cc756aeb..afabd3052 100644 --- a/server/routers/resource/listResourceUsers.ts +++ b/server/routers/resource/listResourceUsers.ts @@ -38,7 +38,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/users", description: "List all users for a resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.User], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: listResourceUsersSchema }, diff --git a/server/routers/resource/removeRoleFromResource.ts b/server/routers/resource/removeRoleFromResource.ts index a373ba33e..4c91773f1 100644 --- a/server/routers/resource/removeRoleFromResource.ts +++ b/server/routers/resource/removeRoleFromResource.ts @@ -29,7 +29,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/roles/remove", description: "Remove a single role from a resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.Role], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: removeRoleFromResourceParamsSchema, body: { diff --git a/server/routers/resource/removeUserFromResource.ts b/server/routers/resource/removeUserFromResource.ts index 100036497..d48f31983 100644 --- a/server/routers/resource/removeUserFromResource.ts +++ b/server/routers/resource/removeUserFromResource.ts @@ -29,7 +29,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/users/remove", description: "Remove a single user from a resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.User], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: removeUserFromResourceParamsSchema, body: { diff --git a/server/routers/resource/setResourceRoles.ts b/server/routers/resource/setResourceRoles.ts index f640fa5d7..d21cca478 100644 --- a/server/routers/resource/setResourceRoles.ts +++ b/server/routers/resource/setResourceRoles.ts @@ -23,7 +23,7 @@ registry.registerPath({ path: "/resource/{resourceId}/roles", description: "Set roles for a resource. This will replace all existing roles. When the resource has an inline policy defined (no shared resource policy assigned), roles are set on the inline policy instead of directly on the resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.Role], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: setResourceRolesParamsSchema, body: { diff --git a/server/routers/resource/setResourceUsers.ts b/server/routers/resource/setResourceUsers.ts index 4f7877d7c..0ae833071 100644 --- a/server/routers/resource/setResourceUsers.ts +++ b/server/routers/resource/setResourceUsers.ts @@ -23,7 +23,7 @@ registry.registerPath({ path: "/resource/{resourceId}/users", description: "Set users for a resource. This will replace all existing users. When the resource has an inline policy defined (no shared resource policy assigned), users are set on the inline policy instead of directly on the resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.User], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: setUserResourcesParamsSchema, body: { diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index c9f394730..0ca574017 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -49,7 +49,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/rule/{ruleId}", description: "Update a resource rule.", - tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: updateResourceRuleParamsSchema, body: { diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 8d4ed3696..cf2cd6b5f 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -93,7 +93,7 @@ registry.registerPath({ method: "put", path: "/resource/{resourceId}/target", description: "Create a target for a resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.Target], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: createTargetParamsSchema, body: { diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index b22476023..f2d16dd21 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -92,7 +92,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/targets", description: "List targets for a resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.Target], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: listTargetsParamsSchema, query: listTargetsSchema From 0b82dae01eb5c891300382890073ccd38160b7e2 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 16:54:00 -0400 Subject: [PATCH 093/494] More route cleanup --- server/openApi.ts | 3 ++- server/private/routers/policy/createResourcePolicy.ts | 2 +- server/private/routers/policy/deleteResourcePolicy.ts | 2 +- server/private/routers/policy/listResourcePolicies.ts | 2 +- server/routers/policy/getResourcePolicy.ts | 6 +++--- server/routers/policy/setResourcePolicyAccessControl.ts | 4 ++-- server/routers/policy/setResourcePolicyHeaderAuth.ts | 4 ++-- server/routers/policy/setResourcePolicyPassword.ts | 4 ++-- server/routers/policy/setResourcePolicyPincode.ts | 4 ++-- server/routers/policy/setResourcePolicyRules.ts | 4 ++-- server/routers/policy/setResourcePolicyWhitelist.ts | 4 ++-- server/routers/policy/updateResourcePolicy.ts | 4 ++-- server/routers/resource/getResourcePolicies.ts | 2 +- server/routers/siteResource/addClientToSiteResource.ts | 2 +- server/routers/siteResource/addRoleToSiteResource.ts | 2 +- server/routers/siteResource/addUserToSiteResource.ts | 2 +- server/routers/siteResource/listSiteResourceClients.ts | 2 +- server/routers/siteResource/listSiteResourceRoles.ts | 2 +- server/routers/siteResource/listSiteResourceUsers.ts | 2 +- server/routers/siteResource/removeClientFromSiteResource.ts | 2 +- server/routers/siteResource/removeRoleFromSiteResource.ts | 2 +- server/routers/siteResource/removeUserFromSiteResource.ts | 2 +- server/routers/siteResource/setSiteResourceClients.ts | 2 +- server/routers/siteResource/setSiteResourceRoles.ts | 2 +- server/routers/siteResource/setSiteResourceUsers.ts | 2 +- 25 files changed, 35 insertions(+), 34 deletions(-) diff --git a/server/openApi.ts b/server/openApi.ts index 935b64f8d..f80165d9e 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -10,7 +10,7 @@ export enum OpenAPITags { Client = "Client", Org = "Organization", Domain = "Domain", - Policy = "Policy", + PublicResourcePolicy = "Public Resource Policy", Role = "Role", User = "User", Rule = "Rule", @@ -23,6 +23,7 @@ export enum OpenAPITags { Blueprint = "Blueprint", Ssh = "SSH", Logs = "Logs", + PublicResourcePolicyLegacy = "Public Resource Policy (Legacy)", PublicResourceLegacy = "Public Resource (Legacy)", PrivateResourceLegacy = "Private Resource (Legacy)" } diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 9f02b912c..5b8016286 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -121,7 +121,7 @@ registry.registerPath({ method: "post", path: "/org/{orgId}/resource-policy", description: "Create a resource policy.", - tags: [OpenAPITags.Org, OpenAPITags.Policy], + tags: [OpenAPITags.Org, OpenAPITags.PublicResourcePolicy], request: { params: createResourcePolicyParamsSchema, body: { diff --git a/server/private/routers/policy/deleteResourcePolicy.ts b/server/private/routers/policy/deleteResourcePolicy.ts index a586cf3b4..6f14f1cde 100644 --- a/server/private/routers/policy/deleteResourcePolicy.ts +++ b/server/private/routers/policy/deleteResourcePolicy.ts @@ -31,7 +31,7 @@ registry.registerPath({ method: "delete", path: "/resource-policy/{resourcePolicyId}", description: "Delete a resource policy.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: deleteResourcePolicySchema }, diff --git a/server/private/routers/policy/listResourcePolicies.ts b/server/private/routers/policy/listResourcePolicies.ts index a5a52d9c8..7bd17a5f5 100644 --- a/server/private/routers/policy/listResourcePolicies.ts +++ b/server/private/routers/policy/listResourcePolicies.ts @@ -79,7 +79,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/resource-policies", description: "List resource policies for an organization.", - tags: [OpenAPITags.Org, OpenAPITags.Policy], + tags: [OpenAPITags.Org, OpenAPITags.PublicResourcePolicy], request: { params: z.object({ orgId: z.string() diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts index fa9102082..4ce032b0b 100644 --- a/server/routers/policy/getResourcePolicy.ts +++ b/server/routers/policy/getResourcePolicy.ts @@ -168,7 +168,7 @@ registry.registerPath({ path: "/org/{orgId}/resource-policy/{niceId}", description: "Get a resource policy by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.", - tags: [OpenAPITags.Org, OpenAPITags.Policy], + tags: [OpenAPITags.Org, OpenAPITags.PublicResourcePolicy], request: { params: z.object({ orgId: z.string(), @@ -182,7 +182,7 @@ registry.registerPath({ method: "get", path: "/resource-policy/{resourcePolicyId}", description: "Get a resource policy by its resourcePolicyId.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicyLegacy], request: { params: z.object({ resourcePolicyId: z.number() @@ -195,7 +195,7 @@ registry.registerPath({ method: "get", path: "/public-resource-policy/{resourcePolicyId}", description: "Get a resource policy by its resourcePolicyId.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: z.object({ resourcePolicyId: z.number() diff --git a/server/routers/policy/setResourcePolicyAccessControl.ts b/server/routers/policy/setResourcePolicyAccessControl.ts index c59390df1..c74a01410 100644 --- a/server/routers/policy/setResourcePolicyAccessControl.ts +++ b/server/routers/policy/setResourcePolicyAccessControl.ts @@ -40,7 +40,7 @@ registry.registerPath({ path: "/resource-policy/{resourceId}/access-control", description: "Set access control users for a resource policy, including SSO, users, roles, Identity provider.", - tags: [OpenAPITags.Policy, OpenAPITags.User], + tags: [OpenAPITags.PublicResourcePolicyLegacy], request: { params: setResourcePolicyAccessControlParamsSchema, body: { @@ -59,7 +59,7 @@ registry.registerPath({ path: "/public-resource-policy/{resourceId}/access-control", description: "Set access control users for a resource policy, including SSO, users, roles, Identity provider.", - tags: [OpenAPITags.Policy, OpenAPITags.User], + tags: [OpenAPITags.PublicResourcePolicy, OpenAPITags.User], request: { params: setResourcePolicyAccessControlParamsSchema, body: { diff --git a/server/routers/policy/setResourcePolicyHeaderAuth.ts b/server/routers/policy/setResourcePolicyHeaderAuth.ts index d47dd7239..efbbedf8a 100644 --- a/server/routers/policy/setResourcePolicyHeaderAuth.ts +++ b/server/routers/policy/setResourcePolicyHeaderAuth.ts @@ -29,7 +29,7 @@ registry.registerPath({ path: "/resource-policy/{resourcePolicyId}/header-auth", description: "Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicyLegacy], request: { params: setResourcePolicyHeaderAuthParamsSchema, body: { @@ -48,7 +48,7 @@ registry.registerPath({ path: "/public-resource-policy/{resourcePolicyId}/header-auth", description: "Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: setResourcePolicyHeaderAuthParamsSchema, body: { diff --git a/server/routers/policy/setResourcePolicyPassword.ts b/server/routers/policy/setResourcePolicyPassword.ts index 1ab2e549a..909df6da0 100644 --- a/server/routers/policy/setResourcePolicyPassword.ts +++ b/server/routers/policy/setResourcePolicyPassword.ts @@ -24,7 +24,7 @@ registry.registerPath({ path: "/resource-policy/{resourcePolicyId}/password", description: "Set the password for a resource policy. Setting the password to null will remove it.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicyLegacy], request: { params: setResourcePolicyPasswordParamsSchema, body: { @@ -43,7 +43,7 @@ registry.registerPath({ path: "/public-resource-policy/{resourcePolicyId}/password", description: "Set the password for a resource policy. Setting the password to null will remove it.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: setResourcePolicyPasswordParamsSchema, body: { diff --git a/server/routers/policy/setResourcePolicyPincode.ts b/server/routers/policy/setResourcePolicyPincode.ts index c6849cc87..73fc26090 100644 --- a/server/routers/policy/setResourcePolicyPincode.ts +++ b/server/routers/policy/setResourcePolicyPincode.ts @@ -27,7 +27,7 @@ registry.registerPath({ path: "/resource-policy/{resourcePolicyId}/pincode", description: "Set the PIN code for a resource policy. Setting the PIN code to null will remove it.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicyLegacy], request: { params: setResourcePolicyPincodeParamsSchema, body: { @@ -46,7 +46,7 @@ registry.registerPath({ path: "/public-resource-policy/{resourcePolicyId}/pincode", description: "Set the PIN code for a resource policy. Setting the PIN code to null will remove it.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: setResourcePolicyPincodeParamsSchema, body: { diff --git a/server/routers/policy/setResourcePolicyRules.ts b/server/routers/policy/setResourcePolicyRules.ts index 4e0ef6e86..9499afa6e 100644 --- a/server/routers/policy/setResourcePolicyRules.ts +++ b/server/routers/policy/setResourcePolicyRules.ts @@ -47,7 +47,7 @@ registry.registerPath({ path: "/resource-policy/{resourcePolicyId}/rules", description: "Set all rules for a resource policy at once. This will replace all existing rules.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicyLegacy], request: { params: setResourcePolicyRulesParamsSchema, body: { @@ -66,7 +66,7 @@ registry.registerPath({ path: "/public-resource-policy/{resourcePolicyId}/rules", description: "Set all rules for a resource policy at once. This will replace all existing rules.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: setResourcePolicyRulesParamsSchema, body: { diff --git a/server/routers/policy/setResourcePolicyWhitelist.ts b/server/routers/policy/setResourcePolicyWhitelist.ts index f7b6cfc82..788419533 100644 --- a/server/routers/policy/setResourcePolicyWhitelist.ts +++ b/server/routers/policy/setResourcePolicyWhitelist.ts @@ -32,7 +32,7 @@ registry.registerPath({ path: "/resource-policy/{resourcePolicyId}/whitelist", description: "Set email whitelist for a resource policy. This will replace all existing emails.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicyLegacy], request: { params: setResourcePolicyWhitelistParamsSchema, body: { @@ -51,7 +51,7 @@ registry.registerPath({ path: "/public-resource-policy/{resourcePolicyId}/whitelist", description: "Set email whitelist for a resource policy. This will replace all existing emails.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: setResourcePolicyWhitelistParamsSchema, body: { diff --git a/server/routers/policy/updateResourcePolicy.ts b/server/routers/policy/updateResourcePolicy.ts index 29f9112f4..7e4d6c250 100644 --- a/server/routers/policy/updateResourcePolicy.ts +++ b/server/routers/policy/updateResourcePolicy.ts @@ -22,7 +22,7 @@ registry.registerPath({ method: "put", path: "/resource-policy/{resourcePolicyId}", description: "Update a resource policy.", - tags: [OpenAPITags.Org, OpenAPITags.Policy], + tags: [OpenAPITags.Org, OpenAPITags.PublicResourcePolicy], request: { params: updateResourcePolicyParamsSchema, body: { @@ -40,7 +40,7 @@ registry.registerPath({ method: "put", path: "/public-resource-policy/{resourcePolicyId}", description: "Update a resource policy.", - tags: [OpenAPITags.Org, OpenAPITags.Policy], + tags: [OpenAPITags.Org, OpenAPITags.PublicResourcePolicy], request: { params: updateResourcePolicyParamsSchema, body: { diff --git a/server/routers/resource/getResourcePolicies.ts b/server/routers/resource/getResourcePolicies.ts index 30c9e95fc..b87c6caf6 100644 --- a/server/routers/resource/getResourcePolicies.ts +++ b/server/routers/resource/getResourcePolicies.ts @@ -39,7 +39,7 @@ registry.registerPath({ path: "/public-resource/{resourceId}/policies", description: "Get the inline and shared policies associated with a resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.Policy], + tags: [OpenAPITags.PublicResource, OpenAPITags.PublicResourcePolicy], request: { params: getResourcePoliciesParamsSchema }, diff --git a/server/routers/siteResource/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts index da585d2c6..c9ead2cbd 100644 --- a/server/routers/siteResource/addClientToSiteResource.ts +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -33,7 +33,7 @@ registry.registerPath({ path: "/site-resource/{siteResourceId}/clients/add", description: "Add a single client to a site resource. Clients with a userId cannot be added.", - tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: addClientToSiteResourceParamsSchema, body: { diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts index 9f4922b63..2cdae780c 100644 --- a/server/routers/siteResource/addRoleToSiteResource.ts +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -33,7 +33,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/roles/add", description: "Add a single role to a site resource.", - tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: addRoleToSiteResourceParamsSchema, body: { diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts index 83476b189..526494265 100644 --- a/server/routers/siteResource/addUserToSiteResource.ts +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -33,7 +33,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/users/add", description: "Add a single user to a site resource.", - tags: [OpenAPITags.PrivateResource, OpenAPITags.User], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: addUserToSiteResourceParamsSchema, body: { diff --git a/server/routers/siteResource/listSiteResourceClients.ts b/server/routers/siteResource/listSiteResourceClients.ts index 90255e8cb..916345190 100644 --- a/server/routers/siteResource/listSiteResourceClients.ts +++ b/server/routers/siteResource/listSiteResourceClients.ts @@ -39,7 +39,7 @@ registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/clients", description: "List all clients for a site resource.", - tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: listSiteResourceClientsSchema }, diff --git a/server/routers/siteResource/listSiteResourceRoles.ts b/server/routers/siteResource/listSiteResourceRoles.ts index 51a34d7c0..132b4fed0 100644 --- a/server/routers/siteResource/listSiteResourceRoles.ts +++ b/server/routers/siteResource/listSiteResourceRoles.ts @@ -40,7 +40,7 @@ registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/roles", description: "List all roles for a site resource.", - tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: listSiteResourceRolesSchema }, diff --git a/server/routers/siteResource/listSiteResourceUsers.ts b/server/routers/siteResource/listSiteResourceUsers.ts index 00f86c588..70604d718 100644 --- a/server/routers/siteResource/listSiteResourceUsers.ts +++ b/server/routers/siteResource/listSiteResourceUsers.ts @@ -43,7 +43,7 @@ registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/users", description: "List all users for a site resource.", - tags: [OpenAPITags.PrivateResource, OpenAPITags.User], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: listSiteResourceUsersSchema }, diff --git a/server/routers/siteResource/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts index 26d735c6e..bf57668bf 100644 --- a/server/routers/siteResource/removeClientFromSiteResource.ts +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -33,7 +33,7 @@ registry.registerPath({ path: "/site-resource/{siteResourceId}/clients/remove", description: "Remove a single client from a site resource. Clients with a userId cannot be removed.", - tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: removeClientFromSiteResourceParamsSchema, body: { diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts index 872452e48..cd60b288d 100644 --- a/server/routers/siteResource/removeRoleFromSiteResource.ts +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -33,7 +33,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/roles/remove", description: "Remove a single role from a site resource.", - tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: removeRoleFromSiteResourceParamsSchema, body: { diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts index 6ad275508..42b719e85 100644 --- a/server/routers/siteResource/removeUserFromSiteResource.ts +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -33,7 +33,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/users/remove", description: "Remove a single user from a site resource.", - tags: [OpenAPITags.PrivateResource, OpenAPITags.User], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: removeUserFromSiteResourceParamsSchema, body: { diff --git a/server/routers/siteResource/setSiteResourceClients.ts b/server/routers/siteResource/setSiteResourceClients.ts index 43f61666f..e1915badc 100644 --- a/server/routers/siteResource/setSiteResourceClients.ts +++ b/server/routers/siteResource/setSiteResourceClients.ts @@ -33,7 +33,7 @@ registry.registerPath({ path: "/site-resource/{siteResourceId}/clients", description: "Set clients for a site resource. This will replace all existing clients. Clients with a userId cannot be added.", - tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: setSiteResourceClientsParamsSchema, body: { diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts index cc733cc8e..7d1c8db15 100644 --- a/server/routers/siteResource/setSiteResourceRoles.ts +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -34,7 +34,7 @@ registry.registerPath({ path: "/site-resource/{siteResourceId}/roles", description: "Set roles for a site resource. This will replace all existing roles.", - tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: setSiteResourceRolesParamsSchema, body: { diff --git a/server/routers/siteResource/setSiteResourceUsers.ts b/server/routers/siteResource/setSiteResourceUsers.ts index 0ad912831..06953b927 100644 --- a/server/routers/siteResource/setSiteResourceUsers.ts +++ b/server/routers/siteResource/setSiteResourceUsers.ts @@ -35,7 +35,7 @@ registry.registerPath({ path: "/site-resource/{siteResourceId}/users", description: "Set users for a site resource. This will replace all existing users.", - tags: [OpenAPITags.PrivateResource, OpenAPITags.User], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: setSiteResourceUsersParamsSchema, body: { From bf1cc705a5f71e21f0b2c83c940f3082ded60579 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 17:09:56 -0400 Subject: [PATCH 094/494] Update tags --- server/openApi.ts | 3 +++ server/private/routers/alertRule/createAlertRule.ts | 2 +- server/private/routers/alertRule/deleteAlertRule.ts | 2 +- server/private/routers/alertRule/getAlertRule.ts | 2 +- server/private/routers/alertRule/listAlertRules.ts | 2 +- server/private/routers/alertRule/updateAlertRule.ts | 2 +- .../createEventStreamingDestination.ts | 2 +- .../deleteEventStreamingDestination.ts | 2 +- .../listEventStreamingDestinations.ts | 2 +- .../updateEventStreamingDestination.ts | 2 +- server/private/routers/healthChecks/createHealthCheck.ts | 2 +- server/private/routers/healthChecks/deleteHealthCheck.ts | 2 +- server/private/routers/healthChecks/listHealthChecks.ts | 2 +- server/private/routers/healthChecks/updateHealthCheck.ts | 2 +- server/private/routers/policy/createResourcePolicy.ts | 2 +- server/private/routers/policy/listResourcePolicies.ts | 2 +- server/routers/policy/getResourcePolicy.ts | 2 +- server/routers/policy/updateResourcePolicy.ts | 4 ++-- server/routers/site/listSites.ts | 2 +- 19 files changed, 22 insertions(+), 19 deletions(-) diff --git a/server/openApi.ts b/server/openApi.ts index f80165d9e..27c26b2da 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -23,6 +23,9 @@ export enum OpenAPITags { Blueprint = "Blueprint", Ssh = "SSH", Logs = "Logs", + EventStreamingDestination = "Event Streaming Destination", + AlertRule = "Alert Rule", + HealthCheck = "Health Check", PublicResourcePolicyLegacy = "Public Resource Policy (Legacy)", PublicResourceLegacy = "Public Resource (Legacy)", PrivateResourceLegacy = "Private Resource (Legacy)" diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index 7a46c84ff..51cc8ea16 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -191,7 +191,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/alert-rule", description: "Create an alert rule for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.AlertRule], request: { params: paramsSchema, body: { diff --git a/server/private/routers/alertRule/deleteAlertRule.ts b/server/private/routers/alertRule/deleteAlertRule.ts index 0439a6622..c3a21e8ac 100644 --- a/server/private/routers/alertRule/deleteAlertRule.ts +++ b/server/private/routers/alertRule/deleteAlertRule.ts @@ -34,7 +34,7 @@ registry.registerPath({ method: "delete", path: "/org/{orgId}/alert-rule/{alertRuleId}", description: "Delete an alert rule for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.AlertRule], request: { params: paramsSchema }, diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index fcbd44f11..92a53b93e 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -48,7 +48,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/alert-rule/{alertRuleId}", description: "Get a specific alert rule for an organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.AlertRule], request: { params: paramsSchema }, diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index 0fc720d66..c6835a45d 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -90,7 +90,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/alert-rules", description: "List all alert rules for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.AlertRule], request: { query: querySchema, params: paramsSchema diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 690a97d29..87fa4675f 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -158,7 +158,7 @@ registry.registerPath({ method: "post", path: "/org/{orgId}/alert-rule/{alertRuleId}", description: "Update an alert rule for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.AlertRule], request: { params: paramsSchema, body: { diff --git a/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts index 5050c55e8..7b000c5d8 100644 --- a/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts +++ b/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts @@ -52,7 +52,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/event-streaming-destination", description: "Create an event streaming destination for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.EventStreamingDestination], request: { params: paramsSchema, body: { diff --git a/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts index 5aa4bf314..c047b6c95 100644 --- a/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts +++ b/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts @@ -35,7 +35,7 @@ registry.registerPath({ path: "/org/{orgId}/event-streaming-destination/{destinationId}", description: "Delete an event streaming destination for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.EventStreamingDestination], request: { params: paramsSchema }, diff --git a/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts b/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts index e20f95b8e..dc741d482 100644 --- a/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts +++ b/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts @@ -109,7 +109,7 @@ registry.registerPath({ path: "/org/{orgId}/event-streaming-destination", description: "List all event streaming destinations for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.EventStreamingDestination], request: { query: querySchema, params: paramsSchema diff --git a/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts index adbac2cca..84202bf8e 100644 --- a/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts +++ b/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts @@ -55,7 +55,7 @@ registry.registerPath({ method: "post", path: "/org/{orgId}/event-streaming-destination/{destinationId}", description: "Update an event streaming destination for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.EventStreamingDestination], request: { params: paramsSchema, body: { diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts index 6f49f0f18..ef7331c5f 100644 --- a/server/private/routers/healthChecks/createHealthCheck.ts +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -75,7 +75,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/health-check", description: "Create a health check for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.HealthCheck], request: { params: paramsSchema, body: { diff --git a/server/private/routers/healthChecks/deleteHealthCheck.ts b/server/private/routers/healthChecks/deleteHealthCheck.ts index 90bb41e50..d51c42d81 100644 --- a/server/private/routers/healthChecks/deleteHealthCheck.ts +++ b/server/private/routers/healthChecks/deleteHealthCheck.ts @@ -37,7 +37,7 @@ registry.registerPath({ method: "delete", path: "/org/{orgId}/health-check/{healthCheckId}", description: "Delete a health check for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.HealthCheck], request: { params: paramsSchema }, diff --git a/server/private/routers/healthChecks/listHealthChecks.ts b/server/private/routers/healthChecks/listHealthChecks.ts index 0d18b881e..2e34b7f6e 100644 --- a/server/private/routers/healthChecks/listHealthChecks.ts +++ b/server/private/routers/healthChecks/listHealthChecks.ts @@ -63,7 +63,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/health-checks", description: "List health checks for an organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.HealthCheck], request: { params: paramsSchema, query: querySchema diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts index 4fb7a624b..a4022d298 100644 --- a/server/private/routers/healthChecks/updateHealthCheck.ts +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -109,7 +109,7 @@ registry.registerPath({ method: "post", path: "/org/{orgId}/health-check/{healthCheckId}", description: "Update a health check for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.HealthCheck], request: { params: paramsSchema, body: { diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 5b8016286..78eace74c 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -121,7 +121,7 @@ registry.registerPath({ method: "post", path: "/org/{orgId}/resource-policy", description: "Create a resource policy.", - tags: [OpenAPITags.Org, OpenAPITags.PublicResourcePolicy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: createResourcePolicyParamsSchema, body: { diff --git a/server/private/routers/policy/listResourcePolicies.ts b/server/private/routers/policy/listResourcePolicies.ts index 7bd17a5f5..af02b50c6 100644 --- a/server/private/routers/policy/listResourcePolicies.ts +++ b/server/private/routers/policy/listResourcePolicies.ts @@ -79,7 +79,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/resource-policies", description: "List resource policies for an organization.", - tags: [OpenAPITags.Org, OpenAPITags.PublicResourcePolicy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: z.object({ orgId: z.string() diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts index 4ce032b0b..fafa31d7d 100644 --- a/server/routers/policy/getResourcePolicy.ts +++ b/server/routers/policy/getResourcePolicy.ts @@ -168,7 +168,7 @@ registry.registerPath({ path: "/org/{orgId}/resource-policy/{niceId}", description: "Get a resource policy by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.", - tags: [OpenAPITags.Org, OpenAPITags.PublicResourcePolicy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: z.object({ orgId: z.string(), diff --git a/server/routers/policy/updateResourcePolicy.ts b/server/routers/policy/updateResourcePolicy.ts index 7e4d6c250..e395987eb 100644 --- a/server/routers/policy/updateResourcePolicy.ts +++ b/server/routers/policy/updateResourcePolicy.ts @@ -22,7 +22,7 @@ registry.registerPath({ method: "put", path: "/resource-policy/{resourcePolicyId}", description: "Update a resource policy.", - tags: [OpenAPITags.Org, OpenAPITags.PublicResourcePolicy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: updateResourcePolicyParamsSchema, body: { @@ -40,7 +40,7 @@ registry.registerPath({ method: "put", path: "/public-resource-policy/{resourcePolicyId}", description: "Update a resource policy.", - tags: [OpenAPITags.Org, OpenAPITags.PublicResourcePolicy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: updateResourcePolicyParamsSchema, body: { diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 1f8e41f26..cdc3a5630 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -177,7 +177,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/sites", description: "List all sites in an organization", - tags: [OpenAPITags.Org, OpenAPITags.Site], + tags: [OpenAPITags.Site], request: { params: listSitesParamsSchema, query: listSitesSchema From adeefb9dbd5e1ee5ed669b949fb7189ffaaa34e8 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 17:13:47 -0400 Subject: [PATCH 095/494] Add missing endpoints to api --- server/private/routers/integration.ts | 152 ++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index b613b2f27..2e53b5b9d 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -17,6 +17,10 @@ import * as logs from "#private/routers/auditLogs"; import * as alertEvents from "#private/routers/alertEvents"; import * as certificates from "#private/routers/certificates"; import * as siteProvisioning from "#private/routers/siteProvisioning"; +import * as policy from "#private/routers/policy"; +import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; +import * as alertRule from "#private/routers/alertRule"; +import * as healthChecks from "#private/routers/healthChecks"; import { verifyApiKeyHasAction, @@ -26,6 +30,7 @@ import { verifyApiKeyRoleAccess, verifyApiKeyUserAccess, verifyApiKeySiteProvisioningKeyAccess, + verifyApiKeyResourcePolicyAccess, verifyLimits } from "@server/middlewares"; import * as user from "#private/routers/user"; @@ -259,3 +264,150 @@ authenticated.patch( logActionAudit(ActionsEnum.updateSiteProvisioningKey), siteProvisioning.updateSiteProvisioningKey ); + +authenticated.get( + ["/org/:orgId/resource-policies", "/org/:orgId/public-resource-policies"], + verifyValidLicense, + verifyValidSubscription(tierMatrix.resourcePolicies), + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.listResourcePolicies), + logActionAudit(ActionsEnum.listResourcePolicies), + policy.listResourcePolicies +); + +authenticated.post( + ["/org/:orgId/resource-policy", "/org/:orgId/public-resource-policy"], + verifyValidLicense, + verifyValidSubscription(tierMatrix.resourcePolicies), + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createResourcePolicy), + logActionAudit(ActionsEnum.createResourcePolicy), + policy.createResourcePolicy +); + +authenticated.delete( + [ + "/resource-policy/:resourcePolicyId", + "/public-resource-policy/:resourcePolicyId" + ], + verifyApiKeyResourcePolicyAccess, + verifyValidLicense, + verifyValidSubscription(tierMatrix.resourcePolicies), + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.deleteResourcePolicy), + logActionAudit(ActionsEnum.deleteResourcePolicy), + policy.deleteResourcePolicy +); + +authenticated.put( + "/org/:orgId/event-streaming-destination", + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createEventStreamingDestination), + logActionAudit(ActionsEnum.createEventStreamingDestination), + eventStreamingDestination.createEventStreamingDestination +); + +authenticated.post( + "/org/:orgId/event-streaming-destination/:destinationId", + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.updateEventStreamingDestination), + logActionAudit(ActionsEnum.updateEventStreamingDestination), + eventStreamingDestination.updateEventStreamingDestination +); + +authenticated.delete( + "/org/:orgId/event-streaming-destination/:destinationId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.deleteEventStreamingDestination), + logActionAudit(ActionsEnum.deleteEventStreamingDestination), + eventStreamingDestination.deleteEventStreamingDestination +); + +authenticated.get( + "/org/:orgId/event-streaming-destinations", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listEventStreamingDestinations), + eventStreamingDestination.listEventStreamingDestinations +); + +authenticated.put( + "/org/:orgId/alert-rule", + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createAlertRule), + logActionAudit(ActionsEnum.createAlertRule), + alertRule.createAlertRule +); + +authenticated.post( + "/org/:orgId/alert-rule/:alertRuleId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.updateAlertRule), + logActionAudit(ActionsEnum.updateAlertRule), + alertRule.updateAlertRule +); + +authenticated.delete( + "/org/:orgId/alert-rule/:alertRuleId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.deleteAlertRule), + logActionAudit(ActionsEnum.deleteAlertRule), + alertRule.deleteAlertRule +); + +authenticated.get( + "/org/:orgId/alert-rules", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listAlertRules), + alertRule.listAlertRules +); + +authenticated.get( + "/org/:orgId/alert-rule/:alertRuleId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getAlertRule), + alertRule.getAlertRule +); + +authenticated.get( + "/org/:orgId/health-checks", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listHealthChecks), + healthChecks.listHealthChecks +); + +authenticated.put( + "/org/:orgId/health-check", + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createHealthCheck), + logActionAudit(ActionsEnum.createHealthCheck), + healthChecks.createHealthCheck +); + +authenticated.post( + "/org/:orgId/health-check/:healthCheckId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.updateHealthCheck), + logActionAudit(ActionsEnum.updateHealthCheck), + healthChecks.updateHealthCheck +); + +authenticated.delete( + "/org/:orgId/health-check/:healthCheckId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.deleteHealthCheck), + logActionAudit(ActionsEnum.deleteHealthCheck), + healthChecks.deleteHealthCheck +); + +authenticated.get( + "/org/:orgId/health-check/:healthCheckId/status-history", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getTarget), + healthChecks.getHealthCheckStatusHistory +); From f1ed4da8a4094717fae9533e4a04c58f9800e79a Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 17:45:15 -0400 Subject: [PATCH 096/494] Move to legacy --- server/routers/resource/addRoleToResource.ts | 2 +- server/routers/siteResource/batchAddClientToSiteResources.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/routers/resource/addRoleToResource.ts b/server/routers/resource/addRoleToResource.ts index 1fac3b7db..9c5b6f0c6 100644 --- a/server/routers/resource/addRoleToResource.ts +++ b/server/routers/resource/addRoleToResource.ts @@ -30,7 +30,7 @@ registry.registerPath({ path: "/resource/{resourceId}/roles/add", description: "Add a single role to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the role is added to the inline policy instead of directly to the resource.", - tags: [OpenAPITags.PublicResourceLegacy, OpenAPITags.Role], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: addRoleToResourceParamsSchema, body: { diff --git a/server/routers/siteResource/batchAddClientToSiteResources.ts b/server/routers/siteResource/batchAddClientToSiteResources.ts index 558eaee32..6fde3b55a 100644 --- a/server/routers/siteResource/batchAddClientToSiteResources.ts +++ b/server/routers/siteResource/batchAddClientToSiteResources.ts @@ -38,7 +38,7 @@ registry.registerPath({ method: "post", path: "/client/{clientId}/site-resources", description: "Add a machine client to multiple site resources at once.", - tags: [OpenAPITags.Client], + tags: [OpenAPITags.PrivateResourceLegacy], request: { params: batchAddClientToSiteResourcesParamsSchema, body: { From 2bc6b28978d9cb76e8047cbdf0c70883337892bb Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 15:33:43 -0400 Subject: [PATCH 097/494] Unify put vs post --- server/routers/domain/updateDomain.ts | 27 ++++- server/routers/integration.ts | 101 ++++++++++++++++++ .../policy/setResourcePolicyAccessControl.ts | 40 +++++++ .../policy/setResourcePolicyHeaderAuth.ts | 44 +++++++- .../policy/setResourcePolicyPassword.ts | 44 +++++++- .../policy/setResourcePolicyPincode.ts | 44 +++++++- .../routers/policy/setResourcePolicyRules.ts | 44 +++++++- .../policy/setResourcePolicyWhitelist.ts | 44 +++++++- server/routers/policy/updateResourcePolicy.ts | 42 +++++++- 9 files changed, 417 insertions(+), 13 deletions(-) diff --git a/server/routers/domain/updateDomain.ts b/server/routers/domain/updateDomain.ts index 61b99f542..cc2a26844 100644 --- a/server/routers/domain/updateDomain.ts +++ b/server/routers/domain/updateDomain.ts @@ -33,7 +33,7 @@ const UpdateDomainResponseDataSchema = z.object({ registry.registerPath({ - method: "patch", + method: "post", path: "/org/{orgId}/domain/{domainId}", description: "Update a domain by domainId.", tags: [OpenAPITags.Domain], @@ -55,6 +55,31 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "patch", + path: "/org/{orgId}/domain/{domainId}", + description: + "Update a domain by domainId. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.Domain], + request: { + params: z.object({ + domainId: z.string(), + orgId: z.string() + }) + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: createApiResponseSchema(UpdateDomainResponseDataSchema) + } + } + } + } +}); + export async function updateOrgDomain( req: Request, res: Response, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index d16efdb63..13498ad25 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -426,6 +426,15 @@ authenticated.put( domain.createOrgDomain ); +authenticated.post( + "/org/:orgId/domain/:domainId", + verifyApiKeyOrgAccess, + verifyApiKeyDomainAccess, + verifyApiKeyHasAction(ActionsEnum.updateOrgDomain), + domain.updateOrgDomain +); + +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.patch( "/org/:orgId/domain/:domainId", verifyApiKeyOrgAccess, @@ -531,6 +540,17 @@ authenticated.post( resource.updateResource ); +authenticated.post( + [ + "/resource-policy/:resourcePolicyId", + "/public-resource-policy/:resourcePolicyId" + ], + verifyApiKeyResourcePolicyAccess, + verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy), + policy.updateResourcePolicy +); + +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.put( [ "/resource-policy/:resourcePolicyId", @@ -698,6 +718,22 @@ authenticated.post( resource.setResourceUsers ); +authenticated.post( + [ + "/resource-policy/:resourcePolicyId/access-control", + "/public-resource-policy/:resourcePolicyId/access-control" + ], + verifyApiKeyResourcePolicyAccess, + verifyApiKeyRoleAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), + verifyUserHasAction(ActionsEnum.setResourcePolicyRoles), + logActionAudit(ActionsEnum.setResourcePolicyUsers), + logActionAudit(ActionsEnum.setResourcePolicyRoles), + policy.setResourcePolicyAccessControl +); + +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.put( [ "/resource-policy/:resourcePolicyId/access-control", @@ -713,6 +749,19 @@ authenticated.put( policy.setResourcePolicyAccessControl ); +authenticated.post( + [ + "/resource-policy/:resourcePolicyId/password", + "/public-resource-policy/:resourcePolicyId/password" + ], + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPassword), + logActionAudit(ActionsEnum.setResourcePolicyPassword), + policy.setResourcePolicyPassword +); + +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.put( [ "/resource-policy/:resourcePolicyId/password", @@ -725,6 +774,19 @@ authenticated.put( policy.setResourcePolicyPassword ); +authenticated.post( + [ + "/resource-policy/:resourcePolicyId/pincode", + "/public-resource-policy/:resourcePolicyId/pincode" + ], + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPincode), + logActionAudit(ActionsEnum.setResourcePolicyPincode), + policy.setResourcePolicyPincode +); + +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.put( [ "/resource-policy/:resourcePolicyId/pincode", @@ -737,6 +799,19 @@ authenticated.put( policy.setResourcePolicyPincode ); +authenticated.post( + [ + "/resource-policy/:resourcePolicyId/header-auth", + "/public-resource-policy/:resourcePolicyId/header-auth" + ], + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyHeaderAuth), + logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth), + policy.setResourcePolicyHeaderAuth +); + +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.put( [ "/resource-policy/:resourcePolicyId/header-auth", @@ -749,6 +824,19 @@ authenticated.put( policy.setResourcePolicyHeaderAuth ); +authenticated.post( + [ + "/resource-policy/:resourcePolicyId/whitelist", + "/public-resource-policy/:resourcePolicyId/whitelist" + ], + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyWhitelist), + logActionAudit(ActionsEnum.setResourcePolicyWhitelist), + policy.setResourcePolicyWhitelist +); + +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.put( [ "/resource-policy/:resourcePolicyId/whitelist", @@ -761,6 +849,19 @@ authenticated.put( policy.setResourcePolicyWhitelist ); +authenticated.post( + [ + "/resource-policy/:resourcePolicyId/rules", + "/public-resource-policy/:resourcePolicyId/rules" + ], + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRules), + logActionAudit(ActionsEnum.setResourcePolicyRules), + policy.setResourcePolicyRules +); + +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.put( [ "/resource-policy/:resourcePolicyId/rules", diff --git a/server/routers/policy/setResourcePolicyAccessControl.ts b/server/routers/policy/setResourcePolicyAccessControl.ts index c74a01410..3af1c6eac 100644 --- a/server/routers/policy/setResourcePolicyAccessControl.ts +++ b/server/routers/policy/setResourcePolicyAccessControl.ts @@ -73,6 +73,46 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourceId}/access-control", + description: + "Set access control users for a resource policy, including SSO, users, roles, Identity provider. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyAccessControlParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyAcccessControlBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourceId}/access-control", + description: + "Set access control users for a resource policy, including SSO, users, roles, Identity provider. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy, OpenAPITags.User], + request: { + params: setResourcePolicyAccessControlParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyAcccessControlBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourcePolicyAccessControl( req: Request, res: Response, diff --git a/server/routers/policy/setResourcePolicyHeaderAuth.ts b/server/routers/policy/setResourcePolicyHeaderAuth.ts index efbbedf8a..6c9c2dc49 100644 --- a/server/routers/policy/setResourcePolicyHeaderAuth.ts +++ b/server/routers/policy/setResourcePolicyHeaderAuth.ts @@ -25,7 +25,7 @@ const setResourcePolicyHeaderAuthBodySchema = z.strictObject({ }); registry.registerPath({ - method: "put", + method: "post", path: "/resource-policy/{resourcePolicyId}/header-auth", description: "Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication.", @@ -44,7 +44,7 @@ registry.registerPath({ }); registry.registerPath({ - method: "put", + method: "post", path: "/public-resource-policy/{resourcePolicyId}/header-auth", description: "Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication.", @@ -62,6 +62,46 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/header-auth", + description: + "Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyHeaderAuthParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyHeaderAuthBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/header-auth", + description: + "Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: setResourcePolicyHeaderAuthParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyHeaderAuthBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourcePolicyHeaderAuth( req: Request, res: Response, diff --git a/server/routers/policy/setResourcePolicyPassword.ts b/server/routers/policy/setResourcePolicyPassword.ts index 909df6da0..1255245e8 100644 --- a/server/routers/policy/setResourcePolicyPassword.ts +++ b/server/routers/policy/setResourcePolicyPassword.ts @@ -20,7 +20,7 @@ const setResourcePolicyPasswordBodySchema = z.strictObject({ }); registry.registerPath({ - method: "put", + method: "post", path: "/resource-policy/{resourcePolicyId}/password", description: "Set the password for a resource policy. Setting the password to null will remove it.", @@ -39,7 +39,7 @@ registry.registerPath({ }); registry.registerPath({ - method: "put", + method: "post", path: "/public-resource-policy/{resourcePolicyId}/password", description: "Set the password for a resource policy. Setting the password to null will remove it.", @@ -57,6 +57,46 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/password", + description: + "Set the password for a resource policy. Setting the password to null will remove it. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyPasswordParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyPasswordBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/password", + description: + "Set the password for a resource policy. Setting the password to null will remove it. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: setResourcePolicyPasswordParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyPasswordBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourcePolicyPassword( req: Request, res: Response, diff --git a/server/routers/policy/setResourcePolicyPincode.ts b/server/routers/policy/setResourcePolicyPincode.ts index 73fc26090..6537a3933 100644 --- a/server/routers/policy/setResourcePolicyPincode.ts +++ b/server/routers/policy/setResourcePolicyPincode.ts @@ -23,7 +23,7 @@ const setResourcePolicyPincodeBodySchema = z.strictObject({ }); registry.registerPath({ - method: "put", + method: "post", path: "/resource-policy/{resourcePolicyId}/pincode", description: "Set the PIN code for a resource policy. Setting the PIN code to null will remove it.", @@ -42,7 +42,7 @@ registry.registerPath({ }); registry.registerPath({ - method: "put", + method: "post", path: "/public-resource-policy/{resourcePolicyId}/pincode", description: "Set the PIN code for a resource policy. Setting the PIN code to null will remove it.", @@ -60,6 +60,46 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/pincode", + description: + "Set the PIN code for a resource policy. Setting the PIN code to null will remove it. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyPincodeParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyPincodeBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/pincode", + description: + "Set the PIN code for a resource policy. Setting the PIN code to null will remove it. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: setResourcePolicyPincodeParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyPincodeBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourcePolicyPincode( req: Request, res: Response, diff --git a/server/routers/policy/setResourcePolicyRules.ts b/server/routers/policy/setResourcePolicyRules.ts index 9499afa6e..8cb5f4e09 100644 --- a/server/routers/policy/setResourcePolicyRules.ts +++ b/server/routers/policy/setResourcePolicyRules.ts @@ -43,7 +43,7 @@ const setResourcePolicyRulesParamsSchema = z.strictObject({ }); registry.registerPath({ - method: "put", + method: "post", path: "/resource-policy/{resourcePolicyId}/rules", description: "Set all rules for a resource policy at once. This will replace all existing rules.", @@ -62,7 +62,7 @@ registry.registerPath({ }); registry.registerPath({ - method: "put", + method: "post", path: "/public-resource-policy/{resourcePolicyId}/rules", description: "Set all rules for a resource policy at once. This will replace all existing rules.", @@ -80,6 +80,46 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/rules", + description: + "Set all rules for a resource policy at once. This will replace all existing rules. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyRulesParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyRulesBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/rules", + description: + "Set all rules for a resource policy at once. This will replace all existing rules. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: setResourcePolicyRulesParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyRulesBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourcePolicyRules( req: Request, res: Response, diff --git a/server/routers/policy/setResourcePolicyWhitelist.ts b/server/routers/policy/setResourcePolicyWhitelist.ts index 788419533..2cdd94e98 100644 --- a/server/routers/policy/setResourcePolicyWhitelist.ts +++ b/server/routers/policy/setResourcePolicyWhitelist.ts @@ -28,7 +28,7 @@ const setResourcePolicyWhitelistParamsSchema = z.strictObject({ }); registry.registerPath({ - method: "put", + method: "post", path: "/resource-policy/{resourcePolicyId}/whitelist", description: "Set email whitelist for a resource policy. This will replace all existing emails.", @@ -47,7 +47,7 @@ registry.registerPath({ }); registry.registerPath({ - method: "put", + method: "post", path: "/public-resource-policy/{resourcePolicyId}/whitelist", description: "Set email whitelist for a resource policy. This will replace all existing emails.", @@ -65,6 +65,46 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/whitelist", + description: + "Set email whitelist for a resource policy. This will replace all existing emails. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyWhitelistBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/whitelist", + description: + "Set email whitelist for a resource policy. This will replace all existing emails. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: setResourcePolicyWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyWhitelistBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourcePolicyWhitelist( req: Request, res: Response, diff --git a/server/routers/policy/updateResourcePolicy.ts b/server/routers/policy/updateResourcePolicy.ts index e395987eb..f42f1bc74 100644 --- a/server/routers/policy/updateResourcePolicy.ts +++ b/server/routers/policy/updateResourcePolicy.ts @@ -19,7 +19,7 @@ const updateResourcePolicyBodySchema = z.strictObject({ }); registry.registerPath({ - method: "put", + method: "post", path: "/resource-policy/{resourcePolicyId}", description: "Update a resource policy.", tags: [OpenAPITags.PublicResourcePolicy], @@ -37,7 +37,7 @@ registry.registerPath({ }); registry.registerPath({ - method: "put", + method: "post", path: "/public-resource-policy/{resourcePolicyId}", description: "Update a resource policy.", tags: [OpenAPITags.PublicResourcePolicy], @@ -54,6 +54,44 @@ registry.registerPath({ responses: {} }); +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}", + description: "Update a resource policy. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: updateResourcePolicyParamsSchema, + body: { + content: { + "application/json": { + schema: updateResourcePolicyBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}", + description: "Update a resource policy. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: updateResourcePolicyParamsSchema, + body: { + content: { + "application/json": { + schema: updateResourcePolicyBodySchema + } + } + } + }, + responses: {} +}); + export async function updateResourcePolicy( req: Request, res: Response, From 26713b53f6006e551bfdc1a44816628dfd0e2a4b Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Jul 2026 09:04:43 -0400 Subject: [PATCH 098/494] Fix #3462 --- server/routers/resource/createResourceRule.ts | 49 +++---------- server/routers/resource/updateResourceRule.ts | 70 ++++--------------- 2 files changed, 24 insertions(+), 95 deletions(-) diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index f75922722..4cb9967ed 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -9,16 +9,14 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern + RESOURCE_RULE_MATCH_TYPES, + getResourceRuleValueValidationError } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; -import { isValidRegionId } from "@server/db/regions"; const createResourceRuleSchema = z.strictObject({ action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]), + match: z.enum(RESOURCE_RULE_MATCH_TYPES), value: z.string().min(1), priority: z.int(), enabled: z.boolean().optional() @@ -151,39 +149,14 @@ export async function createResourceRule( ); } - if (match === "CIDR") { - if (!isValidCIDR(value)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid CIDR provided" - ) - ); - } - } else if (match === "IP") { - if (!isValidIP(value)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided") - ); - } - } else if (match === "PATH") { - if (!isValidUrlGlobPattern(value)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid URL glob pattern provided" - ) - ); - } - } else if (match === "REGION") { - if (!isValidRegionId(value)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid region ID provided" - ) - ); - } + const valueValidationError = getResourceRuleValueValidationError( + match, + value + ); + if (valueValidationError) { + return next( + createHttpError(HttpCode.BAD_REQUEST, valueValidationError) + ); } // Create the new resource rule diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index 0ca574017..5eba8aaa0 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -9,12 +9,11 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern + RESOURCE_RULE_MATCH_TYPES, + getResourceRuleValueValidationError, + ResourceRuleMatchType } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; -import { isValidRegionId } from "@server/db/regions"; // Define Zod schema for request parameters validation const updateResourceRuleParamsSchema = z.strictObject({ @@ -22,15 +21,7 @@ const updateResourceRuleParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); -const resourceRuleMatchSchema = z.enum([ - "CIDR", - "IP", - "PATH", - "COUNTRY", - "COUNTRY_IS_NOT", - "ASN", - "REGION" -]); +const resourceRuleMatchSchema = z.enum(RESOURCE_RULE_MATCH_TYPES); // Define Zod schema for request body validation const updateResourceRuleSchema = z @@ -173,14 +164,7 @@ export async function updateResourceRule( resource.resourcePolicyId === null && resource.defaultResourcePolicyId !== null; - let existingMatch: - | "CIDR" - | "IP" - | "PATH" - | "COUNTRY" - | "COUNTRY_IS_NOT" - | "ASN" - | "REGION"; + let existingMatch: ResourceRuleMatchType; if (isInlinePolicy) { const policyId = resource.defaultResourcePolicyId!; @@ -264,42 +248,14 @@ export async function updateResourceRule( const { value } = updateData; if (value !== undefined) { - if (match === "CIDR") { - if (!isValidCIDR(value)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid CIDR provided" - ) - ); - } - } else if (match === "IP") { - if (!isValidIP(value)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid IP provided" - ) - ); - } - } else if (match === "PATH") { - if (!isValidUrlGlobPattern(value)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid URL glob pattern provided" - ) - ); - } - } else if (match === "REGION") { - if (!isValidRegionId(value)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid region ID provided" - ) - ); - } + const valueValidationError = getResourceRuleValueValidationError( + match, + value + ); + if (valueValidationError) { + return next( + createHttpError(HttpCode.BAD_REQUEST, valueValidationError) + ); } } From df0198df9cfcf1a05146705166f8aa6ae75e5075 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Jul 2026 11:37:18 -0400 Subject: [PATCH 099/494] Update internal -> private --- messages/en-US.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 2ca25d850..aff3be28b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -934,7 +934,7 @@ "newtVersion": "Version", "architecture": "Architecture", "sites": "Sites", - "siteWgAnyClients": "Use any WireGuard client to connect. You will have to address internal resources using the peer IP.", + "siteWgAnyClients": "Use any WireGuard client to connect. You will have to address private resources using the peer IP.", "siteWgCompatibleAllClients": "Compatible with all WireGuard clients", "siteWgManualConfigurationRequired": "Manual configuration required", "userErrorNotAdminOrOwner": "User is not an admin or owner", @@ -2361,7 +2361,7 @@ "resourcesTableProxyResources": "Public", "resourcesTableClientResources": "Private", "resourcesTableNoProxyResourcesFound": "No proxy resources found.", - "resourcesTableNoInternalResourcesFound": "No internal resources found.", + "resourcesTableNoInternalResourcesFound": "No private resources found.", "resourcesTableDestination": "Destination", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Alias Address", @@ -2384,9 +2384,9 @@ "editInternalResourceDialogCancel": "Cancel", "editInternalResourceDialogSaveResource": "Save Resource", "editInternalResourceDialogSuccess": "Success", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Internal resource updated successfully", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Private resource updated successfully", "editInternalResourceDialogError": "Error", - "editInternalResourceDialogFailedToUpdateInternalResource": "Failed to update internal resource", + "editInternalResourceDialogFailedToUpdateInternalResource": "Failed to update private resource", "editInternalResourceDialogNameRequired": "Name is required", "editInternalResourceDialogNameMaxLength": "Name must be less than 255 characters", "editInternalResourceDialogProxyPortMin": "Proxy port must be at least 1", @@ -2412,7 +2412,7 @@ "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", "createInternalResourceDialogNoSitesAvailable": "No Sites Available", - "createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create internal resources.", + "createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create private resources.", "createInternalResourceDialogClose": "Close", "createInternalResourceDialogCreateClientResource": "Create Private Resource", "createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization", @@ -2441,9 +2441,9 @@ "createInternalResourceDialogCancel": "Cancel", "createInternalResourceDialogCreateResource": "Create Resource", "createInternalResourceDialogSuccess": "Success", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Internal resource created successfully", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Private resource created successfully", "createInternalResourceDialogError": "Error", - "createInternalResourceDialogFailedToCreateInternalResource": "Failed to create internal resource", + "createInternalResourceDialogFailedToCreateInternalResource": "Failed to create private resource", "createInternalResourceDialogNameRequired": "Name is required", "createInternalResourceDialogNameMaxLength": "Name must be less than 255 characters", "createInternalResourceDialogPleaseSelectSite": "Please select a site", From 56fcb80b233d0af9fc9552330d5ab4b79ca8e9af Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 20 Jul 2026 17:36:45 +0100 Subject: [PATCH 100/494] =?UTF-8?q?=F0=9F=92=84=20fix=20labels=20dropdown?= =?UTF-8?q?=20flicker=20if=20no=20changes=20applied?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useOptimisticLabels.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/hooks/useOptimisticLabels.ts b/src/hooks/useOptimisticLabels.ts index f5c787474..059abc672 100644 --- a/src/hooks/useOptimisticLabels.ts +++ b/src/hooks/useOptimisticLabels.ts @@ -95,7 +95,10 @@ export function useOptimisticLabels({ } async function refresh() { - router.refresh(); + // Only refresh if the labels have been modified + if (pendingActions.length > 0) { + router.refresh(); + } setPendingActions([]); } From 72f179578b372e71a2cceb8051f4a91d870c7b8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:06:46 +0000 Subject: [PATCH 101/494] Bump axios from 1.16.1 to 1.18.0 Bumps [axios](https://github.com/axios/axios) from 1.16.1 to 1.18.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.16.1...v1.18.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.18.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 378a1f26c..fc1ce971b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "arctic": "3.7.0", - "axios": "1.16.1", + "axios": "1.18.0", "better-sqlite3": "11.9.1", "canvas-confetti": "1.9.4", "class-variance-authority": "0.7.1", @@ -9078,9 +9078,9 @@ } }, "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", diff --git a/package.json b/package.json index 15ef7bda5..af6250486 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "arctic": "3.7.0", - "axios": "1.16.1", + "axios": "1.18.0", "better-sqlite3": "11.9.1", "canvas-confetti": "1.9.4", "class-variance-authority": "0.7.1", From 9cc3190e3a75ece1c3fd01bc895d33d1ecd9c7bb Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 20 Jul 2026 20:37:52 +0100 Subject: [PATCH 102/494] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20batched=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/statusHistory.ts | 87 ++++++++++++------- .../routers/site/getBatchedStatusHistory.ts | 42 ++++++--- 2 files changed, 85 insertions(+), 44 deletions(-) diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index a0ede99a6..315c776c2 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -264,9 +264,7 @@ export function computeBuckets( // Shift by the client's offset before formatting so the label reflects // their local calendar date rather than the UTC date of dayStartSec - const dateStr = new Date( - (dayStartSec + tzOffsetMinutes * 60) * 1000 - ) + const dateStr = new Date((dayStartSec + tzOffsetMinutes * 60) * 1000) .toISOString() .slice(0, 10); @@ -305,13 +303,10 @@ export function computeBuckets( return { buckets, totalDowntime }; } -export interface BatchedStatusHistoryResponse { - entityType: string; - entityIds: number[]; - days: StatusHistoryDayBucket[]; - overallUptimePercent: number; - totalDowntimeSeconds: number; -} +export type BatchedStatusHistoryResponse = Record< + string, + StatusHistoryResponse +>; export async function getBatchedStatusHistory( entityType: string, @@ -345,8 +340,8 @@ export async function getBatchedStatusHistory( // Fetch the last known state before the window so that entities that // haven't changed status recently still show the correct status rather // than appearing as "no_data". - const [lastKnownEvent] = await logsDb - .select() + const lastKnownEvents = await logsDb + .selectDistinctOn([statusHistory.entityId, statusHistory.timestamp]) .from(statusHistory) .where( and( @@ -355,29 +350,57 @@ export async function getBatchedStatusHistory( lt(statusHistory.timestamp, startSec) ) ) - .orderBy(desc(statusHistory.timestamp)) - .limit(1); + .orderBy(desc(statusHistory.timestamp)); - const priorStatus = lastKnownEvent?.status ?? null; + const eventStatusMap: Record< + number, + { + events: typeof events; + lastKnownEvent: (typeof lastKnownEvents)[number] | null; + } + > = {}; - const { buckets, totalDowntime } = computeBuckets( - events, - days, - priorStatus - ); - const totalWindow = days * 86400; - const overallUptime = - totalWindow > 0 - ? Math.max(0, ((totalWindow - totalDowntime) / totalWindow) * 100) - : 100; + for (const event of events) { + if (!eventStatusMap[event.entityId]) { + eventStatusMap[event.entityId] = { + events: [], + lastKnownEvent: + lastKnownEvents.find( + (ev) => ev.entityId === event.entityId + ) ?? null + }; + } + eventStatusMap[event.entityId].events.push(event); + } - const result: BatchedStatusHistoryResponse = { - entityType, - entityIds, - days: buckets, - overallUptimePercent: Math.round(overallUptime * 100) / 100, - totalDowntimeSeconds: totalDowntime - }; + const result: BatchedStatusHistoryResponse = {}; + + for (const entityId in eventStatusMap) { + const event = eventStatusMap[Number(entityId)]; + const priorStatus = event.lastKnownEvent?.status ?? null; + + const { buckets, totalDowntime } = computeBuckets( + event.events, + days, + priorStatus + ); + const totalWindow = days * 86400; + const overallUptime = + totalWindow > 0 + ? Math.max( + 0, + ((totalWindow - totalDowntime) / totalWindow) * 100 + ) + : 100; + + result[entityId] = { + entityType, + entityId: Number(entityId), + days: buckets, + overallUptimePercent: Math.round(overallUptime * 100) / 100, + totalDowntimeSeconds: totalDowntime + }; + } // await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); return result; diff --git a/server/routers/site/getBatchedStatusHistory.ts b/server/routers/site/getBatchedStatusHistory.ts index 97ed8a9ac..507a556d4 100644 --- a/server/routers/site/getBatchedStatusHistory.ts +++ b/server/routers/site/getBatchedStatusHistory.ts @@ -11,8 +11,35 @@ import { StatusHistoryResponse } from "@server/lib/statusHistory"; -const siteParamsSchema = z.object({ - siteId: z.string().transform((v) => parseInt(v, 10)) +const siteIdParamsSchema = z.object({ + days: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 90)), + // Minutes to add to UTC to get the requesting client's local time + // (e.g. Australia/Sydney standard time is 600). Optional and + // defaults to 0 (UTC) so older clients keep the prior behavior. + tzOffsetMinutes: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 0)), + siteIds: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + const raw = Array.isArray(val) ? val : [val]; + const nums = raw + .map((v) => + typeof v === "string" ? parseInt(v, 10) : Number(v) + ) + .filter((n) => Number.isInteger(n) && n > 0); + const unique = [...new Set(nums)]; + return unique.length ? unique : undefined; + }, z.array(z.number().int().positive())) + .openapi({ + description: "Filter by siteIds (repeat query param)" + }) }); export async function getBatchedSiteStatusHistory( @@ -21,16 +48,7 @@ export async function getBatchedSiteStatusHistory( next: NextFunction ): Promise { try { - const parsedParams = siteParamsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - const parsedQuery = statusHistoryQuerySchema.safeParse(req.query); + const parsedQuery = siteIdParamsSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( From 23181f4019b4ac852b55af3eaba162f058861b59 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 21 Jul 2026 19:57:09 +0100 Subject: [PATCH 103/494] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20batched=20site=20?= =?UTF-8?q?status=20histories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/statusHistory.ts | 34 ++++++++++---- server/routers/external.ts | 7 +++ .../routers/site/getBatchedStatusHistory.ts | 29 ++++++------ server/routers/site/index.ts | 1 + src/components/SitesTable.tsx | 34 +++++++++----- src/components/UptimeMiniBar.tsx | 45 +++++++++++-------- src/lib/queries.ts | 31 +++++++++---- 7 files changed, 122 insertions(+), 59 deletions(-) diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 315c776c2..f116d74e2 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -37,10 +37,11 @@ export async function getCachedStatusHistory( tzOffsetMinutes ); const cached = await cache.get(cacheKey); - if (cached !== undefined) { - return cached; - } + // if (cached !== undefined) { + // return cached; + // } + console.time(`[getCachedStatusHistory/${entityType}=${entityId}]`); // Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query // window aligns with stable calendar days for the requesting client const todayMidnightSec = localMidnightSec(tzOffsetMinutes); @@ -76,12 +77,14 @@ export async function getCachedStatusHistory( const priorStatus = lastKnownEvent?.status ?? null; + console.time(`[computeBuckets/${entityType}=${entityId}]`); const { buckets, totalDowntime } = computeBuckets( events, days, priorStatus, tzOffsetMinutes ); + console.timeEnd(`[computeBuckets/${entityType}=${entityId}]`); const totalWindow = days * 86400; const overallUptime = totalWindow > 0 @@ -96,6 +99,7 @@ export async function getCachedStatusHistory( totalDowntimeSeconds: totalDowntime }; + console.timeEnd(`[getCachedStatusHistory/${entityType}=${entityId}]`); await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); return result; } @@ -299,7 +303,6 @@ export function computeBuckets( status }); } - return { buckets, totalDowntime }; } @@ -311,7 +314,8 @@ export type BatchedStatusHistoryResponse = Record< export async function getBatchedStatusHistory( entityType: string, entityIds: number[], - days: number + days: number, + tzOffsetMinutes: number = 0 ): Promise { // const cacheKey = statusHistoryCacheKey(entityType, entityId, days); // const cached = await cache.get(cacheKey); @@ -319,10 +323,13 @@ export async function getBatchedStatusHistory( // return cached; // } - // Anchor to UTC midnight so the query window aligns with stable calendar days - const utcToday = new Date(); - utcToday.setUTCHours(0, 0, 0, 0); - const todayMidnightSec = Math.floor(utcToday.getTime() / 1000); + console.time( + `[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]` + ); + + // Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query + // window aligns with stable calendar days for the requesting client + const todayMidnightSec = localMidnightSec(tzOffsetMinutes); const startSec = todayMidnightSec - days * 86400; const events = await logsDb @@ -337,6 +344,10 @@ export async function getBatchedStatusHistory( ) .orderBy(asc(statusHistory.timestamp)); + console.log({ + events + }); + // Fetch the last known state before the window so that entities that // haven't changed status recently still show the correct status rather // than appearing as "no_data". @@ -375,6 +386,7 @@ export async function getBatchedStatusHistory( const result: BatchedStatusHistoryResponse = {}; + console.time(`[computeBuckets/${entityType}=(${entityIds.join(" ,")})]`); for (const entityId in eventStatusMap) { const event = eventStatusMap[Number(entityId)]; const priorStatus = event.lastKnownEvent?.status ?? null; @@ -401,7 +413,11 @@ export async function getBatchedStatusHistory( totalDowntimeSeconds: totalDowntime }; } + console.timeEnd(`[computeBuckets/${entityType}=(${entityIds.join(" ,")})]`); + console.timeEnd( + `[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]` + ); // await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); return result; } diff --git a/server/routers/external.ts b/server/routers/external.ts index 62ca15316..e6d487f56 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -319,6 +319,13 @@ authenticated.get( site.getSiteStatusHistory ); +authenticated.get( + "/org/:orgId/site-status-histories", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listSites), + site.getBatchedSiteStatusHistory +); + // Site Resource endpoints authenticated.put( "/org/:orgId/site-resource", diff --git a/server/routers/site/getBatchedStatusHistory.ts b/server/routers/site/getBatchedStatusHistory.ts index 507a556d4..f266c2840 100644 --- a/server/routers/site/getBatchedStatusHistory.ts +++ b/server/routers/site/getBatchedStatusHistory.ts @@ -1,15 +1,14 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; import { - getCachedStatusHistory, - statusHistoryQuerySchema, - StatusHistoryResponse + getBatchedStatusHistory, + type BatchedStatusHistoryResponse } from "@server/lib/statusHistory"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const siteIdParamsSchema = z.object({ days: z @@ -59,12 +58,16 @@ export async function getBatchedSiteStatusHistory( } const entityType = "site"; - const entityId = parsedParams.data.siteId; - const { days } = parsedQuery.data; + const { days, siteIds, tzOffsetMinutes } = parsedQuery.data; - const data = await getCachedStatusHistory(entityType, entityId, days); + const data = await getBatchedStatusHistory( + entityType, + siteIds, + days, + tzOffsetMinutes + ); - return response(res, { + return response(res, { data, success: true, error: false, diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index c0c399789..7d5bcb698 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -1,5 +1,6 @@ export * from "./getSite"; export * from "./getStatusHistory"; +export * from "./getBatchedStatusHistory"; export * from "./createSite"; export * from "./deleteSite"; export * from "./updateSite"; diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 95d63cf9a..1f745269c 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -1,7 +1,7 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import UptimeMiniBar from "@app/components/UptimeMiniBar"; +import { UptimeMiniBar } from "@app/components/UptimeMiniBar"; import { Credenza, @@ -56,8 +56,9 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; import { useQuery } from "@tanstack/react-query"; -import { productUpdatesQueries } from "@app/lib/queries"; +import { orgQueries, productUpdatesQueries } from "@app/lib/queries"; import semver from "semver"; +import { durationToMs } from "@app/lib/durationToMs"; export type SiteRow = { id: number; @@ -89,6 +90,8 @@ type SitesTableProps = { rowCount: number; }; +const SITE_STATUS_HISTORY_DAYS = 30; + export default function SitesTable({ sites, orgId, @@ -112,7 +115,14 @@ export default function SitesTable({ const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); - const { isPaidUser } = usePaidStatus(); + const statusHistoryQuery = useQuery({ + ...orgQueries.batchedSiteStatusHistory({ + orgId, + siteIds: sites.map((s) => s.id), + days: SITE_STATUS_HISTORY_DAYS + }), + staleTime: durationToMs(5, "seconds") + }); const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -296,7 +306,14 @@ export default function SitesTable({ if (originalRow.type == "local") { return -; } - return ; + const data = statusHistoryQuery.data?.[row.original.id]; + return ( + + ); } }, { @@ -361,12 +378,9 @@ export default function SitesTable({ let updateAvailable = Boolean( latestNewtVersion && - originalRow.newtVersion && - semver.valid(originalRow.newtVersion) && - semver.lt( - originalRow.newtVersion, - latestNewtVersion - ) + originalRow.newtVersion && + semver.valid(originalRow.newtVersion) && + semver.lt(originalRow.newtVersion, latestNewtVersion) ); if (originalRow.type === "newt") { diff --git a/src/components/UptimeMiniBar.tsx b/src/components/UptimeMiniBar.tsx index b7e684c8b..454f44d66 100644 --- a/src/components/UptimeMiniBar.tsx +++ b/src/components/UptimeMiniBar.tsx @@ -1,15 +1,14 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { orgQueries } from "@app/lib/queries"; import { Tooltip, TooltipContent, TooltipTrigger } from "@app/components/ui/tooltip"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { createApiClient } from "@app/lib/api"; import { cn } from "@app/lib/cn"; +import { orgQueries } from "@app/lib/queries"; +import type { StatusHistoryResponse } from "@server/lib/statusHistory"; +import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; function formatDuration(seconds: number): string { @@ -46,21 +45,16 @@ type UptimeMiniBarProps = { days?: number; }; -export default function UptimeMiniBar({ +export default function UptimeMiniBarWrapper({ orgId, siteId, resourceId, healthCheckId, days = 30 }: UptimeMiniBarProps) { - const t = useTranslations(); - const api = createApiClient(useEnvContext()); - const siteQuery = useQuery({ ...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }), - enabled: siteId != null, - meta: { api }, - staleTime: 5 * 60 * 1000 + enabled: siteId != null }); const hcQuery = useQuery({ @@ -69,16 +63,12 @@ export default function UptimeMiniBar({ healthCheckId: healthCheckId ?? 0, days }), - enabled: healthCheckId != null && siteId == null && resourceId == null, - meta: { api }, - staleTime: 5 * 60 * 1000 + enabled: healthCheckId != null && siteId == null && resourceId == null }); const resourceQuery = useQuery({ ...orgQueries.resourceStatusHistory({ resourceId, days }), - enabled: resourceId != null && siteId == null && healthCheckId == null, - meta: { api }, - staleTime: 5 * 60 * 1000 + enabled: resourceId != null && siteId == null && healthCheckId == null }); const { data, isLoading } = @@ -88,6 +78,22 @@ export default function UptimeMiniBar({ ? resourceQuery : hcQuery; + return ; +} + +type UptimeMiniBarUIProps = { + data?: StatusHistoryResponse; + isLoading?: boolean; + days?: number; +}; + +export function UptimeMiniBar({ + data, + isLoading, + days = 30 +}: UptimeMiniBarUIProps) { + const t = useTranslations(); + if (isLoading) { return (
@@ -138,7 +144,8 @@ export default function UptimeMiniBar({ {formatDate(day.date)}
- {day.status === "no_data" || day.status === "unknown" + {day.status === "no_data" || + day.status === "unknown" ? t("uptimeNoData") : `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
@@ -159,4 +166,4 @@ export default function UptimeMiniBar({
); -} \ No newline at end of file +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index b552fd26c..a56271991 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -47,7 +47,10 @@ import { remote } from "./api"; import { durationToMs } from "./durationToMs"; import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; -import { StatusHistoryResponse } from "@server/lib/statusHistory"; +import { + StatusHistoryResponse, + type BatchedStatusHistoryResponse +} from "@server/lib/statusHistory"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; import type { GetResourcePolicyResponse } from "@server/routers/policy"; import type { @@ -642,24 +645,33 @@ export const orgQueries = { }), batchedSiteStatusHistory: ({ siteIds, + orgId, days = 90 }: { + orgId: string; siteIds: number[]; days?: number; }) => queryOptions({ queryKey: [ - "SITE_STATUS_HISTORY", - "BATCHED", + "ORG", + orgId, + "BATCHED_SITE_STATUS_HISTORY", siteIds, days ] as const, queryFn: async ({ signal, meta }) => { - // TODO - // const res = await meta!.api.get< - // AxiosResponse - // >(`/site/${siteId}/status-history?days=${days}`, { signal }); - // return res.data.data; + const sp = new URLSearchParams([ + ["days", days.toString()], + ...siteIds.map((id) => ["siteIds", id.toString()]) + ]); + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/site-status-histories?${sp.toString()}`, { + signal + }); + return res.data.data; } }), siteStatusHistory: ({ @@ -671,6 +683,7 @@ export const orgQueries = { }) => queryOptions({ queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const, + staleTime: durationToMs(5, "seconds"), queryFn: async ({ signal, meta }) => { const tzOffsetMinutes = -new Date().getTimezoneOffset(); const res = await meta!.api.get< @@ -692,6 +705,7 @@ export const orgQueries = { }) => queryOptions({ queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const, + staleTime: durationToMs(5, "seconds"), queryFn: async ({ signal, meta }) => { const tzOffsetMinutes = -new Date().getTimezoneOffset(); const res = await meta!.api.get< @@ -714,6 +728,7 @@ export const orgQueries = { days?: number; }) => queryOptions({ + staleTime: durationToMs(5, "seconds"), queryKey: [ "HC_STATUS_HISTORY", orgId, From 70bddba55b126de0fba0a60f4cbea1b7bb97f225 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 21 Jul 2026 20:29:16 +0100 Subject: [PATCH 104/494] =?UTF-8?q?=E2=9C=A8=20Make=20batched=20status=20h?= =?UTF-8?q?istory=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/statusHistory.ts | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index f116d74e2..da579d3d3 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -37,9 +37,9 @@ export async function getCachedStatusHistory( tzOffsetMinutes ); const cached = await cache.get(cacheKey); - // if (cached !== undefined) { - // return cached; - // } + if (cached !== undefined) { + return cached; + } console.time(`[getCachedStatusHistory/${entityType}=${entityId}]`); // Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query @@ -344,10 +344,6 @@ export async function getBatchedStatusHistory( ) .orderBy(asc(statusHistory.timestamp)); - console.log({ - events - }); - // Fetch the last known state before the window so that entities that // haven't changed status recently still show the correct status rather // than appearing as "no_data". @@ -371,17 +367,12 @@ export async function getBatchedStatusHistory( } > = {}; - for (const event of events) { - if (!eventStatusMap[event.entityId]) { - eventStatusMap[event.entityId] = { - events: [], - lastKnownEvent: - lastKnownEvents.find( - (ev) => ev.entityId === event.entityId - ) ?? null - }; - } - eventStatusMap[event.entityId].events.push(event); + for (const entityId of entityIds) { + eventStatusMap[entityId] = { + events: events.filter((ev) => ev.entityId === entityId), + lastKnownEvent: + lastKnownEvents.find((ev) => ev.entityId === entityId) ?? null + }; } const result: BatchedStatusHistoryResponse = {}; From 9867d3c876cda24fff3a3678f6d9391edc5cfa8c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 21 Jul 2026 20:31:57 +0100 Subject: [PATCH 105/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20`const`=20instead?= =?UTF-8?q?=20of=20`let`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SitesTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 1f745269c..9c5a2b634 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -376,7 +376,7 @@ export default function SitesTable({ cell: ({ row }) => { const originalRow = row.original; - let updateAvailable = Boolean( + const updateAvailable = Boolean( latestNewtVersion && originalRow.newtVersion && semver.valid(originalRow.newtVersion) && From c92d5096c42c340bd897e204c31afbc687c2928b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:15:07 +0000 Subject: [PATCH 106/494] Bump body-parser from 2.2.2 to 2.3.0 Bumps [body-parser](https://github.com/expressjs/body-parser) from 2.2.2 to 2.3.0. - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0) --- updated-dependencies: - dependency-name: body-parser dependency-version: 2.3.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 378a1f26c..e68fca2a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9214,20 +9214,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -9237,6 +9237,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/body-parser/node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", From a33de8268b87ae21c4a35cb87fecba81d5afa43f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 22 Jul 2026 19:32:53 +0100 Subject: [PATCH 107/494] =?UTF-8?q?=F0=9F=90=9B=20Get=20last=20known=20eve?= =?UTF-8?q?nts=20working=20with=20sqlite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/statusHistory.ts | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index da579d3d3..ecb3f369e 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { db, logsDb, statusHistory } from "@server/db"; -import { and, eq, gte, lt, asc, desc, inArray } from "drizzle-orm"; +import { and, eq, gte, lt, asc, desc, inArray, max, sql } from "drizzle-orm"; import { regionalCache as cache } from "#dynamic/lib/cache"; const STATUS_HISTORY_CACHE_TTL = 60; // seconds @@ -347,8 +347,26 @@ export async function getBatchedStatusHistory( // Fetch the last known state before the window so that entities that // haven't changed status recently still show the correct status rather // than appearing as "no_data". - const lastKnownEvents = await logsDb - .selectDistinctOn([statusHistory.entityId, statusHistory.timestamp]) + + /** + * If we used only postgres, we would have used `SELECT DISTINCT ON` to get the + * latest event for each `entityId`, + * but it doesn't work on SQLite, so instead we use a subquery, + * the `ROW_NUMBER() OVER PARTITION` allows to assign a number + * to each row ordered by the timestamp, the number 1 is the first one appearing in + * the specified order, then the next and more, we only want the highest timestamp, + * so we get for `row_number=1` + */ + const lastKnowEventsSub = logsDb + .select({ + entityId: statusHistory.entityId, + status: statusHistory.status, + timestamp: statusHistory.timestamp, + row_number: + sql`ROW_NUMBER() OVER (PARTITION BY ${statusHistory.entityId} ORDER BY ${statusHistory.timestamp} DESC)`.as( + "row_number" + ) + }) .from(statusHistory) .where( and( @@ -357,7 +375,16 @@ export async function getBatchedStatusHistory( lt(statusHistory.timestamp, startSec) ) ) - .orderBy(desc(statusHistory.timestamp)); + .as("sub"); + + const lastKnownEvents = await logsDb + .select({ + entityId: lastKnowEventsSub.entityId, + status: lastKnowEventsSub.status, + timestamp: lastKnowEventsSub.timestamp + }) + .from(lastKnowEventsSub) + .where(eq(lastKnowEventsSub.row_number, 1)); const eventStatusMap: Record< number, From b1558b09b1708cff195c40f5fbabc73d77a2a3f0 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 22 Jul 2026 19:33:00 +0100 Subject: [PATCH 108/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SitesTable.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 9c5a2b634..5fdaf05a0 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -52,13 +52,12 @@ import { } from "./ui/controlled-data-table"; import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { durationToMs } from "@app/lib/durationToMs"; +import { orgQueries, productUpdatesQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; -import { useQuery } from "@tanstack/react-query"; -import { orgQueries, productUpdatesQueries } from "@app/lib/queries"; -import semver from "semver"; -import { durationToMs } from "@app/lib/durationToMs"; export type SiteRow = { id: number; From 262f7bd0903b3be9bef0e03b9920d30e63a30804 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 22 Jul 2026 20:57:32 +0100 Subject: [PATCH 109/494] =?UTF-8?q?=E2=9C=A8=20resource=20status=20histori?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/statusHistory.ts | 10 +-- server/routers/external.ts | 7 ++ .../resource/getBatchedStatusHistory.ts | 83 +++++++++++++++++++ server/routers/resource/index.ts | 1 + src/components/PublicResourcesTable.tsx | 36 +++++++- src/components/SitesTable.tsx | 2 +- src/lib/queries.ts | 43 +++++++++- 7 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 server/routers/resource/getBatchedStatusHistory.ts diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index ecb3f369e..0ec338d93 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -317,12 +317,6 @@ export async function getBatchedStatusHistory( days: number, tzOffsetMinutes: number = 0 ): Promise { - // const cacheKey = statusHistoryCacheKey(entityType, entityId, days); - // const cached = await cache.get(cacheKey); - // if (cached !== undefined) { - // return cached; - // } - console.time( `[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]` ); @@ -412,7 +406,8 @@ export async function getBatchedStatusHistory( const { buckets, totalDowntime } = computeBuckets( event.events, days, - priorStatus + priorStatus, + tzOffsetMinutes ); const totalWindow = days * 86400; const overallUptime = @@ -436,6 +431,5 @@ export async function getBatchedStatusHistory( console.timeEnd( `[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]` ); - // await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); return result; } diff --git a/server/routers/external.ts b/server/routers/external.ts index e6d487f56..fc3170365 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -476,6 +476,13 @@ authenticated.get( resource.getResourceStatusHistory ); +authenticated.get( + "/org/:orgId/resource-status-histories", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listResources), + resource.getBatchedResourceStatusHistory +); + authenticated.get( "/org/:orgId/resources", verifyOrgAccess, diff --git a/server/routers/resource/getBatchedStatusHistory.ts b/server/routers/resource/getBatchedStatusHistory.ts new file mode 100644 index 000000000..870f20287 --- /dev/null +++ b/server/routers/resource/getBatchedStatusHistory.ts @@ -0,0 +1,83 @@ +import response from "@server/lib/response"; +import { + getBatchedStatusHistory, + type BatchedStatusHistoryResponse +} from "@server/lib/statusHistory"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const resourceIdParamsSchema = z.object({ + days: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 90)), + // Minutes to add to UTC to get the requesting client's local time + // (e.g. Australia/Sydney standard time is 600). Optional and + // defaults to 0 (UTC) so older clients keep the prior behavior. + tzOffsetMinutes: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 0)), + resourceIds: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + const raw = Array.isArray(val) ? val : [val]; + const nums = raw + .map((v) => + typeof v === "string" ? parseInt(v, 10) : Number(v) + ) + .filter((n) => Number.isInteger(n) && n > 0); + const unique = [...new Set(nums)]; + return unique.length ? unique : undefined; + }, z.array(z.number().int().positive())) + .openapi({ + description: "Filter by resourceIds (repeat query param)" + }) +}); + +export async function getBatchedResourceStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = resourceIdParamsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "resource"; + const { days, resourceIds, tzOffsetMinutes } = parsedQuery.data; + + const data = await getBatchedStatusHistory( + entityType, + resourceIds, + days, + tzOffsetMinutes + ); + + return response(res, { + data, + success: true, + error: false, + message: "Status history retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 83dbdea2a..709bf7340 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -33,4 +33,5 @@ export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; export * from "./getStatusHistory"; +export * from "./getBatchedStatusHistory"; export * from "./getResourcePolicies"; diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index 369fe0601..0b2e167fe 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -31,10 +31,13 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +import { durationToMs } from "@app/lib/durationToMs"; +import { orgQueries } from "@app/lib/queries"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { UpdateResourceResponse } from "@server/routers/resource"; +import { useQuery } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; import { @@ -69,7 +72,7 @@ import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { ControlledDataTable } from "./ui/controlled-data-table"; -import UptimeMiniBar from "./UptimeMiniBar"; +import { UptimeMiniBar } from "./UptimeMiniBar"; import { type SelectedLabel } from "./labels-selector"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { useLocalLabels } from "@app/hooks/useLocalLabels"; @@ -127,6 +130,8 @@ const booleanSearchFilterSchema = z .optional() .catch(undefined); +const RESOURCE_STATUS_HISTORY_DAYS = 30; + export default function PublicResourcesTable({ resources, orgId, @@ -155,6 +160,21 @@ export default function PublicResourcesTable({ const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); + // Only http resources show an uptime bar, so don't ask for the others + const statusHistoryResourceIds = useMemo( + () => resources.filter((r) => r.mode === "http").map((r) => r.id), + [resources] + ); + + const statusHistoryQuery = useQuery({ + ...orgQueries.batchedResourceStatusHistory({ + orgId, + resourceIds: statusHistoryResourceIds, + days: RESOURCE_STATUS_HISTORY_DAYS + }), + enabled: statusHistoryResourceIds.length > 0 + }); + const refreshData = () => { startTransition(() => { try { @@ -407,7 +427,11 @@ export default function PublicResourcesTable({ return -; } return ( - + ); } }, @@ -625,7 +649,13 @@ export default function PublicResourcesTable({ ]; return cols; - }, [orgId, t, searchParams]); + }, [ + orgId, + t, + searchParams, + statusHistoryQuery.data, + statusHistoryQuery.isLoading + ]); function handleFilterChange( column: string, diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 5fdaf05a0..199810eb3 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -120,7 +120,7 @@ export default function SitesTable({ siteIds: sites.map((s) => s.id), days: SITE_STATUS_HISTORY_DAYS }), - staleTime: durationToMs(5, "seconds") + enabled: sites.length > 0 }); const api = createApiClient(useEnvContext()); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index a56271991..18568f721 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -661,8 +661,12 @@ export const orgQueries = { days ] as const, queryFn: async ({ signal, meta }) => { + // Negated because getTimezoneOffset() returns UTC - local, + // while the API expects minutes to add to UTC to get local + const tzOffsetMinutes = -new Date().getTimezoneOffset(); const sp = new URLSearchParams([ ["days", days.toString()], + ["tzOffsetMinutes", tzOffsetMinutes.toString()], ...siteIds.map((id) => ["siteIds", id.toString()]) ]); @@ -672,7 +676,44 @@ export const orgQueries = { signal }); return res.data.data; - } + }, + staleTime: durationToMs(5, "seconds") + }), + batchedResourceStatusHistory: ({ + resourceIds, + orgId, + days = 90 + }: { + orgId: string; + resourceIds: number[]; + days?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "BATCHED_RESOURCE_STATUS_HISTORY", + resourceIds, + days + ] as const, + queryFn: async ({ signal, meta }) => { + // Negated because getTimezoneOffset() returns UTC - local, + // while the API expects minutes to add to UTC to get local + const tzOffsetMinutes = -new Date().getTimezoneOffset(); + const sp = new URLSearchParams([ + ["days", days.toString()], + ["tzOffsetMinutes", tzOffsetMinutes.toString()], + ...resourceIds.map((id) => ["resourceIds", id.toString()]) + ]); + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/resource-status-histories?${sp.toString()}`, { + signal + }); + return res.data.data; + }, + staleTime: durationToMs(5, "seconds") }), siteStatusHistory: ({ siteId, From b0ff7d2707a523200f445e18757abd99e392449b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 22 Jul 2026 21:06:40 +0100 Subject: [PATCH 110/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20batch=20healthchec?= =?UTF-8?q?k=20status=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/private/routers/external.ts | 8 ++ .../healthChecks/getBatchedStatusHistory.ts | 96 +++++++++++++++++++ server/private/routers/healthChecks/index.ts | 1 + src/components/HealthChecksTable.tsx | 30 +++++- src/lib/queries.ts | 40 ++++++++ 5 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 server/private/routers/healthChecks/getBatchedStatusHistory.ts diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 75a5b8d48..d2dc7bec1 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -853,6 +853,14 @@ authenticated.get( healthChecks.getHealthCheckStatusHistory ); +authenticated.get( + "/org/:orgId/health-check-status-histories", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getTarget), + healthChecks.getBatchedHealthCheckStatusHistory +); + authenticated.get( "/client/:clientId/verify-associations-cache", verifyClientAccess, diff --git a/server/private/routers/healthChecks/getBatchedStatusHistory.ts b/server/private/routers/healthChecks/getBatchedStatusHistory.ts new file mode 100644 index 000000000..38c4903ec --- /dev/null +++ b/server/private/routers/healthChecks/getBatchedStatusHistory.ts @@ -0,0 +1,96 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import response from "@server/lib/response"; +import { + getBatchedStatusHistory, + type BatchedStatusHistoryResponse +} from "@server/lib/statusHistory"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const healthCheckIdParamsSchema = z.object({ + days: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 90)), + // Minutes to add to UTC to get the requesting client's local time + // (e.g. Australia/Sydney standard time is 600). Optional and + // defaults to 0 (UTC) so older clients keep the prior behavior. + tzOffsetMinutes: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 0)), + healthCheckIds: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + const raw = Array.isArray(val) ? val : [val]; + const nums = raw + .map((v) => + typeof v === "string" ? parseInt(v, 10) : Number(v) + ) + .filter((n) => Number.isInteger(n) && n > 0); + const unique = [...new Set(nums)]; + return unique.length ? unique : undefined; + }, z.array(z.number().int().positive())) + .openapi({ + description: "Filter by healthCheckIds (repeat query param)" + }) +}); + +export async function getBatchedHealthCheckStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = healthCheckIdParamsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "health_check"; + const { days, healthCheckIds, tzOffsetMinutes } = parsedQuery.data; + + const data = await getBatchedStatusHistory( + entityType, + healthCheckIds, + days, + tzOffsetMinutes + ); + + return response(res, { + data, + success: true, + error: false, + message: "Status history retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/healthChecks/index.ts b/server/private/routers/healthChecks/index.ts index 665ae5cca..a2e01c2ae 100644 --- a/server/private/routers/healthChecks/index.ts +++ b/server/private/routers/healthChecks/index.ts @@ -16,3 +16,4 @@ export * from "./createHealthCheck"; export * from "./updateHealthCheck"; export * from "./deleteHealthCheck"; export * from "./getStatusHistory"; +export * from "./getBatchedStatusHistory"; diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 02e725fed..b4f273e5a 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -1,6 +1,6 @@ "use client"; -import UptimeMiniBar from "@app/components/UptimeMiniBar"; +import { UptimeMiniBar } from "@app/components/UptimeMiniBar"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import HealthCheckCredenza, { @@ -51,6 +51,8 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; type StandaloneHealthChecksTableProps = { orgId: string; @@ -81,6 +83,8 @@ function formatTarget(row: HealthCheckRow): string { return `${scheme}://${host}${port}${path}`; } +const HEALTH_CHECK_STATUS_HISTORY_DAYS = 30; + export default function HealthChecksTable({ orgId, healthChecks, @@ -157,6 +161,20 @@ export default function HealthChecksTable({ const rows = healthChecks; + const healthCheckIds = useMemo( + () => rows.map((r) => r.targetHealthCheckId), + [rows] + ); + + const statusHistoryQuery = useQuery({ + ...orgQueries.batchedHealthCheckStatusHistory({ + orgId, + healthCheckIds, + days: HEALTH_CHECK_STATUS_HISTORY_DAYS + }), + enabled: healthCheckIds.length > 0 + }); + function refreshList() { startRefresh(() => { router.refresh(); @@ -547,9 +565,13 @@ export default function HealthChecksTable({ cell: ({ row }) => { return ( ); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 18568f721..8007fd2f1 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -679,6 +679,46 @@ export const orgQueries = { }, staleTime: durationToMs(5, "seconds") }), + batchedHealthCheckStatusHistory: ({ + healthCheckIds, + orgId, + days = 90 + }: { + orgId: string; + healthCheckIds: number[]; + days?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "BATCHED_HEALTH_CHECK_STATUS_HISTORY", + healthCheckIds, + days + ] as const, + queryFn: async ({ signal, meta }) => { + // Negated because getTimezoneOffset() returns UTC - local, + // while the API expects minutes to add to UTC to get local + const tzOffsetMinutes = -new Date().getTimezoneOffset(); + const sp = new URLSearchParams([ + ["days", days.toString()], + ["tzOffsetMinutes", tzOffsetMinutes.toString()], + ...healthCheckIds.map((id) => [ + "healthCheckIds", + id.toString() + ]) + ]); + + const res = await meta!.api.get< + AxiosResponse + >( + `/org/${orgId}/health-check-status-histories?${sp.toString()}`, + { signal } + ); + return res.data.data; + }, + staleTime: durationToMs(5, "seconds") + }), batchedResourceStatusHistory: ({ resourceIds, orgId, From 58004a8ec914c89b55db0fa7bfeff4d366bc3828 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 22 Jul 2026 21:25:31 +0100 Subject: [PATCH 111/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20little=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SitesTable.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 199810eb3..2c4ecd1e5 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -114,13 +114,15 @@ export default function SitesTable({ const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); + const siteIds = sites.map((s) => s.id); + const statusHistoryQuery = useQuery({ ...orgQueries.batchedSiteStatusHistory({ orgId, - siteIds: sites.map((s) => s.id), + siteIds, days: SITE_STATUS_HISTORY_DAYS }), - enabled: sites.length > 0 + enabled: siteIds.length > 0 }); const api = createApiClient(useEnvContext()); From b3880e5c02ead9359e6d419953891287953c00bb Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 22 Jul 2026 21:34:13 +0100 Subject: [PATCH 112/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routers/certificates/restartCertificate.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/server/private/routers/certificates/restartCertificate.ts b/server/private/routers/certificates/restartCertificate.ts index 9c3cbb8cc..05614b078 100644 --- a/server/private/routers/certificates/restartCertificate.ts +++ b/server/private/routers/certificates/restartCertificate.ts @@ -11,18 +11,16 @@ * This file is not licensed under the AGPLv3. */ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; import { certificates, db } from "@server/db"; -import { sites } from "@server/db"; -import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; import logger from "@server/logger"; -import stoi from "@server/lib/stoi"; +import { registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; const restartCertificateParamsSchema = z.strictObject({ certId: z.coerce.number().int().positive(), From ecf008a8d9baade72af02ea68409285259fd42c3 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Jul 2026 17:22:17 -0400 Subject: [PATCH 113/494] Improve cert retreival and new newt combined certs --- server/lib/certificates.ts | 1 + server/lib/clientVersionChecks.ts | 16 +++ server/lib/ip.ts | 124 ++++++++++++++++-- server/routers/newt/buildConfiguration.ts | 10 +- server/routers/newt/certs.ts | 75 +++++++++++ .../newt/handleNewtGetConfigMessage.ts | 18 ++- server/routers/newt/sync.ts | 19 ++- 7 files changed, 245 insertions(+), 18 deletions(-) create mode 100644 server/routers/newt/certs.ts diff --git a/server/lib/certificates.ts b/server/lib/certificates.ts index f03f37f25..6d24d2996 100644 --- a/server/lib/certificates.ts +++ b/server/lib/certificates.ts @@ -5,6 +5,7 @@ export async function getValidCertificatesForDomains( Array<{ id: number; domain: string; + queriedDomain: string; wildcard: boolean | null; certFile: string | null; keyFile: string | null; diff --git a/server/lib/clientVersionChecks.ts b/server/lib/clientVersionChecks.ts index 330959e7c..4c9a11c14 100644 --- a/server/lib/clientVersionChecks.ts +++ b/server/lib/clientVersionChecks.ts @@ -18,3 +18,19 @@ export function canCompress( return false; } } + +// Whether this newt client understands `tlsCertId` references into the +// sync message's `certs` array, instead of requiring each target to carry +// its own inline `tlsCert`/`tlsKey` PEM data. Bump the version floor here to +// match whatever release first ships the newt-side support. +export function supportsCertReferences( + clientVersion: string | null | undefined +): boolean { + try { + if (!clientVersion) return false; + if (!semver.valid(clientVersion)) return false; + return semver.gte(clientVersion, "1.16.0"); + } catch { + return false; + } +} diff --git a/server/lib/ip.ts b/server/lib/ip.ts index fb161df1c..e8f589424 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -5,6 +5,7 @@ import config from "@server/lib/config"; import z from "zod"; import logger from "@server/logger"; import semver from "semver"; +import { createHash } from "crypto"; import { getValidCertificatesForDomains } from "#dynamic/lib/certificates"; import { lockManager } from "#dynamic/lib/lock"; @@ -648,21 +649,101 @@ export type SubnetProxyTargetV2 = { httpTargets?: HTTPTarget[]; tlsCert?: string; tlsKey?: string; + tlsCertId?: string; // references an entry in the sync message's top-level `certs` array instead of inlining tlsCert/tlsKey }; +export type CertRef = { id: string; cert: string; key: string }; + +/** + * Replaces each target's inline tlsCert/tlsKey with a tlsCertId reference + * into a deduplicated certs array, so that many targets sharing the same + * certificate (e.g. a wildcard cert used by thousands of site resources) + * only need that certificate sent once per sync message. + */ +export function dedupeCertsForTargets( + targetsV2: SubnetProxyTargetV2[] +): { targets: SubnetProxyTargetV2[]; certs: CertRef[] } { + const idByContent = new Map(); + const certs: CertRef[] = []; + + const targets = targetsV2.map((target) => { + if (!target.tlsCert || !target.tlsKey) { + return target; + } + + const contentKey = `${target.tlsCert}|${target.tlsKey}`; + let id = idByContent.get(contentKey); + if (!id) { + id = createHash("sha1").update(contentKey).digest("hex").slice(0, 16); + idByContent.set(contentKey, id); + certs.push({ id, cert: target.tlsCert, key: target.tlsKey }); + } + + const { tlsCert, tlsKey, ...rest } = target; + return { ...rest, tlsCertId: id }; + }); + + return { targets, certs }; +} + export type HTTPTarget = { destAddr: string; // must be an IP or hostname destPort: number; scheme: "http" | "https"; }; +export type CertByDomain = Map; + +/** + * Fetches the TLS certificates for every enabled, SSL-enabled HTTP site + * resource's fullDomain in a single batched call, instead of one call per + * resource. Many resources commonly resolve to the very same certificate + * (e.g. a wildcard covering the org's domain), so batching turns what would + * be N concurrent DB/cache round-trips into one, and a lookup failure fails + * loudly for the whole batch rather than silently dropping the cert on a + * random subset of otherwise-identical resources under load. + */ +export async function batchFetchCertsForSiteResources( + allSiteResources: SiteResource[] +): Promise { + const domains = new Set( + allSiteResources + .filter((r) => r.enabled && r.mode === "http" && r.ssl && r.fullDomain) + .map((r) => r.fullDomain as string) + ); + + const certByDomain: CertByDomain = new Map(); + if (domains.size === 0) { + return certByDomain; + } + + try { + const certResults = await getValidCertificatesForDomains(domains, true); + for (const cert of certResults) { + if (cert.certFile && cert.keyFile) { + certByDomain.set(cert.queriedDomain, { + certFile: cert.certFile, + keyFile: cert.keyFile + }); + } + } + } catch (err) { + logger.error( + `Failed to batch-retrieve certificates for ${domains.size} domain(s): ${err}` + ); + } + + return certByDomain; +} + export async function generateSubnetProxyTargetV2( siteResource: SiteResource, clients: { clientId: number; pubKey: string | null; subnet: string | null; - }[] + }[], + certByDomain?: CertByDomain ): Promise { if (!siteResource.enabled) { logger.debug( @@ -750,23 +831,40 @@ export async function generateSubnetProxyTargetV2( let tlsKey: string | undefined; if (siteResource.ssl && siteResource.fullDomain) { - try { - const certs = await getValidCertificatesForDomains( - new Set([siteResource.fullDomain]), - true - ); - if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) { - tlsCert = certs[0].certFile; - tlsKey = certs[0].keyFile; + if (certByDomain) { + // Caller batch-fetched certs for all resources up front (the + // common, high-scale path) — just look up this resource's + // domain rather than issuing its own DB/cache round-trip. + const cert = certByDomain.get(siteResource.fullDomain); + if (cert) { + tlsCert = cert.certFile; + tlsKey = cert.keyFile; } else { logger.warn( `No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.fullDomain}` ); } - } catch (err) { - logger.error( - `Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.fullDomain}: ${err}` - ); + } else { + // No batched map supplied by the caller — fall back to a + // single-domain lookup for this resource alone. + try { + const certs = await getValidCertificatesForDomains( + new Set([siteResource.fullDomain]), + true + ); + if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) { + tlsCert = certs[0].certFile; + tlsKey = certs[0].keyFile; + } else { + logger.warn( + `No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.fullDomain}` + ); + } + } catch (err) { + logger.error( + `Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.fullDomain}: ${err}` + ); + } } } diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index a12d035a5..ee33a2c94 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -19,6 +19,7 @@ import { eq, and, inArray } from "drizzle-orm"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { + batchFetchCertsForSiteResources, formatEndpoint, generateSubnetProxyTargetV2, SubnetProxyTargetV2 @@ -206,11 +207,18 @@ export async function buildClientConfigurationForNewtClient( }); } + // Batch-fetch certs for every SSL-enabled HTTP resource's domain in one + // call rather than letting each resource fetch its own — with thousands + // of resources this avoids a concurrent DB/cache stampede for what is + // often the very same (e.g. wildcard) certificate. + const certByDomain = await batchFetchCertsForSiteResources(allSiteResources); + const resourceTargetsArr = await Promise.all( allSiteResources.map((resource) => generateSubnetProxyTargetV2( resource, - clientsByResourceId.get(resource.siteResourceId) ?? [] + clientsByResourceId.get(resource.siteResourceId) ?? [], + certByDomain ) ) ); diff --git a/server/routers/newt/certs.ts b/server/routers/newt/certs.ts new file mode 100644 index 000000000..8898ee564 --- /dev/null +++ b/server/routers/newt/certs.ts @@ -0,0 +1,75 @@ +import { sendToClient } from "#dynamic/routers/ws"; +import logger from "@server/logger"; +import { + canCompress, + supportsCertReferences +} from "@server/lib/clientVersionChecks"; +import { CertRef } from "@server/lib/ip"; + +/** + * Pushes an incremental set of certs to a newt client outside of a full + * newt/sync or newt/wg/receive-config, e.g. after a certificate renewal so + * that every target referencing it (by tlsCertId) picks up the new material + * without waiting for the next full resync. + */ +export async function sendCertsAdd( + newtId: string, + certs: CertRef[], + version?: string | null +) { + if (certs.length === 0) { + return; + } + + if (!supportsCertReferences(version)) { + logger.debug( + `Newt ${newtId} (version ${version}) does not support cert references, skipping certs/add` + ); + return; + } + + await sendToClient( + newtId, + { + type: "newt/certs/add", + data: certs + }, + { + incrementConfigVersion: true, + compress: canCompress(version, "newt") + } + ); +} + +/** + * Tells a newt client to drop the given cert IDs, e.g. once the server knows + * no target references them anymore. + */ +export async function sendCertsRemove( + newtId: string, + certIds: string[], + version?: string | null +) { + if (certIds.length === 0) { + return; + } + + if (!supportsCertReferences(version)) { + logger.debug( + `Newt ${newtId} (version ${version}) does not support cert references, skipping certs/remove` + ); + return; + } + + await sendToClient( + newtId, + { + type: "newt/certs/remove", + data: { ids: certIds } + }, + { + incrementConfigVersion: true, + compress: canCompress(version, "newt") + } + ); +} diff --git a/server/routers/newt/handleNewtGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts index d99dd89ef..cb8b8d931 100644 --- a/server/routers/newt/handleNewtGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -7,7 +7,11 @@ import { eq } from "drizzle-orm"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; import { convertTargetsIfNecessary } from "../client/targets"; -import { canCompress } from "@server/lib/clientVersionChecks"; +import { + canCompress, + supportsCertReferences +} from "@server/lib/clientVersionChecks"; +import { dedupeCertsForTargets } from "@server/lib/ip"; import config from "@server/lib/config"; import { waitForSiteRebuildIdle } from "@server/lib/rebuildClientAssociations"; @@ -119,7 +123,16 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { exitNode ); - const targetsToSend = await convertTargetsIfNecessary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format + // Older newt clients only understand inline tlsCert/tlsKey on each + // target, so only switch to certId references once we know the client + // can resolve them. + let dedupedTargets = targets; + let certs: { id: string; cert: string; key: string }[] = []; + if (supportsCertReferences(newt.version)) { + ({ targets: dedupedTargets, certs } = dedupeCertsForTargets(targets)); + } + + const targetsToSend = await convertTargetsIfNecessary(newt.newtId, dedupedTargets); // for backward compatibility with old newt versions that don't support the new target format return { message: { @@ -128,6 +141,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { ipAddress: site.address, peers, targets: targetsToSend, + certs, chainId: chainId } }, diff --git a/server/routers/newt/sync.ts b/server/routers/newt/sync.ts index 30e2d59f8..e88f71bee 100644 --- a/server/routers/newt/sync.ts +++ b/server/routers/newt/sync.ts @@ -6,7 +6,11 @@ import { buildClientConfigurationForNewtClient, buildTargetConfigurationForNewtClient } from "./buildConfiguration"; -import { canCompress } from "@server/lib/clientVersionChecks"; +import { + canCompress, + supportsCertReferences +} from "@server/lib/clientVersionChecks"; +import { dedupeCertsForTargets } from "@server/lib/ip"; export async function sendNewtSyncMessage(newt: Newt, site: Site) { const { @@ -28,6 +32,16 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) { site, exitNode ); + + // Older newt clients only understand inline tlsCert/tlsKey on each + // target, so only switch to certId references once we know the client + // can resolve them. + let clientTargets = targets; + let certs: { id: string; cert: string; key: string }[] = []; + if (supportsCertReferences(newt.version)) { + ({ targets: clientTargets, certs } = dedupeCertsForTargets(targets)); + } + await sendToClient( newt.newtId, { @@ -39,7 +53,8 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) { }, healthCheckTargets: validHealthCheckTargets, peers: peers, - clientTargets: targets, + clientTargets: clientTargets, + certs: certs, browserGatewayTargets: browserGatewayTargets, remoteExitNodeSubnets: remoteExitNodeSubnets } From 8a68b899f548b77a129d643a5eba973b38ba9a7c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:33:10 +0000 Subject: [PATCH 114/494] Bump golang.org/x/term Bumps the go-install-dependencies group with 1 update in the /install directory: [golang.org/x/term](https://github.com/golang/term). Updates `golang.org/x/term` from 0.44.0 to 0.45.0 - [Commits](https://github.com/golang/term/compare/v0.44.0...v0.45.0) --- updated-dependencies: - dependency-name: golang.org/x/term dependency-version: 0.45.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-install-dependencies ... Signed-off-by: dependabot[bot] --- install/go.mod | 4 ++-- install/go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/install/go.mod b/install/go.mod index 69dc9e5fc..a15cc4491 100644 --- a/install/go.mod +++ b/install/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 - golang.org/x/term v0.44.0 + golang.org/x/term v0.45.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -33,6 +33,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.46.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.23.0 // indirect ) diff --git a/install/go.sum b/install/go.sum index 704142a64..275bab0f8 100644 --- a/install/go.sum +++ b/install/go.sum @@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= From 832e382888448ba2758feb554284e3178c2338fd Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 23 Jul 2026 22:00:31 +0100 Subject: [PATCH 115/494] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20batch=20certifica?= =?UTF-8?q?tes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/schema.ts | 2 +- .../certificates/getBatchedCertificates.ts | 220 ++++++++++++++++++ 3 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 server/private/routers/certificates/getBatchedCertificates.ts diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 09e12e6d0..17a58d94d 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -18,7 +18,7 @@ export const domains = pgTable("domains", { domainId: varchar("domainId").primaryKey(), baseDomain: varchar("baseDomain").notNull(), configManaged: boolean("configManaged").notNull().default(false), - type: varchar("type"), // "ns", "cname", "wildcard" + type: varchar("type").$type<"ns" | "cname" | "wildcard">(), verified: boolean("verified").notNull().default(false), failed: boolean("failed").notNull().default(false), tries: integer("tries").notNull().default(0), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 61f3c2d6e..d0db18555 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -15,7 +15,7 @@ export const domains = sqliteTable("domains", { configManaged: integer("configManaged", { mode: "boolean" }) .notNull() .default(false), - type: text("type"), // "ns", "cname", "wildcard" + type: text("type").$type<"ns" | "cname" | "wildcard">(), verified: integer("verified", { mode: "boolean" }).notNull().default(false), failed: integer("failed", { mode: "boolean" }).notNull().default(false), tries: integer("tries").notNull().default(0), diff --git a/server/private/routers/certificates/getBatchedCertificates.ts b/server/private/routers/certificates/getBatchedCertificates.ts new file mode 100644 index 000000000..6962b9c53 --- /dev/null +++ b/server/private/routers/certificates/getBatchedCertificates.ts @@ -0,0 +1,220 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { certificates, db, domains } from "@server/db"; +import { eq, and, or, like, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { registry } from "@server/openApi"; +import { GetCertificateResponse } from "@server/routers/certificates/types"; + +const getCertificateSchema = z.strictObject({ + domains: z.preprocess( + (val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + if (Array.isArray(val)) { + return val; + } + // the array is returned as this + if (typeof val === "string") { + return val.split(","); + } + return undefined; + }, + z.array(z.string().min(1).max(255)) + ), + orgId: z.string() +}); + +async function query(domainId: string, domain: string) { + const [domainRecord] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .limit(1); + + if (!domainRecord) { + throw new Error(`Domain with ID ${domainId} not found`); + } + + const domainType = domainRecord.type; + + let existing: any[] = []; + if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { + const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; + + existing = await db + .select({ + certId: certificates.certId, + domain: certificates.domain, + wildcard: certificates.wildcard, + status: certificates.status, + expiresAt: certificates.expiresAt, + lastRenewalAttempt: certificates.lastRenewalAttempt, + createdAt: certificates.createdAt, + updatedAt: certificates.updatedAt, + errorMessage: certificates.errorMessage, + renewalCount: certificates.renewalCount + }) + .from(certificates) + .where( + and( + eq(certificates.domainId, domainId), + or( + eq(certificates.domain, domain), + and( + eq(certificates.wildcard, true), + or( + eq(certificates.domain, domainLevelDown), + eq(certificates.domain, wildcardPrefixed) + ) + ) + ) + ) + ); + } else { + // For non-NS domains, we only match exact domain names + existing = await db + .select({ + certId: certificates.certId, + domain: certificates.domain, + wildcard: certificates.wildcard, + status: certificates.status, + expiresAt: certificates.expiresAt, + lastRenewalAttempt: certificates.lastRenewalAttempt, + createdAt: certificates.createdAt, + updatedAt: certificates.updatedAt, + errorMessage: certificates.errorMessage, + renewalCount: certificates.renewalCount + }) + .from(certificates) + .where( + and( + eq(certificates.domainId, domainId), + eq(certificates.domain, domain) // exact match for non-NS domains + ) + ); + } + + return existing.length > 0 ? { ...existing[0], domainType } : null; +} + +async function query2(domainList: string[]) { + // Try to get CNAME certificates first + let existingCertificates = await db + .select({ + certId: certificates.certId, + domain: certificates.domain, + wildcard: certificates.wildcard, + status: certificates.status, + expiresAt: certificates.expiresAt, + lastRenewalAttempt: certificates.lastRenewalAttempt, + createdAt: certificates.createdAt, + updatedAt: certificates.updatedAt, + errorMessage: certificates.errorMessage, + renewalCount: certificates.renewalCount + }) + .from(certificates) + .innerJoin(domains, eq(certificates.domainId, domains.domainId)) + .where(and(inArray(certificates.domain, domainList))); + + // All non resolved domain certificates might be `ns` or `wildcard` + const nonAvailableCertificates = existingCertificates + .filter((cert) => !domainList.includes(cert.domain)) + .map((cert) => cert.domain); + + if (nonAvailableCertificates.length > 0) { + const wildcardPrefixedDomains = nonAvailableCertificates.map( + (domain) => { + const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; + return { + domainLevelDown, + wildcardPrefixed + }; + } + ); + + // Need to map the certificates to each domain + await db + .select({ + certId: certificates.certId, + domain: certificates.domain, + wildcard: certificates.wildcard, + status: certificates.status, + expiresAt: certificates.expiresAt, + lastRenewalAttempt: certificates.lastRenewalAttempt, + createdAt: certificates.createdAt, + updatedAt: certificates.updatedAt, + errorMessage: certificates.errorMessage, + renewalCount: certificates.renewalCount + }) + .from(certificates) + .where( + or( + eq(certificates.domain, domain), + and( + eq(certificates.wildcard, true), + or( + eq(certificates.domain, domainLevelDown), + eq(certificates.domain, wildcardPrefixed) + ) + ) + ) + ); + } + + return; +} + +export async function getBatchedCertificate( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getCertificateSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { domains } = parsedParams.data; + + const cert = await query(domains); + + return response(res, { + data: cert, + success: true, + error: false, + message: "Certificates retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} From e128b8c282fffbc2cbe89fc09bff71d92ea23576 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 24 Jul 2026 19:07:37 +0100 Subject: [PATCH 116/494] =?UTF-8?q?=E2=9C=A8=20get=20batched=20certificate?= =?UTF-8?q?s=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../certificates/getBatchedCertificates.ts | 116 ++++++++++++------ server/private/routers/certificates/index.ts | 1 + server/private/routers/external.ts | 7 ++ server/routers/certificates/types.ts | 11 +- 4 files changed, 98 insertions(+), 37 deletions(-) diff --git a/server/private/routers/certificates/getBatchedCertificates.ts b/server/private/routers/certificates/getBatchedCertificates.ts index 6962b9c53..ba51b77b5 100644 --- a/server/private/routers/certificates/getBatchedCertificates.ts +++ b/server/private/routers/certificates/getBatchedCertificates.ts @@ -12,7 +12,7 @@ */ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { certificates, db, domains } from "@server/db"; +import { certificates, db, domains, orgDomains } from "@server/db"; import { eq, and, or, like, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -20,9 +20,16 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { registry } from "@server/openApi"; -import { GetCertificateResponse } from "@server/routers/certificates/types"; +import { + GetCertificateResponse, + type GetBatchedCertificateResponse +} from "@server/routers/certificates/types"; -const getCertificateSchema = z.strictObject({ +const getCertificateParamSchema = z.strictObject({ + orgId: z.string() +}); + +const getCertificateQuerySchema = z.object({ domains: z.preprocess( (val) => { if (val === undefined || val === null || val === "") { @@ -38,11 +45,10 @@ const getCertificateSchema = z.strictObject({ return undefined; }, z.array(z.string().min(1).max(255)) - ), - orgId: z.string() + ) }); -async function query(domainId: string, domain: string) { +async function query1(domainId: string, domain: string) { const [domainRecord] = await db .select() .from(domains) @@ -116,7 +122,7 @@ async function query(domainId: string, domain: string) { return existing.length > 0 ? { ...existing[0], domainType } : null; } -async function query2(domainList: string[]) { +async function query(orgId: string, domainList: string[]) { // Try to get CNAME certificates first let existingCertificates = await db .select({ @@ -129,31 +135,39 @@ async function query2(domainList: string[]) { createdAt: certificates.createdAt, updatedAt: certificates.updatedAt, errorMessage: certificates.errorMessage, - renewalCount: certificates.renewalCount + renewalCount: certificates.renewalCount, + domainType: domains.type }) .from(certificates) .innerJoin(domains, eq(certificates.domainId, domains.domainId)) + .innerJoin( + orgDomains, + and( + eq(domains.domainId, orgDomains.domainId), + eq(orgDomains.orgId, orgId) + ) + ) .where(and(inArray(certificates.domain, domainList))); - // All non resolved domain certificates might be `ns` or `wildcard` + // All non resolved domain certificates might be `ns` or `wildcard`, + // which means exact domain certificates do not const nonAvailableCertificates = existingCertificates .filter((cert) => !domainList.includes(cert.domain)) .map((cert) => cert.domain); if (nonAvailableCertificates.length > 0) { - const wildcardPrefixedDomains = nonAvailableCertificates.map( - (domain) => { - const domainLevelDown = domain.split(".").slice(1).join("."); - const wildcardPrefixed = `*.${domainLevelDown}`; - return { - domainLevelDown, - wildcardPrefixed - }; - } - ); + const domainLevelDownSet = new Set(); + const wildcardDomainSet = new Set(); + + for (const domain of nonAvailableCertificates) { + const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; + domainLevelDownSet.add(domainLevelDown); + wildcardDomainSet.add(wildcardPrefixed); + } // Need to map the certificates to each domain - await db + const wildcardCertificates = await db .select({ certId: certificates.certId, domain: certificates.domain, @@ -164,33 +178,54 @@ async function query2(domainList: string[]) { createdAt: certificates.createdAt, updatedAt: certificates.updatedAt, errorMessage: certificates.errorMessage, - renewalCount: certificates.renewalCount + renewalCount: certificates.renewalCount, + domainType: domains.type }) .from(certificates) + .innerJoin(domains, eq(certificates.domainId, domains.domainId)) + .innerJoin( + orgDomains, + and( + eq(domains.domainId, orgDomains.domainId), + eq(orgDomains.orgId, orgId) + ) + ) .where( - or( - eq(certificates.domain, domain), - and( - eq(certificates.wildcard, true), - or( - eq(certificates.domain, domainLevelDown), - eq(certificates.domain, wildcardPrefixed) - ) + and( + eq(certificates.wildcard, true), + or( + inArray(certificates.domain, [...domainLevelDownSet]), + inArray(certificates.domain, [...wildcardDomainSet]) ) ) ); + + existingCertificates.push(...wildcardCertificates); } - return; + const certificateMap: Record = {}; + for (const domain of domainList) { + const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; + certificateMap[domain] = + existingCertificates.find( + (cert) => + cert.domain === domain || + cert.domain === domainLevelDown || + cert.domain === wildcardPrefixed + ) ?? null; + } + + return certificateMap; } -export async function getBatchedCertificate( +export async function getBatchedCertificates( req: Request, res: Response, next: NextFunction ): Promise { try { - const parsedParams = getCertificateSchema.safeParse(req.params); + const parsedParams = getCertificateParamSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( @@ -200,11 +235,22 @@ export async function getBatchedCertificate( ); } - const { domains } = parsedParams.data; + const { orgId } = parsedParams.data; + const parsedQuery = getCertificateQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } - const cert = await query(domains); + const { domains } = parsedQuery.data; - return response(res, { + const cert = await query(orgId, domains); + + return response(res, { data: cert, success: true, error: false, diff --git a/server/private/routers/certificates/index.ts b/server/private/routers/certificates/index.ts index 18b942d5c..54b11aa1e 100644 --- a/server/private/routers/certificates/index.ts +++ b/server/private/routers/certificates/index.ts @@ -14,3 +14,4 @@ export * from "./getCertificate"; export * from "./restartCertificate"; export * from "./syncCertToNewts"; +export * from "./getBatchedCertificates"; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index d2dc7bec1..fab026418 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -175,6 +175,13 @@ authenticated.get( certificates.getCertificate ); +authenticated.get( + "/org/:orgId/batched-certificates", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getCertificate), + certificates.getBatchedCertificates +); + authenticated.post( "/org/:orgId/certificate/:certId/restart", verifyValidLicense, diff --git a/server/routers/certificates/types.ts b/server/routers/certificates/types.ts index e6aeecdf8..55fe92183 100644 --- a/server/routers/certificates/types.ts +++ b/server/routers/certificates/types.ts @@ -1,9 +1,11 @@ +import type { Domain } from "@server/db"; + export type GetCertificateResponse = { certId: number; domain: string; domainId: string; wildcard: boolean; - domainType: string; + domainType: Domain["type"]; status: string; // pending, requested, valid, expired, failed expiresAt: string | null; lastRenewalAttempt: Date | null; @@ -11,4 +13,9 @@ export type GetCertificateResponse = { updatedAt: number; errorMessage?: string | null; renewalCount: number; -}; \ No newline at end of file +}; + +export type GetBatchedCertificateResponse = Record< + string, + GetCertificateResponse | null +>; From 6e29ebd649e44e4122692b26511f4f390ea64430 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 24 Jul 2026 20:58:47 +0100 Subject: [PATCH 117/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor=20getbatc?= =?UTF-8?q?hedcertificate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../certificates/getBatchedCertificates.ts | 92 ++----------------- 1 file changed, 8 insertions(+), 84 deletions(-) diff --git a/server/private/routers/certificates/getBatchedCertificates.ts b/server/private/routers/certificates/getBatchedCertificates.ts index ba51b77b5..7149de036 100644 --- a/server/private/routers/certificates/getBatchedCertificates.ts +++ b/server/private/routers/certificates/getBatchedCertificates.ts @@ -10,20 +10,16 @@ * * This file is not licensed under the AGPLv3. */ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; import { certificates, db, domains, orgDomains } from "@server/db"; -import { eq, and, or, like, inArray } from "drizzle-orm"; import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; import logger from "@server/logger"; +import { type GetBatchedCertificateResponse } from "@server/routers/certificates/types"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq, inArray, or } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { registry } from "@server/openApi"; -import { - GetCertificateResponse, - type GetBatchedCertificateResponse -} from "@server/routers/certificates/types"; const getCertificateParamSchema = z.strictObject({ orgId: z.string() @@ -48,80 +44,6 @@ const getCertificateQuerySchema = z.object({ ) }); -async function query1(domainId: string, domain: string) { - const [domainRecord] = await db - .select() - .from(domains) - .where(eq(domains.domainId, domainId)) - .limit(1); - - if (!domainRecord) { - throw new Error(`Domain with ID ${domainId} not found`); - } - - const domainType = domainRecord.type; - - let existing: any[] = []; - if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { - const domainLevelDown = domain.split(".").slice(1).join("."); - const wildcardPrefixed = `*.${domainLevelDown}`; - - existing = await db - .select({ - certId: certificates.certId, - domain: certificates.domain, - wildcard: certificates.wildcard, - status: certificates.status, - expiresAt: certificates.expiresAt, - lastRenewalAttempt: certificates.lastRenewalAttempt, - createdAt: certificates.createdAt, - updatedAt: certificates.updatedAt, - errorMessage: certificates.errorMessage, - renewalCount: certificates.renewalCount - }) - .from(certificates) - .where( - and( - eq(certificates.domainId, domainId), - or( - eq(certificates.domain, domain), - and( - eq(certificates.wildcard, true), - or( - eq(certificates.domain, domainLevelDown), - eq(certificates.domain, wildcardPrefixed) - ) - ) - ) - ) - ); - } else { - // For non-NS domains, we only match exact domain names - existing = await db - .select({ - certId: certificates.certId, - domain: certificates.domain, - wildcard: certificates.wildcard, - status: certificates.status, - expiresAt: certificates.expiresAt, - lastRenewalAttempt: certificates.lastRenewalAttempt, - createdAt: certificates.createdAt, - updatedAt: certificates.updatedAt, - errorMessage: certificates.errorMessage, - renewalCount: certificates.renewalCount - }) - .from(certificates) - .where( - and( - eq(certificates.domainId, domainId), - eq(certificates.domain, domain) // exact match for non-NS domains - ) - ); - } - - return existing.length > 0 ? { ...existing[0], domainType } : null; -} - async function query(orgId: string, domainList: string[]) { // Try to get CNAME certificates first let existingCertificates = await db @@ -136,6 +58,7 @@ async function query(orgId: string, domainList: string[]) { updatedAt: certificates.updatedAt, errorMessage: certificates.errorMessage, renewalCount: certificates.renewalCount, + domainId: domains.domainId, domainType: domains.type }) .from(certificates) @@ -179,6 +102,7 @@ async function query(orgId: string, domainList: string[]) { updatedAt: certificates.updatedAt, errorMessage: certificates.errorMessage, renewalCount: certificates.renewalCount, + domainId: domains.domainId, domainType: domains.type }) .from(certificates) From 762c79511b372edc9bcd258fc32af320d6f3c0f3 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 24 Jul 2026 20:59:29 +0100 Subject: [PATCH 118/494] =?UTF-8?q?=F0=9F=9A=A7=20Add=20batchedCertificate?= =?UTF-8?q?=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/PublicResourcesTable.tsx | 14 +++++++ src/lib/queries.ts | 54 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index 0b2e167fe..86c149df4 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -166,6 +166,12 @@ export default function PublicResourcesTable({ [resources] ); + // Domain list + const resourceDomains = useMemo( + () => resources.map((r) => r.fullDomain).filter(Boolean) as string[], + [resources] + ); + const statusHistoryQuery = useQuery({ ...orgQueries.batchedResourceStatusHistory({ orgId, @@ -175,6 +181,14 @@ export default function PublicResourcesTable({ enabled: statusHistoryResourceIds.length > 0 }); + const domainCertificatesQuery = useQuery({ + ...orgQueries.batchedDomainCertificates({ + orgId, + domains: resourceDomains + }), + enabled: resourceDomains.length > 0 + }); + const refreshData = () => { startTransition(() => { try { diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 8007fd2f1..c52dff981 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -69,6 +69,7 @@ import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getRe import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource"; import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; +import type { GetCertificateResponse } from "@server/routers/certificates/types"; export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; @@ -719,6 +720,31 @@ export const orgQueries = { }, staleTime: durationToMs(5, "seconds") }), + batchedDomainCertificates: ({ + domains, + orgId + }: { + orgId: string; + domains: string[]; + }) => + queryOptions({ + queryKey: ["ORG", orgId, "BATCHED_CERTIFICATES", domains] as const, + queryFn: async ({ signal, meta }) => { + // Negated because getTimezoneOffset() returns UTC - local, + // while the API expects minutes to add to UTC to get local + const sp = new URLSearchParams([ + ...domains.map((domain) => ["domains", domain.toString()]) + ]); + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/batched-certificates?${sp.toString()}`, { + signal + }); + return res.data.data; + }, + staleTime: durationToMs(5, "seconds") + }), batchedResourceStatusHistory: ({ resourceIds, orgId, @@ -1355,6 +1381,34 @@ export const approvalQueries = { }; export const domainQueries = { + getCertificate: ({ + orgId, + domainId, + domain + }: { + orgId: string; + domainId: string; + domain: string; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "DOMAIN", + domainId, + "CERTIFICATE" + ] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/certificate/${domainId}/${domain}`, { + signal + }); + if (res.status === 404) return null; + return res.data.data; + }, + staleTime: durationToMs(1, "minutes") + }), getDomain: ({ orgId, domainId }: { orgId: string; domainId: string }) => queryOptions({ queryKey: ["ORG", orgId, "DOMAIN", domainId] as const, From 49854f31a51374318a72035193cc523c9615108c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 24 Jul 2026 21:03:52 +0100 Subject: [PATCH 119/494] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20refactor=20`useCe?= =?UTF-8?q?rtificate`=20to=20use=20tanstack=20qeury?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ResourceAccessCertIndicator.tsx | 1 + src/hooks/useCertificate.ts | 93 +++++++++++-------- 2 files changed, 55 insertions(+), 39 deletions(-) diff --git a/src/components/ResourceAccessCertIndicator.tsx b/src/components/ResourceAccessCertIndicator.tsx index 40ddfbfab..4067f80ae 100644 --- a/src/components/ResourceAccessCertIndicator.tsx +++ b/src/components/ResourceAccessCertIndicator.tsx @@ -22,6 +22,7 @@ type ResourceAccessCertIndicatorProps = { orgId: string; domainId: string; fullDomain: string; + initialCertValue?: any; }; function getStatusColor(status: string) { diff --git a/src/hooks/useCertificate.ts b/src/hooks/useCertificate.ts index cd0802ef0..0a555c8fc 100644 --- a/src/hooks/useCertificate.ts +++ b/src/hooks/useCertificate.ts @@ -5,6 +5,8 @@ import { AxiosResponse } from "axios"; import { GetCertificateResponse } from "@server/routers/certificates/types"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useQuery } from "@tanstack/react-query"; +import { domainQueries } from "@app/lib/queries"; type UseCertificateProps = { orgId: string; @@ -13,6 +15,7 @@ type UseCertificateProps = { autoFetch?: boolean; polling?: boolean; pollingInterval?: number; + initialCertValue?: GetCertificateResponse | null; }; type UseCertificateReturn = { @@ -22,51 +25,63 @@ type UseCertificateReturn = { refreshing: boolean; fetchCert: (showLoading?: boolean) => Promise; refreshCert: () => Promise; - clearCert: () => void; + // clearCert: () => void; }; export function useCertificate({ orgId, domainId, fullDomain, + initialCertValue = null, autoFetch = true, polling = false, pollingInterval = 5000 }: UseCertificateProps): UseCertificateReturn { const api = createApiClient(useEnvContext()); - const [cert, setCert] = useState(null); - const [certLoading, setCertLoading] = useState(false); + // const [cert, setCert] = useState( + // initialCertValue + // ); + // const [certLoading, setCertLoading] = useState(false); const [certError, setCertError] = useState(null); const [refreshing, setRefreshing] = useState(false); - const fetchCert = useCallback( - async (showLoading = true) => { - if (!orgId || !domainId || !fullDomain) return; + const query = useQuery({ + ...domainQueries.getCertificate({ + orgId, + domainId, + domain: fullDomain + }), + initialData: initialCertValue + }); - if (showLoading) { - setCertLoading(true); - } - try { - const res = await api.get< - AxiosResponse - >(`/org/${orgId}/certificate/${domainId}/${fullDomain}`); - const certData = res.data.data; - if (certData) { - setCertError(null); - setCert(certData); - } - } catch (error: any) { - console.error("Failed to fetch certificate:", error); - setCertError("Failed"); - } finally { - if (showLoading) { - setCertLoading(false); - } - } - }, - [api, orgId, domainId, fullDomain] - ); + // const fetchCert = useCallback( + // async (showLoading = true) => { + // if (!orgId || !domainId || !fullDomain) return; + + // if (showLoading) { + // setCertLoading(true); + // } + // try { + // const res = await api.get< + // AxiosResponse + // >(`/org/${orgId}/certificate/${domainId}/${fullDomain}`); + // const certData = res.data.data; + // if (certData) { + // setCertError(null); + // setCert(certData); + // } + // } catch (error: any) { + // console.error("Failed to fetch certificate:", error); + // setCertError("Failed"); + // } finally { + // if (showLoading) { + // setCertLoading(false); + // } + // } + // }, + // [api, orgId, domainId, fullDomain] + // ); const refreshCert = useCallback(async () => { if (!cert) return; @@ -96,11 +111,11 @@ export function useCertificate({ }, []); // Auto-fetch on mount if enabled - useEffect(() => { - if (autoFetch && orgId && domainId && fullDomain) { - fetchCert(); - } - }, [autoFetch, orgId, domainId, fullDomain, fetchCert]); + // useEffect(() => { + // if (autoFetch && orgId && domainId && fullDomain) { + // fetchCert(); + // } + // }, [autoFetch, orgId, domainId, fullDomain, fetchCert]); useEffect(() => { if (!polling || !orgId || !domainId || !fullDomain) return; @@ -132,12 +147,12 @@ export function useCertificate({ }, [polling, orgId, domainId, fullDomain, pollingInterval, fetchCert]); return { - cert, - certLoading, + cert: query.data, + certLoading: query.isLoading, certError, refreshing, - fetchCert, - refreshCert, - clearCert + fetchCert: query.refetch, + refreshCert + // clearCert }; } From 6649f15498c133d38c7b09ab0bb08e52a0b0c497 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:40:05 +0000 Subject: [PATCH 120/494] Bump fast-uri from 3.1.2 to 3.1.4 Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 378a1f26c..c5ec79590 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11925,9 +11925,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { From 0da96b06ea2ec70f378536915e62500fa7d0369b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:34:06 +0000 Subject: [PATCH 121/494] Bump the github-actions-dependencies group across 1 directory with 5 updates Bumps the github-actions-dependencies group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `7.0.0` | `7.0.1` | | [docker/login-action](https://github.com/docker/login-action) | `4.2.0` | `4.5.1` | | [actions/setup-go](https://github.com/actions/setup-go) | `6.4.0` | `7.0.0` | | [actions/setup-node](https://github.com/actions/setup-node) | `6.4.0` | `7.0.0` | | [actions/stale](https://github.com/actions/stale) | `10.3.0` | `10.4.0` | Updates `actions/checkout` from 7.0.0 to 7.0.1 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1) Updates `docker/login-action` from 4.2.0 to 4.5.1 - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...abd2ef45e78c5afb21d64d4ca52ee8550d9572c7) Updates `actions/setup-go` from 6.4.0 to 7.0.0 - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/4a3601121dd01d1626a1e23e37211e3254c1c06c...b7ad1dad31e06c5925ef5d2fc7ad053ef454303e) Updates `actions/setup-node` from 6.4.0 to 7.0.0 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020) Updates `actions/stale` from 10.3.0 to 10.4.0 - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899...1e223db275d687790206a7acac4d1a11bd6fe629) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions-dependencies - dependency-name: docker/login-action dependency-version: 4.5.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-dependencies - dependency-name: actions/setup-go dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions-dependencies - dependency-name: actions/setup-node dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions-dependencies - dependency-name: actions/stale dependency-version: 10.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-dependencies ... Signed-off-by: dependabot[bot] --- .github/workflows/cicd.yml | 18 +++++++++--------- .github/workflows/linting.yml | 4 ++-- .github/workflows/stale-bot.yml | 2 +- .github/workflows/test.yml | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 09d2986e9..558a946ce 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -62,7 +62,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Monitor storage space run: | @@ -77,7 +77,7 @@ jobs: fi - name: Log in to Docker Hub - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} @@ -134,7 +134,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Monitor storage space run: | @@ -149,7 +149,7 @@ jobs: fi - name: Log in to Docker Hub - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} @@ -201,10 +201,10 @@ jobs: timeout-minutes: 30 steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Log in to Docker Hub - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} @@ -256,7 +256,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Extract tag name id: get-tag @@ -264,7 +264,7 @@ jobs: shell: bash - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: 1.25 @@ -407,7 +407,7 @@ jobs: shell: bash - name: Login to GitHub Container Registry (for cosign) - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 09b5270b0..e75349117 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -21,10 +21,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index e7a03a98d..965dcfa06 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -14,7 +14,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 with: days-before-stale: 14 days-before-close: 14 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3f64d913f..98798ab06 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,10 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' @@ -62,7 +62,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Build Docker image sqlite run: make dev-build-sqlite @@ -71,7 +71,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Build Docker image pg run: make dev-build-pg From aa6dc67015854db79583c1486d3fbd4c3a44487b Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Jul 2026 10:01:12 -0400 Subject: [PATCH 122/494] Pull the version on the info page and use api --- .../sites/[niceId]/credentials/page.tsx | 9 ++++ .../[orgId]/settings/sites/create/page.tsx | 48 ++++--------------- 2 files changed, 17 insertions(+), 40 deletions(-) diff --git a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx index d4463cb7c..41b8f62eb 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx @@ -40,6 +40,8 @@ import { NewtSiteInstallCommands } from "@app/components/newt-install-commands"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { AxiosResponse } from "axios"; +import { useQuery } from "@tanstack/react-query"; +import { productUpdatesQueries } from "@app/lib/queries"; export default function CredentialsPage() { const { env } = useEnvContext(); @@ -67,6 +69,12 @@ export default function CredentialsPage() { const { isPaidUser } = usePaidStatus(); + const { data: latestVersions } = useQuery( + productUpdatesQueries.latestVersion(true) + ); + const newtVersion = + latestVersions?.data?.newt?.latestVersion ?? "latest"; + // Fetch site defaults for wireguard sites to show in obfuscated config useEffect(() => { const fetchSiteDefaults = async () => { @@ -302,6 +310,7 @@ export default function CredentialsPage() { id={displayNewtId ?? "**********"} secret={displaySecret ?? "**************"} endpoint={env.app.dashboardUrl} + version={newtVersion} /> )} diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index bf74b91d7..27d50bc8b 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -56,6 +56,8 @@ import { QRCodeCanvas } from "qrcode.react"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; import { NewtSiteInstallCommands } from "@app/components/newt-install-commands"; +import { useQuery } from "@tanstack/react-query"; +import { productUpdatesQueries } from "@app/lib/queries"; type SiteType = "newt" | "wireguard" | "local"; @@ -189,9 +191,14 @@ export default function Page() { const [wgConfig, setWgConfig] = useState(""); const [createLoading, setCreateLoading] = useState(false); - const [newtVersion, setNewtVersion] = useState("latest"); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + const { data: latestVersions } = useQuery( + productUpdatesQueries.latestVersion(true) + ); + const newtVersion = + latestVersions?.data?.newt?.latestVersion ?? "latest"; + const [siteDefaults, setSiteDefaults] = useState(null); @@ -302,45 +309,6 @@ export default function Page() { const load = async () => { setLoadingPage(true); - let currentNewtVersion = "latest"; - - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); - - const response = await fetch( - `https://api.github.com/repos/fosrl/newt/releases/latest`, - { signal: controller.signal } - ); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error( - t("newtErrorFetchReleases", { - err: response.statusText - }) - ); - } - const data = await response.json(); - const latestVersion = data.tag_name; - currentNewtVersion = latestVersion; - setNewtVersion(latestVersion); - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - console.error(t("newtErrorFetchTimeout")); - } else { - console.error( - t("newtErrorFetchLatest", { - err: - error instanceof Error - ? error.message - : String(error) - }) - ); - } - } - const generatedKeypair = generateKeypair(); const privateKey = generatedKeypair.privateKey; From 9a9ae649efe7ffa87c1f18ac8b3e992a6462f54c Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Jul 2026 10:02:18 -0400 Subject: [PATCH 123/494] Add default to path Fix #3484 --- server/lib/blueprints/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index cc9865533..828b3bd15 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -28,7 +28,7 @@ export const TargetHealthCheckSchema = z.object({ hostname: z.string(), port: z.int().min(1).max(65535), enabled: z.boolean().optional().default(true), - path: z.string().optional(), + path: z.string().optional().default("/"), scheme: z.string().optional(), mode: z.string().default("http"), interval: z.int().default(30), From b5518d029ee17a7fdb571f4530865ded612b5ce5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:10:39 +0000 Subject: [PATCH 124/494] Bump next from 16.2.6 to 16.2.11 Bumps [next](https://github.com/vercel/next.js) from 16.2.6 to 16.2.11. - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.2.6...v16.2.11) --- updated-dependencies: - dependency-name: next dependency-version: 16.2.11 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 311 ++++++++++++++++++++++++++++++++++++++++------ package.json | 2 +- 2 files changed, 272 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 378a1f26c..81531a701 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,7 +75,7 @@ "lucide-react": "1.17.0", "maxmind": "5.0.6", "moment": "2.30.1", - "next": "16.2.6", + "next": "16.2.11", "next-intl": "4.13.0", "next-themes": "0.4.6", "nextjs-toploader": "3.9.17", @@ -2574,9 +2574,9 @@ } }, "node_modules/@next/env": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", - "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.11.tgz", + "integrity": "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2590,9 +2590,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", - "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.11.tgz", + "integrity": "sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==", "cpu": [ "arm64" ], @@ -2606,9 +2606,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", - "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.11.tgz", + "integrity": "sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==", "cpu": [ "x64" ], @@ -2622,12 +2622,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", - "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.11.tgz", + "integrity": "sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2638,12 +2641,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", - "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.11.tgz", + "integrity": "sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2654,12 +2660,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", - "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.11.tgz", + "integrity": "sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2670,12 +2679,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", - "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.11.tgz", + "integrity": "sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2686,9 +2698,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", - "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.11.tgz", + "integrity": "sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==", "cpu": [ "arm64" ], @@ -2702,9 +2714,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", - "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.11.tgz", + "integrity": "sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==", "cpu": [ "x64" ], @@ -6460,6 +6472,225 @@ "next": "16.2.6" } }, + "node_modules/@react-email/ui/node_modules/@next/env": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", + "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-email/ui/node_modules/@next/swc-darwin-arm64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/ui/node_modules/@next/swc-darwin-x64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/ui/node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/ui/node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/ui/node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", + "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/ui/node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", + "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/ui/node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/ui/node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/ui/node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@react-email/ui/node_modules/next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", + "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/env": "16.2.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, "node_modules/@react-types/shared": { "version": "3.34.0", "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz", @@ -14349,12 +14580,12 @@ } }, "node_modules/next": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", - "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.11.tgz", + "integrity": "sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==", "license": "MIT", "dependencies": { - "@next/env": "16.2.6", + "@next/env": "16.2.11", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -14368,14 +14599,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.6", - "@next/swc-darwin-x64": "16.2.6", - "@next/swc-linux-arm64-gnu": "16.2.6", - "@next/swc-linux-arm64-musl": "16.2.6", - "@next/swc-linux-x64-gnu": "16.2.6", - "@next/swc-linux-x64-musl": "16.2.6", - "@next/swc-win32-arm64-msvc": "16.2.6", - "@next/swc-win32-x64-msvc": "16.2.6", + "@next/swc-darwin-arm64": "16.2.11", + "@next/swc-darwin-x64": "16.2.11", + "@next/swc-linux-arm64-gnu": "16.2.11", + "@next/swc-linux-arm64-musl": "16.2.11", + "@next/swc-linux-x64-gnu": "16.2.11", + "@next/swc-linux-x64-musl": "16.2.11", + "@next/swc-win32-arm64-msvc": "16.2.11", + "@next/swc-win32-x64-msvc": "16.2.11", "sharp": "^0.34.5" }, "peerDependencies": { diff --git a/package.json b/package.json index 15ef7bda5..19bdd36a8 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "lucide-react": "1.17.0", "maxmind": "5.0.6", "moment": "2.30.1", - "next": "16.2.6", + "next": "16.2.11", "next-intl": "4.13.0", "next-themes": "0.4.6", "nextjs-toploader": "3.9.17", From d5a56fb71daf494638ed3061559bc56e8ee81271 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:24:11 +0000 Subject: [PATCH 125/494] Bump js-yaml from 4.2.0 to 4.3.0 Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.0. - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...4.3.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.3.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 378a1f26c..94dfbe6ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,7 @@ "input-otp": "1.4.2", "ioredis": "5.11.0", "jmespath": "0.16.0", - "js-yaml": "4.2.0", + "js-yaml": "4.3.0", "jsonwebtoken": "9.0.3", "lucide-react": "1.17.0", "maxmind": "5.0.6", @@ -13389,9 +13389,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 15ef7bda5..2424a0773 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "input-otp": "1.4.2", "ioredis": "5.11.0", "jmespath": "0.16.0", - "js-yaml": "4.2.0", + "js-yaml": "4.3.0", "jsonwebtoken": "9.0.3", "lucide-react": "1.17.0", "maxmind": "5.0.6", From 10773432bb37861c0703fc5c3178af4e28a05871 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 28 Jul 2026 20:59:42 +0100 Subject: [PATCH 126/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Batch=20certificat?= =?UTF-8?q?e=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/resources/public/page.tsx | 56 +++--- src/components/AuthPageSettings.tsx | 1 - src/components/CertificateStatus.tsx | 3 - src/components/PublicResourcesTable.tsx | 51 ++--- .../ResourceAccessCertIndicator.tsx | 13 +- src/components/ResourceInfoBox.tsx | 1 - src/components/SiteResourceInfoBox.tsx | 1 - src/hooks/useCertificate.ts | 179 +++++++----------- src/lib/queries.ts | 32 +++- 9 files changed, 151 insertions(+), 186 deletions(-) diff --git a/src/app/[orgId]/settings/resources/public/page.tsx b/src/app/[orgId]/settings/resources/public/page.tsx index f8cc85dae..d7fd80d4a 100644 --- a/src/app/[orgId]/settings/resources/public/page.tsx +++ b/src/app/[orgId]/settings/resources/public/page.tsx @@ -5,6 +5,8 @@ import PublicResourcesBanner from "@app/components/PublicResourcesBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import OrgProvider from "@app/providers/OrgProvider"; +import { build } from "@server/build"; +import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types"; import type { GetOrgResponse } from "@server/routers/org"; import type { ListResourcesResponse } from "@server/routers/resource"; import { GetSiteResponse } from "@server/routers/site/getSite"; @@ -60,29 +62,7 @@ export default async function ProxyResourcesPage( searchParams.get("siteId") ?? undefined ); - let initialFilterSite: { - siteId: number; - name: string; - type: string; - } | null = null; - if (siteIdParam) { - try { - const siteRes = await internal.get( - `/site/${siteIdParam}`, - await authCookieHeader() - ); - const s = (siteRes.data as ResponseT).data; - if (s && s.orgId === params.orgId) { - initialFilterSite = { - siteId: s.siteId, - name: s.name, - type: s.type - }; - } - } catch { - // leave null - } - } + let org = null; try { @@ -140,6 +120,34 @@ export default async function ProxyResourcesPage( health: (resource.health as ResourceRow["health"]) ?? undefined }; }); + // Prefetched in one batched call so the table doesn't fire a separate + // certificate request per visible row once it mounts on the client. + const certDomains = Array.from( + new Set( + resourceRows + .filter((r) => r.ssl && r.fullDomain) + .map((r) => r.fullDomain as string) + ) + ); + + let initialCertificates: GetBatchedCertificateResponse | undefined; + if (build !== "oss" && certDomains.length > 0) { + try { + const certSearchParams = new URLSearchParams( + certDomains.map((domain) => ["domains", domain]) + ); + const certRes = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/batched-certificates?${certSearchParams.toString()}`, + await authCookieHeader() + ); + initialCertificates = certRes.data.data; + } catch { + // leave undefined so each row falls back to fetching its own + } + } + return ( <> diff --git a/src/components/AuthPageSettings.tsx b/src/components/AuthPageSettings.tsx index edd1e3a19..d6375c7b5 100644 --- a/src/components/AuthPageSettings.tsx +++ b/src/components/AuthPageSettings.tsx @@ -412,7 +412,6 @@ function AuthPageSettings({ fullDomain={ loginPage.fullDomain } - autoFetch={true} showLabel={true} polling={true} /> diff --git a/src/components/CertificateStatus.tsx b/src/components/CertificateStatus.tsx index 788c39c21..9b1f52c03 100644 --- a/src/components/CertificateStatus.tsx +++ b/src/components/CertificateStatus.tsx @@ -187,7 +187,6 @@ type CertificateStatusProps = { orgId: string; domainId: string; fullDomain: string; - autoFetch?: boolean; showLabel?: boolean; className?: string; onRefresh?: () => void; @@ -199,7 +198,6 @@ export default function CertificateStatus({ orgId, domainId, fullDomain, - autoFetch = true, showLabel = true, className = "", onRefresh, @@ -210,7 +208,6 @@ export default function CertificateStatus({ orgId, domainId, fullDomain, - autoFetch, polling, pollingInterval }); diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index 86c149df4..a5ada101d 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -7,8 +7,7 @@ import { ResourceSitesStatusCell, type ResourceSiteRow } from "@app/components/ResourceSitesStatusCell"; -import { Selectedsite, SitesSelector } from "@app/components/site-selector"; -import { Badge } from "@app/components/ui/badge"; +import { Selectedsite } from "@app/components/site-selector"; import { Button } from "@app/components/ui/button"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { @@ -18,25 +17,18 @@ import { DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; import { Switch } from "@app/components/ui/switch"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { cn } from "@app/lib/cn"; -import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; -import { durationToMs } from "@app/lib/durationToMs"; import { orgQueries } from "@app/lib/queries"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { build } from "@server/build"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { UpdateResourceResponse } from "@server/routers/resource"; +import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types"; import { useQuery } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; @@ -48,9 +40,7 @@ import { ChevronDown, ChevronsUpDownIcon, Clock, - Funnel, MoreHorizontal, - PlusIcon, ShieldCheck, ShieldOff, XCircle @@ -60,7 +50,6 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { startTransition, - useEffect, useMemo, useOptimistic, useRef, @@ -71,15 +60,11 @@ import { import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; +import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; +import { LabelsTableCell } from "./LabelsTableCell"; +import { SitesColumnFilterButton } from "./SitesColumnFilterButton"; import { ControlledDataTable } from "./ui/controlled-data-table"; import { UptimeMiniBar } from "./UptimeMiniBar"; -import { type SelectedLabel } from "./labels-selector"; -import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; -import { useLocalLabels } from "@app/hooks/useLocalLabels"; -import { LabelsTableCell } from "./LabelsTableCell"; -import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; -import { refresh } from "next/cache"; -import { SitesColumnFilterButton } from "./SitesColumnFilterButton"; export type TargetHealth = { targetId: number; @@ -123,6 +108,8 @@ type ProxyResourcesTableProps = { pagination: PaginationState; rowCount: number; initialFilterSite?: Selectedsite | null; + /** Certificates prefetched on the server, keyed by full domain. */ + initialCertificates?: GetBatchedCertificateResponse; }; const booleanSearchFilterSchema = z @@ -137,7 +124,7 @@ export default function PublicResourcesTable({ orgId, pagination, rowCount, - initialFilterSite = null + initialCertificates }: ProxyResourcesTableProps) { const router = useRouter(); const { @@ -166,12 +153,6 @@ export default function PublicResourcesTable({ [resources] ); - // Domain list - const resourceDomains = useMemo( - () => resources.map((r) => r.fullDomain).filter(Boolean) as string[], - [resources] - ); - const statusHistoryQuery = useQuery({ ...orgQueries.batchedResourceStatusHistory({ orgId, @@ -181,14 +162,6 @@ export default function PublicResourcesTable({ enabled: statusHistoryResourceIds.length > 0 }); - const domainCertificatesQuery = useQuery({ - ...orgQueries.batchedDomainCertificates({ - orgId, - domains: resourceDomains - }), - enabled: resourceDomains.length > 0 - }); - const refreshData = () => { startTransition(() => { try { @@ -499,6 +472,9 @@ export default function PublicResourcesTable({ orgId={resourceRow.orgId} domainId={domainId} fullDomain={certHostname} + initialCertValue={ + initialCertificates?.[certHostname] + } /> ) : null}
@@ -668,7 +644,8 @@ export default function PublicResourcesTable({ t, searchParams, statusHistoryQuery.data, - statusHistoryQuery.isLoading + statusHistoryQuery.isLoading, + initialCertificates ]); function handleFilterChange( diff --git a/src/components/ResourceAccessCertIndicator.tsx b/src/components/ResourceAccessCertIndicator.tsx index 4067f80ae..9459add3a 100644 --- a/src/components/ResourceAccessCertIndicator.tsx +++ b/src/components/ResourceAccessCertIndicator.tsx @@ -7,6 +7,7 @@ import { PopoverContent } from "@app/components/ui/popover"; import { useCertificate } from "@app/hooks/useCertificate"; +import type { GetCertificateResponse } from "@server/routers/certificates/types"; import { cn } from "@app/lib/cn"; import { FileBadge } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -22,7 +23,7 @@ type ResourceAccessCertIndicatorProps = { orgId: string; domainId: string; fullDomain: string; - initialCertValue?: any; + initialCertValue?: GetCertificateResponse | null; }; function getStatusColor(status: string) { @@ -44,7 +45,8 @@ function getStatusColor(status: string) { export function ResourceAccessCertIndicator({ orgId, domainId, - fullDomain + fullDomain, + initialCertValue }: ResourceAccessCertIndicatorProps) { const t = useTranslations(); const [open, setOpen] = useState(false); @@ -54,16 +56,19 @@ export function ResourceAccessCertIndicator({ orgId, domainId, fullDomain, - autoFetch: true, + initialCertValue, polling: open, pollingInterval: 5000 }); const { cert, certLoading, certError, refreshing, fetchCert } = certificate; + // `polling` only schedules on predefined intervals (1 second), + // so the first request would be a full interval away if open = true (which set polling = true). + // So we fetch immediately so the popover opens with fresh data. useEffect(() => { if (!open) return; - void fetchCert(false); + void fetchCert(); }, [open, fetchCert]); const clearCloseTimer = useCallback(() => { diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index f459d2c38..642ba8e12 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -180,7 +180,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { orgId={resource.orgId} domainId={resource.domainId!} fullDomain={resource.fullDomain!} - autoFetch={true} showLabel={false} polling={true} /> diff --git a/src/components/SiteResourceInfoBox.tsx b/src/components/SiteResourceInfoBox.tsx index f57acbe99..15457a189 100644 --- a/src/components/SiteResourceInfoBox.tsx +++ b/src/components/SiteResourceInfoBox.tsx @@ -182,7 +182,6 @@ export function SiteResourceInfoSections({ orgId={siteResource.orgId} domainId={siteResource.domainId!} fullDomain={siteResource.fullDomain!} - autoFetch={true} showLabel={false} polling={true} /> diff --git a/src/hooks/useCertificate.ts b/src/hooks/useCertificate.ts index 0a555c8fc..99e78f321 100644 --- a/src/hooks/useCertificate.ts +++ b/src/hooks/useCertificate.ts @@ -1,18 +1,17 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; -import { AxiosResponse } from "axios"; +import { useCallback } from "react"; import { GetCertificateResponse } from "@server/routers/certificates/types"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { domainQueries } from "@app/lib/queries"; +import { durationToMs } from "@app/lib/durationToMs"; type UseCertificateProps = { orgId: string; domainId: string; fullDomain: string; - autoFetch?: boolean; polling?: boolean; pollingInterval?: number; initialCertValue?: GetCertificateResponse | null; @@ -23,135 +22,103 @@ type UseCertificateReturn = { certLoading: boolean; certError: string | null; refreshing: boolean; - fetchCert: (showLoading?: boolean) => Promise; + fetchCert: () => Promise; refreshCert: () => Promise; // clearCert: () => void; }; +const POLL_JITTER_MS = durationToMs(1, "seconds"); + export function useCertificate({ orgId, domainId, fullDomain, - initialCertValue = null, - autoFetch = true, + initialCertValue, polling = false, pollingInterval = 5000 }: UseCertificateProps): UseCertificateReturn { const api = createApiClient(useEnvContext()); + const queryClient = useQueryClient(); - // const [cert, setCert] = useState( - // initialCertValue - // ); - // const [certLoading, setCertLoading] = useState(false); - const [certError, setCertError] = useState(null); - const [refreshing, setRefreshing] = useState(false); - - const query = useQuery({ - ...domainQueries.getCertificate({ - orgId, - domainId, - domain: fullDomain - }), - initialData: initialCertValue + const certQuery = domainQueries.getCertificate({ + orgId, + domainId, + domain: fullDomain }); - // const fetchCert = useCallback( - // async (showLoading = true) => { - // if (!orgId || !domainId || !fullDomain) return; + const { data, isLoading, isError, refetch } = useQuery({ + ...certQuery, + enabled: Boolean(orgId && domainId && fullDomain), + // Add a small random offset to each tick. Without it, every row in a + // table would poll at the exact same instant, hitting the server in + // bursts instead of spreading the requests out. + refetchInterval: polling + ? () => + Math.max( + 1000, + Math.round( + pollingInterval + + (Math.random() * 2 - 1) * POLL_JITTER_MS + ) + ) + : false, + // An explicit `null` is a real answer ("this domain has no certificate") + // and is seeded like any other, so a server-prefetched row doesn't + // refetch on mount. Only an omitted prop leaves the cache empty. + ...(initialCertValue !== undefined && { initialData: initialCertValue }) + }); - // if (showLoading) { - // setCertLoading(true); - // } - // try { - // const res = await api.get< - // AxiosResponse - // >(`/org/${orgId}/certificate/${domainId}/${fullDomain}`); - // const certData = res.data.data; - // if (certData) { - // setCertError(null); - // setCert(certData); - // } - // } catch (error: any) { - // console.error("Failed to fetch certificate:", error); - // setCertError("Failed"); - // } finally { - // if (showLoading) { - // setCertLoading(false); - // } - // } - // }, - // [api, orgId, domainId, fullDomain] - // ); + const cert = data ?? null; + const restartCert = useMutation({ + mutationFn: async (certId: number) => { + await api.post(`/org/${orgId}/certificate/${certId}/restart`, {}); + }, + onSuccess: () => { + // the backend flips the status asynchronously; reflect it optimistically + setTimeout(() => { + queryClient.setQueryData(certQuery.queryKey, (prev) => + prev ? { ...prev, status: "pending" } : prev + ); + }, 500); + }, + onError: (error) => { + console.error("Failed to restart certificate:", error); + } + }); + + const fetchCert = useCallback(async () => { + await refetch(); + }, [refetch]); + + const { mutateAsync: restartCertAsync } = restartCert; const refreshCert = useCallback(async () => { if (!cert) return; - setRefreshing(true); - setCertError(null); try { - await api.post( - `/org/${orgId}/certificate/${cert.certId}/restart`, - {} - ); - // Update status to pending - setTimeout(() => { - setCert({ ...cert, status: "pending" }); - }, 500); - } catch (error: any) { - console.error("Failed to restart certificate:", error); - setCertError("Failed to restart"); - } finally { - setRefreshing(false); + await restartCertAsync(cert.certId); + } catch { + // surfaced through certError } - }, [api, orgId, cert]); + }, [cert, restartCertAsync]); - const clearCert = useCallback(() => { - setCert(null); - setCertError(null); - }, []); + // const clearCert = useCallback(() => { + // queryClient.removeQueries({ queryKey: certQuery.queryKey }); + // }, [queryClient, certQuery.queryKey]); - // Auto-fetch on mount if enabled - // useEffect(() => { - // if (autoFetch && orgId && domainId && fullDomain) { - // fetchCert(); - // } - // }, [autoFetch, orgId, domainId, fullDomain, fetchCert]); - - useEffect(() => { - if (!polling || !orgId || !domainId || !fullDomain) return; - - const POLL_JITTER_MS = 1000; - let cancelled = false; - let timeoutId: ReturnType; - - const scheduleNext = () => { - const jitter = (Math.random() * 2 - 1) * POLL_JITTER_MS; - const delayMs = Math.max( - 1000, - Math.round(pollingInterval + jitter) - ); - - timeoutId = setTimeout(() => { - if (cancelled) return; - void fetchCert(false); - scheduleNext(); - }, delayMs); - }; - - scheduleNext(); - - return () => { - cancelled = true; - clearTimeout(timeoutId); - }; - }, [polling, orgId, domainId, fullDomain, pollingInterval, fetchCert]); + let certError: string | null = null; + if (restartCert.isError) { + certError = "Failed to restart"; + } else if (isError) { + certError = "Failed"; + } return { - cert: query.data, - certLoading: query.isLoading, + cert, + certLoading: isLoading, certError, - refreshing, - fetchCert: query.refetch, + refreshing: restartCert.isPending, + fetchCert, refreshCert // clearCert }; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index c52dff981..f51ca0ac0 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -41,7 +41,7 @@ import { keepPreviousData, queryOptions } from "@tanstack/react-query"; -import type { AxiosResponse } from "axios"; +import { isAxiosError, type AxiosResponse } from "axios"; import z, { meta } from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; @@ -1396,17 +1396,31 @@ export const domainQueries = { orgId, "DOMAIN", domainId, - "CERTIFICATE" + "CERTIFICATE", + domain ] as const, queryFn: async ({ signal, meta }) => { - const res = await meta!.api.get< - AxiosResponse - >(`/org/${orgId}/certificate/${domainId}/${domain}`, { - signal - }); - if (res.status === 404) return null; - return res.data.data; + try { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/certificate/${domainId}/${domain}`, { + signal + }); + return res.data.data; + } catch (error) { + // the endpoint 404s when the domain has no certificate yet + if (isAxiosError(error) && error.response?.status === 404) { + return null; + } + throw error; + } }, + retry: (failureCount, error) => + isAxiosError(error) && + error.response != null && + error.response.status < 500 + ? false + : failureCount < 2, staleTime: durationToMs(1, "minutes") }), getDomain: ({ orgId, domainId }: { orgId: string; domainId: string }) => From f47c94d05b56d28c1d39fdabdd0aa43725931355 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 29 Jul 2026 09:38:15 -0400 Subject: [PATCH 127/494] check idp org ownership on save policy closes #3290 --- server/lib/blueprints/resourcePolicies.ts | 21 ++++------- server/lib/idp/idpExistsForOrg.ts | 36 +++++++++++++++++++ .../routers/policy/createResourcePolicy.ts | 12 ++----- .../policy/setResourcePolicyAccessControl.ts | 20 ++++------- 4 files changed, 52 insertions(+), 37 deletions(-) create mode 100644 server/lib/idp/idpExistsForOrg.ts diff --git a/server/lib/blueprints/resourcePolicies.ts b/server/lib/blueprints/resourcePolicies.ts index aa5f4bc9b..87e9101cc 100644 --- a/server/lib/blueprints/resourcePolicies.ts +++ b/server/lib/blueprints/resourcePolicies.ts @@ -1,7 +1,5 @@ import { db, - idp, - idpOrg, resourcePolicies, resourcePolicyHeaderAuth, resourcePolicyPassword, @@ -20,6 +18,7 @@ import { Config, ResourcePolicyData } from "./types"; import logger from "@server/logger"; import { getUniqueResourcePolicyName } from "@server/db/names"; import { hashPassword } from "@server/auth/password"; +import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "../billing/tierMatrix"; @@ -71,19 +70,13 @@ export async function updateResourcePolicies( // Validate auto-login-idp if provided if (policyData["auto-login-idp"]) { - const [provider] = await trx - .select() - .from(idp) - .innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId)) - .where( - and( - eq(idp.idpId, policyData["auto-login-idp"]), - eq(idpOrg.orgId, orgId) - ) - ) - .limit(1); + const providerExists = await idpExistsForOrg( + policyData["auto-login-idp"], + orgId, + trx + ); - if (!provider) { + if (!providerExists) { throw new Error( `Identity provider not found for policy '${policyNiceId}' in this organization` ); diff --git a/server/lib/idp/idpExistsForOrg.ts b/server/lib/idp/idpExistsForOrg.ts new file mode 100644 index 000000000..52530e7ba --- /dev/null +++ b/server/lib/idp/idpExistsForOrg.ts @@ -0,0 +1,36 @@ +import { db, idp, idpOrg, Transaction } from "@server/db"; +import { and, eq } from "drizzle-orm"; + +export function isOrgIdentityProviderMode(): boolean { + return process.env.IDENTITY_PROVIDER_MODE === "org"; +} + +/** + * Checks whether an identity provider can be used for the given org. + * In org IdP mode, the provider must be linked via idpOrg. + * In global IdP mode, the provider only needs to exist. + */ +export async function idpExistsForOrg( + idpId: number, + orgId: string, + dbOrTrx: typeof db | Transaction = db +): Promise { + if (isOrgIdentityProviderMode()) { + const [provider] = await dbOrTrx + .select({ idpId: idp.idpId }) + .from(idp) + .innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId)) + .where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId))) + .limit(1); + + return !!provider; + } + + const [provider] = await dbOrTrx + .select({ idpId: idp.idpId }) + .from(idp) + .where(eq(idp.idpId, idpId)) + .limit(1); + + return !!provider; +} diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 78eace74c..5cca3f446 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -14,8 +14,6 @@ import { hashPassword } from "@server/auth/password"; import { db, - idp, - idpOrg, orgs, resourcePolicies, resourcePolicyHeaderAuth, @@ -31,6 +29,7 @@ import { type ResourcePolicy } from "@server/db"; import { getUniqueResourcePolicyName } from "@server/db/names"; +import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg"; import response from "@server/lib/response"; import { getResourceRuleValueValidationError, @@ -204,14 +203,9 @@ export async function createResourcePolicy( // Check if Identity provider in `skipToIdpId` exists if (skipToIdpId) { - const [provider] = await db - .select() - .from(idp) - .innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId)) - .where(and(eq(idp.idpId, skipToIdpId), eq(idpOrg.orgId, orgId))) - .limit(1); + const providerExists = await idpExistsForOrg(skipToIdpId, orgId); - if (!provider) { + if (!providerExists) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, diff --git a/server/routers/policy/setResourcePolicyAccessControl.ts b/server/routers/policy/setResourcePolicyAccessControl.ts index 3af1c6eac..d4b7fcc02 100644 --- a/server/routers/policy/setResourcePolicyAccessControl.ts +++ b/server/routers/policy/setResourcePolicyAccessControl.ts @@ -2,8 +2,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, - idp, - idpOrg, resourcePolicies, rolePolicies, roles, @@ -18,6 +16,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { and, eq, inArray, ne } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; +import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg"; const setResourcePolicyAcccessControlBodySchema = z.strictObject({ sso: z.boolean(), @@ -27,7 +26,7 @@ const setResourcePolicyAcccessControlBodySchema = z.strictObject({ }), skipToIdpId: z.int().positive().optional().nullable().openapi({ type: "integer", - description: "Page number to retrieve" + description: "Default identity provider ID to skip to on login" }) }); @@ -36,8 +35,8 @@ const setResourcePolicyAccessControlParamsSchema = z.strictObject({ }); registry.registerPath({ - method: "post", - path: "/resource-policy/{resourceId}/access-control", + method: "put", + path: "/resource-policy/{resourcePolicyId}/access-control", description: "Set access control users for a resource policy, including SSO, users, roles, Identity provider.", tags: [OpenAPITags.PublicResourcePolicyLegacy], @@ -163,16 +162,9 @@ export async function setResourcePolicyAccessControl( // Check if Identity provider in `skipToIdpId` exists if (idpId) { - const [provider] = await db - .select() - .from(idp) - .innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId)) - .where( - and(eq(idp.idpId, idpId), eq(idpOrg.orgId, policy.orgId)) - ) - .limit(1); + const providerExists = await idpExistsForOrg(idpId, policy.orgId); - if (!provider) { + if (!providerExists) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, From ac79621cae5fb7b897d93bc63fabb3ee76e45064 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 29 Jul 2026 19:05:33 +0100 Subject: [PATCH 128/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Adjust=20the=20pol?= =?UTF-8?q?ling=20interval=20to=20`30s`=20for=20valid=20certs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useCertificate.ts | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/hooks/useCertificate.ts b/src/hooks/useCertificate.ts index 99e78f321..a71c3ef70 100644 --- a/src/hooks/useCertificate.ts +++ b/src/hooks/useCertificate.ts @@ -28,6 +28,8 @@ type UseCertificateReturn = { }; const POLL_JITTER_MS = durationToMs(1, "seconds"); +/** A valid cert isn't going anywhere soon, so check on it far less often. */ +const VALID_POLL_INTERVAL_MS = durationToMs(30, "seconds"); export function useCertificate({ orgId, @@ -35,7 +37,7 @@ export function useCertificate({ fullDomain, initialCertValue, polling = false, - pollingInterval = 5000 + pollingInterval = durationToMs(5, "seconds") }: UseCertificateProps): UseCertificateReturn { const api = createApiClient(useEnvContext()); const queryClient = useQueryClient(); @@ -49,19 +51,19 @@ export function useCertificate({ const { data, isLoading, isError, refetch } = useQuery({ ...certQuery, enabled: Boolean(orgId && domainId && fullDomain), - // Add a small random offset to each tick. Without it, every row in a - // table would poll at the exact same instant, hitting the server in - // bursts instead of spreading the requests out. - refetchInterval: polling - ? () => - Math.max( - 1000, - Math.round( - pollingInterval + - (Math.random() * 2 - 1) * POLL_JITTER_MS - ) - ) - : false, + refetchInterval: (query) => { + if (!polling) return false; + const interval = + query.state.data?.status === "valid" + ? VALID_POLL_INTERVAL_MS + : pollingInterval; + // Add a small random offset to each tick. Without it, every row in + // a table would poll at the exact same instant, hitting the server + // in bursts instead of spreading the requests out. + // the requests will be jittered by less or more than 1s + const jitter = (Math.random() * 2 - 1) * POLL_JITTER_MS; + return Math.max(1000, interval + jitter); + }, // An explicit `null` is a real answer ("this domain has no certificate") // and is seeded like any other, so a server-prefetched row doesn't // refetch on mount. Only an omitted prop leaves the cache empty. From 5cbb767e5f9e5653844931354a62a3eb967980a9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 29 Jul 2026 19:11:03 +0100 Subject: [PATCH 129/494] =?UTF-8?q?=E2=9C=A8=20Set=20default=20certificate?= =?UTF-8?q?s=20for=20private=20resources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/resources/private/page.tsx | 39 +++++++++++++++++++ src/components/PrivateResourcesTable.tsx | 11 +++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/app/[orgId]/settings/resources/private/page.tsx b/src/app/[orgId]/settings/resources/private/page.tsx index 348340aa9..cbd9ec7ef 100644 --- a/src/app/[orgId]/settings/resources/private/page.tsx +++ b/src/app/[orgId]/settings/resources/private/page.tsx @@ -6,6 +6,8 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import OrgProvider from "@app/providers/OrgProvider"; +import { build } from "@server/build"; +import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; import type { AxiosResponse } from "axios"; import type { Metadata } from "next"; @@ -99,6 +101,42 @@ export default async function ClientResourcesPage( }; } ); + + // Prefetched in one batched call so the table doesn't fire a separate + // certificate request per visible row once it mounts on the client. + const certDomains = Array.from( + new Set( + internalResourceRows + .filter( + (r) => + r.mode === "http" && + !r.alias && + r.ssl && + r.domainId && + r.fullDomain + ) + .map((r) => r.fullDomain as string) + ) + ); + + let initialCertificates: GetBatchedCertificateResponse | undefined; + if (build !== "oss" && certDomains.length > 0) { + try { + const certSearchParams = new URLSearchParams( + certDomains.map((domain) => ["domains", domain]) + ); + const certRes = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/batched-certificates?${certSearchParams.toString()}`, + await authCookieHeader() + ); + initialCertificates = certRes.data.data; + } catch { + // leave undefined so each row falls back to fetching its own + } + } + return ( <> diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 6c2999624..5ee23f424 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -37,6 +37,7 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types"; import { UpdateSiteResourceResponse } from "@server/routers/siteResource"; import type { PaginationState } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; @@ -136,13 +137,16 @@ type ClientResourcesTableProps = { orgId: string; pagination: PaginationState; rowCount: number; + /** Certificates prefetched on the server, keyed by full domain. */ + initialCertificates?: GetBatchedCertificateResponse; }; export default function PrivateResourcesTable({ internalResources, orgId, pagination, - rowCount + rowCount, + initialCertificates }: ClientResourcesTableProps) { const router = useRouter(); const { @@ -432,6 +436,9 @@ export default function PrivateResourcesTable({ orgId={resourceRow.orgId} domainId={domainId} fullDomain={fullDomain} + initialCertValue={ + initialCertificates?.[fullDomain] + } /> ) : null}
@@ -585,7 +592,7 @@ export default function PrivateResourcesTable({ ]; return cols; - }, [orgId, t, searchParams]); + }, [orgId, t, searchParams, initialCertificates]); function handleFilterChange( column: string, From bc267b71071963645120eead845793514e78a07b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 29 Jul 2026 19:11:16 +0100 Subject: [PATCH 130/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CertificateStatus.tsx | 6 +++--- src/components/PublicResourcesTable.tsx | 2 -- src/components/ResourceAccessCertIndicator.tsx | 3 ++- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/CertificateStatus.tsx b/src/components/CertificateStatus.tsx index 9b1f52c03..88da20ff0 100644 --- a/src/components/CertificateStatus.tsx +++ b/src/components/CertificateStatus.tsx @@ -5,6 +5,7 @@ import { FileBadge, RotateCw } from "lucide-react"; import { useCertificate } from "@app/hooks/useCertificate"; import type { GetCertificateResponse } from "@server/routers/certificates/types"; import { useTranslations } from "next-intl"; +import { durationToMs } from "@app/lib/durationToMs"; export type CertificateStatusContentProps = { cert: GetCertificateResponse | null; @@ -32,8 +33,7 @@ export function CertificateStatusContent({ const labelClass = "inline-flex shrink-0 items-center self-center text-sm font-medium leading-normal"; - const valueClass = - "inline-flex items-center gap-2 text-sm leading-normal"; + const valueClass = "inline-flex items-center gap-2 text-sm leading-normal"; const handleRefresh = async () => { await refreshCert(); @@ -202,7 +202,7 @@ export default function CertificateStatus({ className = "", onRefresh, polling = false, - pollingInterval = 5000 + pollingInterval = durationToMs(5, "seconds") }: CertificateStatusProps) { const hook = useCertificate({ orgId, diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index a5ada101d..09f451a69 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -142,8 +142,6 @@ export default function PublicResourcesTable({ const [selectedResource, setSelectedResource] = useState(); - const { isPaidUser } = usePaidStatus(); - const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); diff --git a/src/components/ResourceAccessCertIndicator.tsx b/src/components/ResourceAccessCertIndicator.tsx index 9459add3a..4fc9c4c2d 100644 --- a/src/components/ResourceAccessCertIndicator.tsx +++ b/src/components/ResourceAccessCertIndicator.tsx @@ -18,6 +18,7 @@ import { useState, type ReactNode } from "react"; +import { durationToMs } from "@app/lib/durationToMs"; type ResourceAccessCertIndicatorProps = { orgId: string; @@ -58,7 +59,7 @@ export function ResourceAccessCertIndicator({ fullDomain, initialCertValue, polling: open, - pollingInterval: 5000 + pollingInterval: durationToMs(5, "seconds") }); const { cert, certLoading, certError, refreshing, fetchCert } = certificate; From e0a87212072eda9f1f4e8234d3934d40ee42f9d7 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 29 Jul 2026 22:29:56 +0100 Subject: [PATCH 131/494] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20idp=20type=20icon?= =?UTF-8?q?=20in=20last=20used=20idp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/IdpTypeIcon.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/IdpTypeIcon.tsx b/src/components/IdpTypeIcon.tsx index be49f9654..ca81ede4f 100644 --- a/src/components/IdpTypeIcon.tsx +++ b/src/components/IdpTypeIcon.tsx @@ -23,6 +23,8 @@ export default function IdpTypeIcon({ }: Props) { const effectiveType = (variant || type || "").toLowerCase(); + console.log(`[IdpTypeIcon]`, { effectiveType, variant, type }); + let src: string | null = null; let defaultAlt = ""; From 9f297317f2d2a25485c9faaff4ae56004b9f6211 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 29 Jul 2026 17:33:49 -0400 Subject: [PATCH 132/494] bump version --- server/lib/consts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 9ee4ec4c3..9744d8665 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.21.0"; +export const APP_VERSION = "1.21.1"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); From 41dbc7d5ed087d515a637c7a3e01dea3634db3b5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 29 Jul 2026 17:45:19 -0400 Subject: [PATCH 133/494] remove time logs --- server/lib/statusHistory.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 0ec338d93..0ae666dd5 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -41,7 +41,6 @@ export async function getCachedStatusHistory( return cached; } - console.time(`[getCachedStatusHistory/${entityType}=${entityId}]`); // Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query // window aligns with stable calendar days for the requesting client const todayMidnightSec = localMidnightSec(tzOffsetMinutes); @@ -77,14 +76,12 @@ export async function getCachedStatusHistory( const priorStatus = lastKnownEvent?.status ?? null; - console.time(`[computeBuckets/${entityType}=${entityId}]`); const { buckets, totalDowntime } = computeBuckets( events, days, priorStatus, tzOffsetMinutes ); - console.timeEnd(`[computeBuckets/${entityType}=${entityId}]`); const totalWindow = days * 86400; const overallUptime = totalWindow > 0 @@ -99,7 +96,6 @@ export async function getCachedStatusHistory( totalDowntimeSeconds: totalDowntime }; - console.timeEnd(`[getCachedStatusHistory/${entityType}=${entityId}]`); await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); return result; } @@ -317,10 +313,6 @@ export async function getBatchedStatusHistory( days: number, tzOffsetMinutes: number = 0 ): Promise { - console.time( - `[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]` - ); - // Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query // window aligns with stable calendar days for the requesting client const todayMidnightSec = localMidnightSec(tzOffsetMinutes); @@ -398,7 +390,6 @@ export async function getBatchedStatusHistory( const result: BatchedStatusHistoryResponse = {}; - console.time(`[computeBuckets/${entityType}=(${entityIds.join(" ,")})]`); for (const entityId in eventStatusMap) { const event = eventStatusMap[Number(entityId)]; const priorStatus = event.lastKnownEvent?.status ?? null; @@ -426,10 +417,5 @@ export async function getBatchedStatusHistory( totalDowntimeSeconds: totalDowntime }; } - console.timeEnd(`[computeBuckets/${entityType}=(${entityIds.join(" ,")})]`); - - console.timeEnd( - `[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]` - ); return result; } From e7f147d4cac51cb8f995bbf8a4a22a59db846bff Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 29 Jul 2026 17:52:55 -0400 Subject: [PATCH 134/494] fix site status stuck loading sometimes --- src/components/SitesTable.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 2c4ecd1e5..5af07d112 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -114,7 +114,7 @@ export default function SitesTable({ const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); - const siteIds = sites.map((s) => s.id); + const siteIds = useMemo(() => sites.map((s) => s.id), [sites]); const statusHistoryQuery = useQuery({ ...orgQueries.batchedSiteStatusHistory({ @@ -638,7 +638,14 @@ export default function SitesTable({ ]; return cols; - }, [orgId, t, searchParams, latestNewtVersion]); + }, [ + orgId, + t, + searchParams, + latestNewtVersion, + statusHistoryQuery.data, + statusHistoryQuery.isLoading + ]); function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); From 263976bf419b481b77dc2ad86134fc723f38cb97 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:13:40 -0400 Subject: [PATCH 135/494] New translations en-us.json (Spanish) [ci skip] --- messages/es-ES.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/messages/es-ES.json b/messages/es-ES.json index e677252a0..fe684502a 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Confirmar Eliminación de Enlace Compartible", "shareQuestionRemove": "¿Está seguro de que desea borrar este enlace compartido?", "shareMessageRemove": "Una vez borrado, el enlace dejará de funcionar y cualquier persona que lo use perderá acceso al recurso.", - "shareTokenDescription": "El token de acceso puede ser pasado de dos maneras: como parámetro de consulta o en las cabeceras de solicitud. Estos deben ser pasados del cliente en cada solicitud de acceso autenticado.", + "shareTokenDescription": "El token de acceso se puede pasar como un parámetro de consulta o en los encabezados de la solicitud. Por defecto, se debe enviar en cada solicitud. Si la persistencia de sesión está habilitada, la primera solicitud lo intercambia por una cookie de sesión.", "accessToken": "Token de acceso", "usageExamples": "Ejemplos de uso", "tokenId": "ID de token", @@ -196,8 +196,14 @@ "shareTitleOptional": "Título (opcional)", "sharePathOptional": "Ruta (opcional)", "sharePathDescription": "El enlace redirigirá a los usuarios a esta ruta tras la autenticación.", + "shareAssociateUserOptional": "Asociar Usuario (opcional)", + "shareAssociateUserDescription": "Cuando está configurado, las solicitudes que usan este enlace se atribuyen al usuario en los registros de acceso y encabezados de identidad. El enlace se elimina si el usuario abandona la organización.", + "userSelect": "Seleccione usuario", + "usersNotFound": "No se encontraron usuarios", "expireIn": "Caduca en", "neverExpire": "Nunca expirar", + "sharePersistSession": "Persistir sesión después del primer uso", + "sharePersistSessionDescription": "Cuando está habilitado, la primera solicitud con este token mediante un parámetro de consulta o encabezado configura una cookie de sesión, por lo que las solicitudes posteriores no necesitan el token. Dejar desactivado para clientes de API que deben enviar el token en cada solicitud.", "shareExpireDescription": "El tiempo de caducidad es cuánto tiempo el enlace será utilizable y proporcionará acceso al recurso. Después de este tiempo, el enlace ya no funcionará, y los usuarios que usaron este enlace perderán el acceso al recurso.", "shareSeeOnce": "Sólo podrás ver este enlace una vez. Asegúrate de copiarlo.", "shareAccessHint": "Cualquiera con este enlace puede acceder al recurso. Compártelo con cuidado.", @@ -928,7 +934,7 @@ "newtVersion": "Versión", "architecture": "Arquitectura", "sites": "Sitios", - "siteWgAnyClients": "Usa cualquier cliente de Wirex para conectarte. Tendrás que dirigirte a los recursos internos usando la IP de compañeros.", + "siteWgAnyClients": "Usa cualquier cliente de WireGuard para conectarte. Tendrás que dirigirte a recursos privados usando la IP del par.", "siteWgCompatibleAllClients": "Compatible con todos los clientes de Wirex Guard", "siteWgManualConfigurationRequired": "Configuración manual requerida", "userErrorNotAdminOrOwner": "El usuario no es un administrador o propietario", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "Subdominio: {subdomain}", "domainPickerNamespace": "Espacio de nombres: {namespace}", "domainPickerShowMore": "Mostrar más", + "domainPickerNoDomainsAvailableTitle": "No hay dominios disponibles", + "domainPickerNoDomainsAvailableDescription": "Aún no tiene ningún dominio configurado. Cree un dominio para continuar.", + "domainPickerNoDomainsAvailableAction": "Ir a Dominios", "regionSelectorTitle": "Seleccionar Región", "domainPickerRemoteExitNodeWarning": "Los dominios suministrados no son compatibles cuando los sitios se conectan a nodos de salida remotos. Para que los recursos estén disponibles en nodos remotos, utilice un dominio personalizado en su lugar.", "regionSelectorInfo": "Seleccionar una región nos ayuda a brindar un mejor rendimiento para tu ubicación. No tienes que estar en la misma región que tu servidor.", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "Público", "resourcesTableClientResources": "Privado", "resourcesTableNoProxyResourcesFound": "No se encontraron recursos de proxy.", - "resourcesTableNoInternalResourcesFound": "No se encontraron recursos internos.", + "resourcesTableNoInternalResourcesFound": "No se encontraron recursos privados.", "resourcesTableDestination": "Destino", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Dirección del alias", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "Cancelar", "editInternalResourceDialogSaveResource": "Guardar recurso", "editInternalResourceDialogSuccess": "Éxito", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Recurso interno actualizado con éxito", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Recurso privado actualizado con éxito", "editInternalResourceDialogError": "Error", - "editInternalResourceDialogFailedToUpdateInternalResource": "Error al actualizar el recurso interno", + "editInternalResourceDialogFailedToUpdateInternalResource": "Error al actualizar el recurso privado", "editInternalResourceDialogNameRequired": "El nombre es requerido", "editInternalResourceDialogNameMaxLength": "El nombre no debe tener más de 255 caracteres", "editInternalResourceDialogProxyPortMin": "El puerto del proxy debe ser al menos 1", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Un alias DNS interno opcional para este recurso.", "createInternalResourceDialogNoSitesAvailable": "No hay sitios disponibles", - "createInternalResourceDialogNoSitesAvailableDescription": "Necesita tener al menos un sitio de Newt con una subred configurada para crear recursos internos.", + "createInternalResourceDialogNoSitesAvailableDescription": "Necesita tener al menos un sitio de Newt con una subred configurada para crear recursos privados.", "createInternalResourceDialogClose": "Cerrar", "createInternalResourceDialogCreateClientResource": "Crear recurso privado", "createInternalResourceDialogCreateClientResourceDescription": "Crear un nuevo recurso que sólo será accesible a los clientes conectados a la organización", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "Cancelar", "createInternalResourceDialogCreateResource": "Crear recurso", "createInternalResourceDialogSuccess": "Éxito", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Recurso interno creado con éxito", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Recurso privado creado con éxito", "createInternalResourceDialogError": "Error", - "createInternalResourceDialogFailedToCreateInternalResource": "Error al crear recurso interno", + "createInternalResourceDialogFailedToCreateInternalResource": "Error al crear recurso privado", "createInternalResourceDialogNameRequired": "El nombre es requerido", "createInternalResourceDialogNameMaxLength": "El nombre debe ser menor de 255 caracteres", "createInternalResourceDialogPleaseSelectSite": "Por favor seleccione un sitio", @@ -3089,8 +3098,8 @@ "sourceAddress": "Dirección de origen", "destinationAddress": "Dirección de destino", "duration": "Duración", - "licenseRequiredToUse": "Se requiere una licencia Enterprise Edition o Pangolin Cloud para usar esta función. Reserve una demostración o prueba POC.", - "ossEnterpriseEditionRequired": "La Enterprise Edition es necesaria para utilizar esta función. Esta función también está disponible en Pangolin Cloud. Reserva una demostración o prueba POC.", + "licenseRequiredToUse": "Se requiere una licencia Enterprise Edition o Pangolin Cloud para usar esta función. Reserve una demostración gratuita o una prueba POC para saber más.", + "ossEnterpriseEditionRequired": "La Enterprise Edition es necesaria para utilizar esta función. Esta función también está disponible en Pangolin Cloud. Reserva una demostración gratuita o prueba POC para saber más.", "certResolver": "Resolver certificado", "certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.", "selectCertResolver": "Seleccionar Resolver Certificado", From 08b5a9b34b0288a3455c567573b550ed942b26fc Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:13:42 -0400 Subject: [PATCH 136/494] New translations en-us.json (Bulgarian) [ci skip] --- messages/bg-BG.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 4bf0d9ab6..7ff5eda75 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Потвърдете изтриването на споделима връзка", "shareQuestionRemove": "Сигурни ли сте, че искате да изтриете тази споделена връзка?", "shareMessageRemove": "След изтриване връзката вече няма да работи и всеки, който я използва, ще загуби достъп до ресурса.", - "shareTokenDescription": "Достъпният токен може да бъде предаван по два начина: като параметър или в хедърите на заявките. Те трябва да бъдат предавани от клиента при всяка заявка за удостоверен достъп.", + "shareTokenDescription": "Токен за достъп може да бъде предаден като параметър на заявка или в хедърите на заявката. По подразбиране трябва да се изпраща при всяка заявка. Ако е активирано задържане на сесията, първата заявка го заменя за сесийна бисквитка.", "accessToken": "Достъп Токен", "usageExamples": "Примери за използване", "tokenId": "Токен ID", @@ -196,8 +196,14 @@ "shareTitleOptional": "Заглавие (по избор)", "sharePathOptional": "Път (по избор)", "sharePathDescription": "След удостоверяване, линкът ще препрати потребителите на този път.", + "shareAssociateUserOptional": "Асоцииране на потребител (по избор)", + "shareAssociateUserDescription": "Когато е настроен, заявките използващи този линк се свързват с потребителя в достъпните логове и заглавия за идентичност. Линкът се премахва, ако потребителят напусне организацията.", + "userSelect": "Изберете потребител", + "usersNotFound": "Няма намерени потребители", "expireIn": "Изтече", "neverExpire": "Никога не изтича", + "sharePersistSession": "Задръж сесия след първо използване", + "sharePersistSessionDescription": "Когато е активирано, първата заявка с този токен чрез параметър на заявка или хедър създава сесийна бисквитка, така че следващите заявки не се нуждаят от токена. Оставете го изключено за клиенти на API, които трябва да изпращат токена при всяка заявка.", "shareExpireDescription": "Времето на изтичане е колко дълго връзката ще бъде използваема и ще предоставя достъп до ресурса. След това време, връзката няма да работи и потребителите, които са я използвали, ще загубят достъп до ресурса.", "shareSeeOnce": "Ще можете да видите този линк само веднъж. Уверете се, че го копирате.", "shareAccessHint": "Всеки с тази връзка може да има достъп до ресурса. Споделяйте я с внимание.", @@ -928,7 +934,7 @@ "newtVersion": "Версия", "architecture": "Архитектура", "sites": "Сайтове", - "siteWgAnyClients": "Използвайте клиент на WireGuard, за да се свържете. Ще трябва да използвате вътрешните ресурси чрез IP адреса на връстника.", + "siteWgAnyClients": "Използвайте всеки WireGuard клиент, за да се свържете. Ще трябва да адресирате частни ресурси, използвайки IP на връстника.", "siteWgCompatibleAllClients": "Съвместим с всички WireGuard клиенти", "siteWgManualConfigurationRequired": "Необходима е ръчна конфигурация", "userErrorNotAdminOrOwner": "Потребителят не е администратор или собственик", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "Поддомейн: {subdomain}", "domainPickerNamespace": "Име на пространство: {namespace}", "domainPickerShowMore": "Покажи повече", + "domainPickerNoDomainsAvailableTitle": "Няма налични домейни", + "domainPickerNoDomainsAvailableDescription": "Все още нямате конфигурирани домейни. Създайте домейн, за да продължите.", + "domainPickerNoDomainsAvailableAction": "Отидете на Домейни", "regionSelectorTitle": "Избор на регион", "domainPickerRemoteExitNodeWarning": "Предоставените домейни не се поддържат, когато сайтовете се свързват към отдалечени крайни възли. За да бъдат ресурсите налични на отдалечени възли, използвайте персонализиран домейн вместо това.", "regionSelectorInfo": "Изборът на регион ни помага да предоставим по-добра производителност за вашето местоположение. Не е необходимо да сте в същия регион като сървъра.", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "Публичен", "resourcesTableClientResources": "Частен", "resourcesTableNoProxyResourcesFound": "Не са намерени ресурсни проксита.", - "resourcesTableNoInternalResourcesFound": "Не са намерени вътрешни ресурси.", + "resourcesTableNoInternalResourcesFound": "Не са намерени частни ресурси.", "resourcesTableDestination": "Дестинация", "resourcesTableAlias": "Псевдоним", "resourcesTableAliasAddress": "Адрес на псевдоним.", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "Отмяна", "editInternalResourceDialogSaveResource": "Запазване на ресурс", "editInternalResourceDialogSuccess": "Успех", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Вътрешният ресурс успешно актуализиран", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Частният ресурс е успешно обновен", "editInternalResourceDialogError": "Грешка", - "editInternalResourceDialogFailedToUpdateInternalResource": "Неуспешно актуализиране на вътрешен ресурс", + "editInternalResourceDialogFailedToUpdateInternalResource": "Неуспешен опит за обновяване на частен ресурс", "editInternalResourceDialogNameRequired": "Името е задължително", "editInternalResourceDialogNameMaxLength": "Името трябва да е по-малко от 255 символа", "editInternalResourceDialogProxyPortMin": "Прокси портът трябва да бъде поне 1", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "Псевдоним", "editInternalResourceDialogAliasDescription": "По избор вътрешен DNS псевдоним за този ресурс.", "createInternalResourceDialogNoSitesAvailable": "Няма достъпни сайтове", - "createInternalResourceDialogNoSitesAvailableDescription": "Трябва да имате поне един сайт на Newt с конфигурирана мрежа, за да създадете вътрешни ресурси.", + "createInternalResourceDialogNoSitesAvailableDescription": "Трябва да имате поне един Newt сайт с конфигуриран сабнет, за да създадете частни ресурси.", "createInternalResourceDialogClose": "Затвори", "createInternalResourceDialogCreateClientResource": "Създаване на частен ресурс", "createInternalResourceDialogCreateClientResourceDescription": "Създайте нов ресурс, който ще бъде достъпен само за клиенти, свързани към организацията", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "Отмяна", "createInternalResourceDialogCreateResource": "Създаване на ресурс", "createInternalResourceDialogSuccess": "Успех", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Вътрешният ресурс създаден успешно", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Частният ресурс е създаден успешно", "createInternalResourceDialogError": "Грешка", - "createInternalResourceDialogFailedToCreateInternalResource": "Неуспешно създаване на вътрешен ресурс", + "createInternalResourceDialogFailedToCreateInternalResource": "Неуспешен опит за създаване на частен ресурс", "createInternalResourceDialogNameRequired": "Името е задължително", "createInternalResourceDialogNameMaxLength": "Името трябва да е по-малко от 255 символа", "createInternalResourceDialogPleaseSelectSite": "Моля, изберете сайт", @@ -3089,8 +3098,8 @@ "sourceAddress": "Източен адрес", "destinationAddress": "Адрес на дестинация", "duration": "Продължителност", - "licenseRequiredToUse": "Изисква се лиценз за Enterprise Edition или Pangolin Cloud за използване на тази функция. Резервирайте демонстрация или пробен POC.", - "ossEnterpriseEditionRequired": "Enterprise Edition е необходим за използване на тази функция. Тази функция също е налична в Pangolin Cloud. Резервирайте демонстрация или пробен POC.", + "licenseRequiredToUse": "Изисква се лиценз за Enterprise Edition или Pangolin Cloud, за да използвате тази функция. Резервирайте безплатна демонстрация или пробен POC, за да научите повече.", + "ossEnterpriseEditionRequired": "Enterprise Edition е необходим за използване на тази функция. Тази функция също е налична в Pangolin Cloud. Резервирайте безплатна демонстрация или пробен POC, за да научите повече.", "certResolver": "Решавач на сертификати", "certResolverDescription": "Изберете решавач на сертификати за използване за този ресурс.", "selectCertResolver": "Изберете решавач на сертификати", From d6264fb39ad08c03d1d1d0eedd3a76b8064b0162 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:13:44 -0400 Subject: [PATCH 137/494] New translations en-us.json (Czech) [ci skip] --- messages/cs-CZ.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 0d96a80cd..99c210d64 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Potvrdit odstranění sdílného odkazu", "shareQuestionRemove": "Jste si jisti, že chcete smazat tento odkaz ke sdílení?", "shareMessageRemove": "Jakmile bude smazán, odkaz přestane fungovat a všichni, kdo jej používají, ztratí přístup k prostředku.", - "shareTokenDescription": "Přístupový token může být předán dvěma způsoby: jako parametr dotazu nebo v záhlaví požadavku. Tyto údaje musí být předány klientovi na každé žádosti o ověřený přístup.", + "shareTokenDescription": "Přístupový token může být předán jako dotazový parametr nebo v hlavičkách žádostí. Ve výchozím nastavení musí být odesílán s každou žádostí. Pokud je povolena perzistence relace, první žádost jej vymění za relaci cookie.", "accessToken": "Přístupový token", "usageExamples": "Příklady použití", "tokenId": "ID tokenu", @@ -196,8 +196,14 @@ "shareTitleOptional": "Název (volitelné)", "sharePathOptional": "Cesta (volitelně)", "sharePathDescription": "Odkaz přesměruje uživatele na tuto cestu po autentikaci.", + "shareAssociateUserOptional": "Přiřadit uživatele (volitelné)", + "shareAssociateUserDescription": "Pokud je nastaveno, žádosti pomocí tohoto odkazu jsou v přístupových protokolech a hlavičkách identity přidružené k uživateli. Odkaz je odstraněn, pokud uživatel opustí organizaci.", + "userSelect": "Vyberte uživatele", + "usersNotFound": "Nebyl nalezen žádný uživatel", "expireIn": "Platnost vyprší za", "neverExpire": "Nikdy nevyprší", + "sharePersistSession": "Udržet relaci po prvním použití", + "sharePersistSessionDescription": "Pokud je povoleno, první žádost s tímto tokenem prostřednictvím dotazu nebo hlavičky nastaví relaci cookie, takže pozdější žádosti nepotřebují token. Nechcete-li, aby API klienti posílali token s každou žádostí, vypněte to.", "shareExpireDescription": "Doba platnosti určuje, jak dlouho bude odkaz použitelný a bude poskytovat přístup ke zdroji. Po této době odkaz již nebude fungovat a uživatelé kteří tento odkaz používali ztratí přístup ke zdroji.", "shareSeeOnce": "Tento odkaz uvidíte pouze jednou. Nezapomeňte jej zkopírovat.", "shareAccessHint": "Kdokoli s tímto odkazem může přistupovat ke zdroji. Sdílejte jej s rozvahou.", @@ -928,7 +934,7 @@ "newtVersion": "Verze", "architecture": "Architektura", "sites": "Stránky", - "siteWgAnyClients": "K připojení použijte jakéhokoli klienta WireGuard. Budete muset řešit interní zdroje pomocí klientské IP adresy.", + "siteWgAnyClients": "Pro připojení použijte jakéhokoli klienta WireGuard. Budete muset adresovat privátní zdroje pomocí IP protějšku.", "siteWgCompatibleAllClients": "Kompatibilní se všemi klienty aplikace WireGuard", "siteWgManualConfigurationRequired": "Je vyžadována ruční konfigurace", "userErrorNotAdminOrOwner": "Uživatel není administrátor nebo vlastník", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "Subdoména: {subdomain}", "domainPickerNamespace": "Jmenný prostor: {namespace}", "domainPickerShowMore": "Zobrazit více", + "domainPickerNoDomainsAvailableTitle": "Nejsou k dispozici žádné domény", + "domainPickerNoDomainsAvailableDescription": "Zatím nemáte žádné nastavené domény. Vytvořte doménu, abyste mohli pokračovat.", + "domainPickerNoDomainsAvailableAction": "Přejít na domény", "regionSelectorTitle": "Vybrat region", "domainPickerRemoteExitNodeWarning": "Poskytnuté domény nejsou podporovány, když se stránky připojují k vzdáleným výstupním uzlům. Pro dostupné zdroje na vzdálených uzlech použijte vlastní doménu.", "regionSelectorInfo": "Výběr regionu nám pomáhá poskytovat lepší výkon pro vaši polohu. Nemusíte být ve stejném regionu jako váš server.", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "Veřejnost", "resourcesTableClientResources": "Soukromé", "resourcesTableNoProxyResourcesFound": "Nebyly nalezeny žádné zdroje proxy", - "resourcesTableNoInternalResourcesFound": "Nebyly nalezeny žádné vnitřní zdroje.", + "resourcesTableNoInternalResourcesFound": "Nebyly nalezeny žádné privátní zdroje.", "resourcesTableDestination": "Místo určení", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Adresa aliasu", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "Zrušit", "editInternalResourceDialogSaveResource": "Uložit dokument", "editInternalResourceDialogSuccess": "Úspěšně", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Interní zdroj byl úspěšně aktualizován", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Privátní zdroj byl úspěšně aktualizován", "editInternalResourceDialogError": "Chyba", - "editInternalResourceDialogFailedToUpdateInternalResource": "Aktualizace interního zdroje se nezdařila", + "editInternalResourceDialogFailedToUpdateInternalResource": "Nepodařilo se aktualizovat privátní zdroj", "editInternalResourceDialogNameRequired": "Název je povinný", "editInternalResourceDialogNameMaxLength": "Název musí mít méně než 255 znaků", "editInternalResourceDialogProxyPortMin": "Port proxy serveru musí být alespoň 1", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Volitelný interní DNS alias pro tento dokument.", "createInternalResourceDialogNoSitesAvailable": "Nejsou k dispozici žádné weby", - "createInternalResourceDialogNoSitesAvailableDescription": "Musíte mít alespoň jeden Newt web s podsítí nakonfigurovanou pro vytvoření vnitřních zdrojů.", + "createInternalResourceDialogNoSitesAvailableDescription": "K vytvoření privátních zdrojů potřebujete mít alespoň jednu lokalitu Newt s nastavenou podsítí.", "createInternalResourceDialogClose": "Zavřít", "createInternalResourceDialogCreateClientResource": "Vytvořit soukromý zdroj", "createInternalResourceDialogCreateClientResourceDescription": "Vytvořte nový zdroj, který bude přístupný pouze klientům připojeným k organizaci", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "Zrušit", "createInternalResourceDialogCreateResource": "Vytvořit zdroj", "createInternalResourceDialogSuccess": "Úspěšně", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Interní zdroj byl úspěšně vytvořen", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Privátní zdroj byl úspěšně vytvořen", "createInternalResourceDialogError": "Chyba", - "createInternalResourceDialogFailedToCreateInternalResource": "Nepodařilo se vytvořit interní zdroj", + "createInternalResourceDialogFailedToCreateInternalResource": "Nepodařilo se vytvořit privátní zdroj", "createInternalResourceDialogNameRequired": "Název je povinný", "createInternalResourceDialogNameMaxLength": "Název musí mít méně než 255 znaků", "createInternalResourceDialogPleaseSelectSite": "Vyberte prosím web", @@ -3089,8 +3098,8 @@ "sourceAddress": "Zdrojová adresa", "destinationAddress": "Cílová adresa", "duration": "Doba trvání", - "licenseRequiredToUse": "Pro použití této funkce je vyžadována licence Enterprise Edition nebo Pangolin Cloud . Zarezervujte si demo nebo POC zkušební verzi.", - "ossEnterpriseEditionRequired": "Enterprise Edition je vyžadována pro použití této funkce. Tato funkce je také k dispozici v Pangolin Cloud. Rezervujte si demo nebo POC zkušební verzi.", + "licenseRequiredToUse": "Pro použití této funkce je vyžadována licence Enterprise Edition nebo Pangolin Cloud. Zarezervujte si bezplatné demo nebo POC zkušební verzi, abyste se dozvěděli více.", + "ossEnterpriseEditionRequired": "Enterprise Edition je vyžadována pro použití této funkce. Tato funkce je také k dispozici v Pangolin Cloud. Rezervujte si demo nebo POC zkušební verzi zdarma, abyste se dozvěděli více.", "certResolver": "Oddělovač certifikátů", "certResolverDescription": "Vyberte řešitele certifikátů pro tento dokument.", "selectCertResolver": "Vyberte řešič certifikátů", From f22301a1eb5f467fd0b24821a591fb04b423bd7c Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:13:46 -0400 Subject: [PATCH 138/494] New translations en-us.json (Danish) [ci skip] --- messages/da-DK.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/messages/da-DK.json b/messages/da-DK.json index f88ffa474..e3eda602b 100644 --- a/messages/da-DK.json +++ b/messages/da-DK.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Bekræft sletning af delbar link", "shareQuestionRemove": "Er du sikker på, at du vil slette dette delingslink?", "shareMessageRemove": "Når linket er slettet, vil det ikke længere fungere, og alle, der bruger det, mister adgang til ressourcen.", - "shareTokenDescription": "Adgangstoken kan sendes på to måter: som en queryparameter eller i request-headers. Disse skal sendes fra klienten på hver forespørgsel om autentificeret adgang.", + "shareTokenDescription": "Adgangstokenet kan sendes som en forespørgselsparameter eller i anmodningsoverskrifter. Som standard skal det sendes ved hver anmodning. Hvis sessionsvedholdelse er aktiveret, byttes den første anmodning for en sessionscookie.", "accessToken": "Adgangstoken", "usageExamples": "Brukseksempler", "tokenId": "Token-ID", @@ -196,8 +196,14 @@ "shareTitleOptional": "Titel (valgfrit)", "sharePathOptional": "Sti (valgfrit)", "sharePathDescription": "Linket vil videresende brugere til denne stien efter autentificering.", + "shareAssociateUserOptional": "Tilknyt brugeren (valgfrit)", + "shareAssociateUserDescription": "Når det er indstillet, tilskrives anmodninger ved hjælp af dette link brugeren i adgangslogfiler og identitetsoverskrifter. Linket fjernes, hvis brugeren forlader organisationen.", + "userSelect": "Vælg bruger", + "usersNotFound": "Ingen brugere fundet", "expireIn": "Udløber om", "neverExpire": "Udløber aldrig", + "sharePersistSession": "Bevar session efter første brug", + "sharePersistSessionDescription": "Når det er aktiveret, sætter den første anmodning med dette token via en forespørgselsparameter eller overskrift en sessionscookie, så senere anmodninger ikke behøver tokenet. Lad være med det til API-klienter, der ønsker at sende tokenet ved hver anmodning.", "shareExpireDescription": "Udløbstid er hvor længe linket vil være brukbar og give adgang til ressourcen. Efter denne tiden vil linket ikke længere fungere, og brugere som brugte denne linket vil miste adgangen til ressourcen.", "shareSeeOnce": "Du vil kun kunne se dette link én gang. Sørg for at kopiere det.", "shareAccessHint": "Alle med denne linket kan få adgang til ressourcen. Del forsiktig.", @@ -928,7 +934,7 @@ "newtVersion": "Version", "architecture": "Arkitektur", "sites": "Websteder", - "siteWgAnyClients": "Brug hvilken som helst WireGuard klient til at oprette forbindelse til. Du skal adressere interne ressourcer ved hjælp af peer IP.", + "siteWgAnyClients": "Brug enhver WireGuard-klient til at oprette forbindelse. Du skal adressere private ressourcer ved hjælp af peer IP.", "siteWgCompatibleAllClients": "Kompatibel med alle WireGuard-klienter", "siteWgManualConfigurationRequired": "Manuel konfiguration påkrævet", "userErrorNotAdminOrOwner": "Bruger er ikke administrator eller ejer", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "Underdomæne: {subdomain}", "domainPickerNamespace": "Navnerom: {namespace}", "domainPickerShowMore": "Vis mere", + "domainPickerNoDomainsAvailableTitle": "Ingen domæner tilgængelige", + "domainPickerNoDomainsAvailableDescription": "Du har endnu ikke oprettet nogen domæner. Opret et domæne for at fortsætte.", + "domainPickerNoDomainsAvailableAction": "Gå til domæner", "regionSelectorTitle": "Vælg Region", "domainPickerRemoteExitNodeWarning": "Leverede domæner understøttes ikke, når sites kobles til eksterne exitnoder. For ressourcer, der skal være tilgængelige på eksterne noder, skal du i stedet bruge et brugerdefineret domæne.", "regionSelectorInfo": "At vælge en region hjelper oss med at give bedre ydeevne for din placering. Du behøver ikke være i samme region som serveren.", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "Offentlig", "resourcesTableClientResources": "Privat", "resourcesTableNoProxyResourcesFound": "Ingen proxy-ressourcer fundet.", - "resourcesTableNoInternalResourcesFound": "Ingen interne ressourcer fundet.", + "resourcesTableNoInternalResourcesFound": "Ingen private ressourcer fundet.", "resourcesTableDestination": "Destination", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Alias adresse", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "Annuller", "editInternalResourceDialogSaveResource": "Gem ressource", "editInternalResourceDialogSuccess": "Succes", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Intern ressource opdateret med succes", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Privat ressource opdateret succesfuldt", "editInternalResourceDialogError": "Fejl", - "editInternalResourceDialogFailedToUpdateInternalResource": "Mislykkedes at opdatere intern ressource", + "editInternalResourceDialogFailedToUpdateInternalResource": "Mislykkedes at opdatere privat ressource", "editInternalResourceDialogNameRequired": "Navn er påkrævet", "editInternalResourceDialogNameMaxLength": "Navn kan ikke være længere enn 255 tegn", "editInternalResourceDialogProxyPortMin": "Proxy-port skal være mindst 1", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Et valgfrit internt DNS-alias for denne ressource.", "createInternalResourceDialogNoSitesAvailable": "Ingen tilgængelige steder", - "createInternalResourceDialogNoSitesAvailableDescription": "Du skal have mindst ét Newt-site med et konfigureret subnet for at oprette interne ressourcer.", + "createInternalResourceDialogNoSitesAvailableDescription": "Du skal have mindst ét Newt-site med et konfigureret subnet for at oprette private ressourcer.", "createInternalResourceDialogClose": "Luk", "createInternalResourceDialogCreateClientResource": "Opret privat ressource", "createInternalResourceDialogCreateClientResourceDescription": "Opret en ny ressource som kun vil være tilgængelig for kunder som er forbundet til organisationen", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "Annuller", "createInternalResourceDialogCreateResource": "Opret ressource", "createInternalResourceDialogSuccess": "Succes", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Intern ressource oprettet med succes", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Privat ressource oprettet succesfuldt", "createInternalResourceDialogError": "Fejl", - "createInternalResourceDialogFailedToCreateInternalResource": "Kunne ikke oprette intern ressource", + "createInternalResourceDialogFailedToCreateInternalResource": "Kunne ikke oprette privat ressource", "createInternalResourceDialogNameRequired": "Navn er påkrævet", "createInternalResourceDialogNameMaxLength": "Navn kan ikke være længere enn 255 tegn", "createInternalResourceDialogPleaseSelectSite": "Vælg venligst et site", @@ -3089,8 +3098,8 @@ "sourceAddress": "Kildeadresse", "destinationAddress": "Måladresse ", "duration": "Varighed", - "licenseRequiredToUse": "En Enterprise Edition-licens eller Pangolin Cloud er påkrævet for at bruge denne funktion. Book en demo eller POC-prøveversion.", - "ossEnterpriseEditionRequired": "Enterprise Edition er nødvendig for at bruge denne funktion. Denne funktion er også tilgængelig i Pangolin Cloud. Book en demo eller POC-prøve.", + "licenseRequiredToUse": "En Enterprise Edition-licens eller Pangolin Cloud er påkrævet for at bruge denne funktion. Book en gratis demo eller POC-prøveversion for at lære mere.", + "ossEnterpriseEditionRequired": "Enterprise Edition er nødvendig for at bruge denne funktion. Denne funktion er også tilgængelig i Pangolin Cloud. Book en gratis demo eller POC-prøve for at lære mere.", "certResolver": "Certifikatløser", "certResolverDescription": "Vælg certifikatløser som skal bruges for denne ressource.", "selectCertResolver": "Vælg certifikatløser", From 58c499acae839a3432ade4ee3b1c7a377f95f1b4 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:13:48 -0400 Subject: [PATCH 139/494] New translations en-us.json (German) [ci skip] --- messages/de-DE.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 615759bf1..a533f0d44 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Löschung des Freigabelinks bestätigen", "shareQuestionRemove": "Sind Sie sicher, dass Sie diesen Freigabelink löschen möchten?", "shareMessageRemove": "Nach dem Löschen funktioniert der Link nicht mehr, und jeder, der ihn nutzt, verliert den Zugriff auf die Ressource.", - "shareTokenDescription": "Das Zugriffstoken kann auf zwei Arten übergeben werden: als Abfrageparameter oder in den Request-Headern. Diese müssen vom Client auf jeder Anfrage für authentifizierten Zugriff weitergegeben werden.", + "shareTokenDescription": "Der Zugriffstoken kann als Abfrageparameter oder in Anforderungsheadern übergeben werden. Standardmäßig muss er bei jeder Anforderung gesendet werden. Wenn die Sitzungsbeständigkeit aktiviert ist, wird die erste Anfrage gegen ein Sitzungscookie ausgetauscht.", "accessToken": "Zugriffstoken", "usageExamples": "Nutzungsbeispiele", "tokenId": "Token-ID", @@ -196,8 +196,14 @@ "shareTitleOptional": "Titel (optional)", "sharePathOptional": "Pfad (optional)", "sharePathDescription": "Der Link leitet Benutzer nach der Authentifizierung zu diesem Pfad weiter.", + "shareAssociateUserOptional": "Benutzer zuweisen (optional)", + "shareAssociateUserDescription": "Wenn gesetzt, werden Anfragen mit diesem Link in Zugriffsprotokollen und Identitätsheadern dem Benutzer zugeordnet. Der Link wird entfernt, wenn der Benutzer die Organisation verlässt.", + "userSelect": "Benutzer auswählen", + "usersNotFound": "Keine Benutzer gefunden", "expireIn": "Läuft ab in", "neverExpire": "Läuft nie ab", + "sharePersistSession": "Sitzung nach erster Nutzung beibehalten", + "sharePersistSessionDescription": "Wenn aktiviert, setzt die erste Anfrage mit diesem Token über einen Abfrageparameter oder einen Header ein Sitzungscookie, sodass spätere Anfragen das Token nicht benötigen. Deaktivieren Sie dies für API-Clients, die das Token bei jeder Anfrage senden sollen.", "shareExpireDescription": "Ablaufzeit ist, wie lange der Link verwendet werden kann und bietet Zugriff auf die Ressource. Nach dieser Zeit wird der Link nicht mehr funktionieren und Benutzer, die diesen Link benutzt haben, verlieren den Zugriff auf die Ressource.", "shareSeeOnce": "Sie können diesen Link nur einmal sehen. Bitte kopieren Sie ihn.", "shareAccessHint": "Jeder mit diesem Link kann auf die Ressource zugreifen. Teilen Sie sie mit Vorsicht.", @@ -928,7 +934,7 @@ "newtVersion": "Version", "architecture": "Architektur", "sites": "Standorte", - "siteWgAnyClients": "Verwenden Sie jeden WireGuard-Client um sich zu verbinden. Sie müssen interne Ressourcen über die Peer-IP ansprechen.", + "siteWgAnyClients": "Verwenden Sie jeden beliebigen WireGuard-Client, um sich zu verbinden. Sie müssen die privaten Ressourcen mit der Peer-IP adressieren.", "siteWgCompatibleAllClients": "Kompatibel mit allen WireGuard-Clients", "siteWgManualConfigurationRequired": "Manuelle Konfiguration erforderlich", "userErrorNotAdminOrOwner": "Benutzer ist kein Administrator oder Eigentümer", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "Subdomain: {subdomain}", "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Mehr anzeigen", + "domainPickerNoDomainsAvailableTitle": "Keine Domains verfügbar", + "domainPickerNoDomainsAvailableDescription": "Sie haben noch keine Domains eingerichtet. Erstellen Sie eine Domain, um fortzufahren.", + "domainPickerNoDomainsAvailableAction": "Zu Domains wechseln", "regionSelectorTitle": "Region auswählen", "domainPickerRemoteExitNodeWarning": "Angegebene Domains werden nicht unterstützt, wenn sich Websites mit externen Exit-Knoten verbinden. Damit Ressourcen auf entfernten Knoten verfügbar sind, verwenden Sie stattdessen eine eigene Domain.", "regionSelectorInfo": "Das Auswählen einer Region hilft uns, eine bessere Leistung für Ihren Standort bereitzustellen. Sie müssen sich nicht in derselben Region wie Ihr Server befinden.", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "Öffentlich", "resourcesTableClientResources": "Privat", "resourcesTableNoProxyResourcesFound": "Keine Proxy-Ressourcen gefunden.", - "resourcesTableNoInternalResourcesFound": "Keine internen Ressourcen gefunden.", + "resourcesTableNoInternalResourcesFound": "Keine privaten Ressourcen gefunden.", "resourcesTableDestination": "Ziel", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Alias-Adresse", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "Abbrechen", "editInternalResourceDialogSaveResource": "Ressource speichern", "editInternalResourceDialogSuccess": "Erfolg", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Interne Ressource erfolgreich aktualisiert", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Private Ressource erfolgreich aktualisiert", "editInternalResourceDialogError": "Fehler", - "editInternalResourceDialogFailedToUpdateInternalResource": "Interne Ressource konnte nicht aktualisiert werden", + "editInternalResourceDialogFailedToUpdateInternalResource": "Fehler beim Aktualisieren der privaten Ressource", "editInternalResourceDialogNameRequired": "Name ist erforderlich", "editInternalResourceDialogNameMaxLength": "Der Name darf nicht länger als 255 Zeichen sein", "editInternalResourceDialogProxyPortMin": "Proxy-Port muss mindestens 1 sein", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Ein optionaler interner DNS-Alias für diese Ressource.", "createInternalResourceDialogNoSitesAvailable": "Kein Standort verfügbar", - "createInternalResourceDialogNoSitesAvailableDescription": "Sie müssen mindestens ein Newt-Standort mit einem konfigurierten Subnetz haben, um interne Ressourcen zu erstellen.", + "createInternalResourceDialogNoSitesAvailableDescription": "Sie müssen mindestens eine Newt-Site mit einem konfigurierten Subnetz haben, um private Ressourcen zu erstellen.", "createInternalResourceDialogClose": "Schließen", "createInternalResourceDialogCreateClientResource": "Private Ressource erstellen", "createInternalResourceDialogCreateClientResourceDescription": "Erstelle eine neue Ressource, die nur für Clients zugänglich ist, die mit der Organisation verbunden sind", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "Abbrechen", "createInternalResourceDialogCreateResource": "Ressource erstellen", "createInternalResourceDialogSuccess": "Erfolg", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Interne Ressource erfolgreich erstellt", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Private Ressource erfolgreich erstellt", "createInternalResourceDialogError": "Fehler", - "createInternalResourceDialogFailedToCreateInternalResource": "Interne Ressource konnte nicht erstellt werden", + "createInternalResourceDialogFailedToCreateInternalResource": "Fehler beim Erstellen der privaten Ressource", "createInternalResourceDialogNameRequired": "Name ist erforderlich", "createInternalResourceDialogNameMaxLength": "Der Name darf nicht länger als 255 Zeichen sein", "createInternalResourceDialogPleaseSelectSite": "Bitte wählen Sie einen Standort aus", @@ -3089,8 +3098,8 @@ "sourceAddress": "Quelladresse", "destinationAddress": "Zieladresse", "duration": "Dauer", - "licenseRequiredToUse": "Eine Enterprise Edition Lizenz oder Pangolin Cloud wird benötigt, um diese Funktion nutzen zu können. Buchen Sie eine Demo oder POC Testversion.", - "ossEnterpriseEditionRequired": "Die Enterprise Edition wird benötigt, um diese Funktion nutzen zu können. Diese Funktion ist auch in Pangolin Cloudverfügbar. Buchen Sie eine Demo oder POC Testversion.", + "licenseRequiredToUse": "Eine Enterprise Edition Lizenz oder Pangolin Cloud wird benötigt, um diese Funktion zu nutzen. Buchen Sie eine kostenlose Demo oder POC Testversion, um mehr zu erfahren.", + "ossEnterpriseEditionRequired": "Die Enterprise Edition ist notwendig, um diese Funktion zu nutzen. Diese Funktion ist auch in Pangolin Cloud verfügbar. Buchen Sie eine kostenlose Demo oder POC Testversion, um mehr zu erfahren.", "certResolver": "Zertifikatsauflöser", "certResolverDescription": "Wählen Sie den Zertifikatslöser aus, der für diese Ressource verwendet werden soll.", "selectCertResolver": "Zertifikatsauflöser auswählen", From f59b52426621ffa710556d23a2c338b82ea758a6 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:13:50 -0400 Subject: [PATCH 140/494] New translations en-us.json (Italian) [ci skip] --- messages/it-IT.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/messages/it-IT.json b/messages/it-IT.json index 794eda30f..21fa1a0fb 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Conferma Eliminazione Collegamento Condivisibile", "shareQuestionRemove": "Sei sicuro di voler eliminare questo link di condivisione?", "shareMessageRemove": "Una volta eliminato, il link non funzionerà più e chiunque lo utilizzi perderà l'accesso alla risorsa.", - "shareTokenDescription": "Il token di accesso può essere passato in due modi: come parametro di interrogazione o nelle intestazioni della richiesta. Questi devono essere passati dal client su ogni richiesta di accesso autenticato.", + "shareTokenDescription": "Il token di accesso può essere passato come parametro di query o nei header delle richieste. Per impostazione predefinita deve essere inviato a ogni richiesta. Se la persistenza della sessione è abilitata, la prima richiesta lo scambia per un cookie di sessione.", "accessToken": "Token Di Accesso", "usageExamples": "Esempi Di Utilizzo", "tokenId": "ID del Token", @@ -196,8 +196,14 @@ "shareTitleOptional": "Titolo (facoltativo)", "sharePathOptional": "Percorso (opzionale)", "sharePathDescription": "Il link reindirizzerà gli utenti a questo percorso dopo l'autenticazione.", + "shareAssociateUserOptional": "Associa utente (opzionale)", + "shareAssociateUserDescription": "Quando impostato, le richieste utilizzando questo link sono attribuite all'utente nei log di accesso e negli header di identità. Il link viene rimosso se l'utente lascia l'organizzazione.", + "userSelect": "Seleziona utente", + "usersNotFound": "Nessun utente trovato", "expireIn": "Scadenza In", "neverExpire": "Nessuna scadenza", + "sharePersistSession": "Mantieni la sessione dopo il primo utilizzo", + "sharePersistSessionDescription": "Quando abilitato, la prima richiesta con questo token tramite un parametro di query o header imposta un cookie di sessione quindi le richieste successive non hanno bisogno del token. Disattivare per i client API che devono inviare il token a ogni richiesta.", "shareExpireDescription": "Il tempo di scadenza indica per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.", "shareSeeOnce": "Potrai vedere questo link solo una volta. Assicurati di copiarlo.", "shareAccessHint": "Chiunque abbia questo link può accedere alla risorsa. Condividilo con cura.", @@ -928,7 +934,7 @@ "newtVersion": "Versione", "architecture": "Architettura", "sites": "Siti", - "siteWgAnyClients": "Usa qualsiasi client WireGuard per connetterti. Dovrai indirizzare le risorse interne utilizzando l'IP del peer.", + "siteWgAnyClients": "Usa qualsiasi client WireGuard per connetterti. Dovrai indirizzare le risorse private utilizzando l'IP del peer.", "siteWgCompatibleAllClients": "Compatibile con tutti i client WireGuard", "siteWgManualConfigurationRequired": "Configurazione manuale richiesta", "userErrorNotAdminOrOwner": "L'utente non è un amministratore o proprietario", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "Sottodominio: {subdomain}", "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Mostra Altro", + "domainPickerNoDomainsAvailableTitle": "Nessun dominio disponibile", + "domainPickerNoDomainsAvailableDescription": "Non hai ancora configurato alcun dominio. Crea un dominio per continuare.", + "domainPickerNoDomainsAvailableAction": "Vai ai Domini", "regionSelectorTitle": "Seleziona regione", "domainPickerRemoteExitNodeWarning": "I domini forniti non sono supportati quando i siti si connettono a nodi di uscita remoti. Affinché le risorse siano disponibili su nodi remoti, utilizza invece un dominio personalizzato.", "regionSelectorInfo": "Selezionare una regione ci aiuta a fornire migliori performance per la tua posizione. Non devi necessariamente essere nella stessa regione del tuo server.", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "Pubblico", "resourcesTableClientResources": "Privato", "resourcesTableNoProxyResourcesFound": "Nessuna risorsa proxy trovata.", - "resourcesTableNoInternalResourcesFound": "Nessuna risorsa interna trovata.", + "resourcesTableNoInternalResourcesFound": "Nessuna risorsa privata trovata.", "resourcesTableDestination": "Destinazione", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Indirizzo Alias", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "Annulla", "editInternalResourceDialogSaveResource": "Salva Risorsa", "editInternalResourceDialogSuccess": "Successo", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Risorsa interna aggiornata con successo", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Risorsa privata aggiornata con successo", "editInternalResourceDialogError": "Errore", - "editInternalResourceDialogFailedToUpdateInternalResource": "Impossibile aggiornare la risorsa interna", + "editInternalResourceDialogFailedToUpdateInternalResource": "Impossibile aggiornare la risorsa privata", "editInternalResourceDialogNameRequired": "Il nome è obbligatorio", "editInternalResourceDialogNameMaxLength": "Il nome deve essere inferiore a 255 caratteri", "editInternalResourceDialogProxyPortMin": "La porta proxy deve essere almeno 1", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Un alias DNS interno opzionale per questa risorsa.", "createInternalResourceDialogNoSitesAvailable": "Nessun Sito Disponibile", - "createInternalResourceDialogNoSitesAvailableDescription": "Devi avere almeno un sito Newt con una subnet configurata per creare risorse interne.", + "createInternalResourceDialogNoSitesAvailableDescription": "Devi avere almeno un sito Newt con una subnet configurata per creare risorse private.", "createInternalResourceDialogClose": "Chiudi", "createInternalResourceDialogCreateClientResource": "Crea Risorsa Privata", "createInternalResourceDialogCreateClientResourceDescription": "Crea una nuova risorsa che sarà accessibile solo ai client connessi all'organizzazione", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "Annulla", "createInternalResourceDialogCreateResource": "Crea Risorsa", "createInternalResourceDialogSuccess": "Successo", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Risorsa interna creata con successo", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Risorsa privata creata con successo", "createInternalResourceDialogError": "Errore", - "createInternalResourceDialogFailedToCreateInternalResource": "Impossibile creare la risorsa interna", + "createInternalResourceDialogFailedToCreateInternalResource": "Impossibile creare la risorsa privata", "createInternalResourceDialogNameRequired": "Il nome è obbligatorio", "createInternalResourceDialogNameMaxLength": "Il nome non deve superare i 255 caratteri", "createInternalResourceDialogPleaseSelectSite": "Si prega di selezionare un sito", @@ -3089,8 +3098,8 @@ "sourceAddress": "Indirizzo Di Origine", "destinationAddress": "Indirizzo Di Destinazione", "duration": "Durata", - "licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza Enterprise Edition o Pangolin Cloud . Prenota una demo o una prova POC.", - "ossEnterpriseEditionRequired": "L' Enterprise Edition è necessaria per utilizzare questa funzione. Questa funzione è disponibile anche in Pangolin Cloud. Prenota una demo o una prova POC.", + "licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza Enterprise Edition o Pangolin Cloud. Prenota una demo gratuita o una prova POC per saperne di più.", + "ossEnterpriseEditionRequired": "L' Enterprise Edition è necessaria per utilizzare questa funzione. Questa funzione è disponibile anche in Pangolin Cloud. Prenota una demo gratuita o una prova POC per saperne di più.", "certResolver": "Risolutore Di Certificato", "certResolverDescription": "Selezionare il risolutore di certificati da usare per questa risorsa.", "selectCertResolver": "Seleziona Risolutore Di Certificato", From e4d81aa610fca9c15b5e42068e165d470cb6897e Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:13:52 -0400 Subject: [PATCH 141/494] New translations en-us.json (Korean) [ci skip] --- messages/ko-KR.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 8505e50e0..d28f06f4f 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "공유 가능한 링크 삭제 확인", "shareQuestionRemove": "이 공유 링크를 삭제하시겠습니까?", "shareMessageRemove": "삭제되면 링크가 더 이상 작동하지 않으며, 이를 사용하는 모든 사용자는 자원에 대한 접근을 잃게 됩니다.", - "shareTokenDescription": "액세스 토큰은 쿼리 매개변수 또는 요청 헤더의 두 가지 방법으로 전달될 수 있습니다. 이는 인증된 액세스를 위해 클라이언트에서 모든 요청마다 전달되어야 합니다.", + "shareTokenDescription": "액세스 토큰은 쿼리 매개변수로 또는 요청 헤더에 전달될 수 있습니다. 기본적으로 모든 요청에 포함되어야 합니다. 세션 지속이 활성화된 경우, 첫 번째 요청이 세션 쿠키로 교환됩니다.", "accessToken": "액세스 토큰", "usageExamples": "사용 예", "tokenId": "토큰 ID", @@ -196,8 +196,14 @@ "shareTitleOptional": "제목 (선택 사항)", "sharePathOptional": "경로 (선택 사항)", "sharePathDescription": "링크는 인증 후 이 경로로 사용자를 리디렉션합니다.", + "shareAssociateUserOptional": "사용자 연관 (선택 사항)", + "shareAssociateUserDescription": "설정 시, 이 링크를 사용하는 요청은 액세스 로그와 ID 헤더에서 사용자로 기록됩니다. 사용자가 조직을 떠나면 링크가 제거됩니다.", + "userSelect": "사용자 선택", + "usersNotFound": "사용자를 찾을 수 없습니다", "expireIn": "만료됨", "neverExpire": "만료되지 않음", + "sharePersistSession": "첫 사용 후 세션 지속", + "sharePersistSessionDescription": "활성화된 경우, 쿼리 매개변수나 헤더를 통해 이 토큰으로 첫 요청 시 세션 쿠키가 설정되어 이후 요청에는 토큰이 필요하지 않습니다. 모든 요청에 토큰을 포함해야 하는 API 클라이언트의 경우, 이 옵션을 해제하세요.", "shareExpireDescription": "만료 시간은 링크가 사용 가능하고 리소스에 접근할 수 있는 기간입니다. 이 시간이 지나면 링크는 더 이상 작동하지 않으며, 이 링크를 사용한 사용자는 리소스에 대한 접근 권한을 잃게 됩니다.", "shareSeeOnce": "이 링크는 한 번만 볼 수 있습니다. 반드시 복사해 두세요.", "shareAccessHint": "이 링크가 있는 누구나 리소스에 접근할 수 있습니다. 주의해서 공유하세요.", @@ -928,7 +934,7 @@ "newtVersion": "버전", "architecture": "아키텍처", "sites": "사이트", - "siteWgAnyClients": "WireGuard 클라이언트를 사용하여 연결하십시오. 피어 IP를 사용하여 내부 리소스에 접근해야 합니다.", + "siteWgAnyClients": "WireGuard 클라이언트를 사용하여 연결하십시오. 피어 IP를 사용하여 개인 리소스에 접근해야 합니다.", "siteWgCompatibleAllClients": "모든 WireGuard 클라이언트와 호환", "siteWgManualConfigurationRequired": "수동 구성이 필요합니다.", "userErrorNotAdminOrOwner": "사용자는 관리자 또는 소유자가 아닙니다.", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "서브도메인: {subdomain}", "domainPickerNamespace": "이름 공간: {namespace}", "domainPickerShowMore": "더보기", + "domainPickerNoDomainsAvailableTitle": "사용 가능한 도메인이 없습니다", + "domainPickerNoDomainsAvailableDescription": "설정된 도메인이 아직 없습니다. 계속하려면 도메인을 생성하세요.", + "domainPickerNoDomainsAvailableAction": "도메인으로 이동", "regionSelectorTitle": "지역 선택", "domainPickerRemoteExitNodeWarning": "제공된 도메인은 원격 종료 노드에 연결된 사이트에서 지원되지 않습니다. 원격 노드에서 리소스를 사용하려면 사용자 지정 도메인을 사용하십시오.", "regionSelectorInfo": "지역을 선택하면 위치에 따라 더 나은 성능이 제공됩니다. 서버와 같은 지역에 있을 필요는 없습니다.", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "공유", "resourcesTableClientResources": "비공개", "resourcesTableNoProxyResourcesFound": "프록시 리소스를 찾을 수 없습니다.", - "resourcesTableNoInternalResourcesFound": "내부 리소스를 찾을 수 없습니다.", + "resourcesTableNoInternalResourcesFound": "개인 리소스를 찾을 수 없습니다.", "resourcesTableDestination": "대상지", "resourcesTableAlias": "별칭", "resourcesTableAliasAddress": "별칭 주소", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "취소", "editInternalResourceDialogSaveResource": "리소스 저장", "editInternalResourceDialogSuccess": "성공", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "내부 리소스가 성공적으로 업데이트되었습니다", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "개인 리소스가 성공적으로 업데이트되었습니다", "editInternalResourceDialogError": "오류", - "editInternalResourceDialogFailedToUpdateInternalResource": "내부 리소스 업데이트 실패", + "editInternalResourceDialogFailedToUpdateInternalResource": "개인 리소스 업데이트 실패", "editInternalResourceDialogNameRequired": "이름은 필수입니다.", "editInternalResourceDialogNameMaxLength": "이름은 255자 이하이어야 합니다.", "editInternalResourceDialogProxyPortMin": "프록시 포트는 최소 1이어야 합니다.", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "별칭", "editInternalResourceDialogAliasDescription": "이 리소스에 대한 선택적 내부 DNS 별칭입니다.", "createInternalResourceDialogNoSitesAvailable": "사용 가능한 사이트가 없습니다.", - "createInternalResourceDialogNoSitesAvailableDescription": "내부 리소스를 생성하려면 서브넷이 구성된 최소 하나의 Newt 사이트가 필요합니다.", + "createInternalResourceDialogNoSitesAvailableDescription": "개인 리소스를 생성하려면 서브넷이 구성된 최소 하나의 Newt 사이트가 필요합니다.", "createInternalResourceDialogClose": "닫기", "createInternalResourceDialogCreateClientResource": "사이트 리소스 생성", "createInternalResourceDialogCreateClientResourceDescription": "선택한 사이트에 연결된 클라이언트에 접근할 새 리소스를 생성합니다", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "취소", "createInternalResourceDialogCreateResource": "리소스 생성", "createInternalResourceDialogSuccess": "성공", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "내부 리소스가 성공적으로 생성되었습니다.", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "개인 리소스가 성공적으로 생성되었습니다", "createInternalResourceDialogError": "오류", - "createInternalResourceDialogFailedToCreateInternalResource": "내부 리소스 생성 실패", + "createInternalResourceDialogFailedToCreateInternalResource": "개인 리소스 생성 실패", "createInternalResourceDialogNameRequired": "이름은 필수입니다.", "createInternalResourceDialogNameMaxLength": "이름은 255자 이하이어야 합니다.", "createInternalResourceDialogPleaseSelectSite": "사이트를 선택하세요", @@ -3089,8 +3098,8 @@ "sourceAddress": "소스 주소", "destinationAddress": "대상 주소", "duration": "지속 시간", - "licenseRequiredToUse": "이 기능을 사용하려면 엔터프라이즈 에디션 라이선스가 필요합니다. 이 기능은 판골린 클라우드에서도 사용할 수 있습니다. 데모 또는 POC 체험을 예약하세요.", - "ossEnterpriseEditionRequired": "이 기능을 사용하려면 엔터프라이즈 에디션이(가) 필요합니다. 이 기능은 판골린 클라우드에서도 사용할 수 있습니다. 데모 또는 POC 체험을 예약하세요.", + "licenseRequiredToUse": "이 기능을 사용하려면 엔터프라이즈 에디션 라이선스가 필요합니다. 이 기능은 판골린 클라우드에서도 사용할 수 있습니다. 데모 또는 POC 체험을 예약하세요", + "ossEnterpriseEditionRequired": "이 기능을 사용하려면 엔터프라이즈 에디션이 필요합니다. 이 기능은 판골린 클라우드에서도 사용할 수 있습니다. 데모 또는 POC 체험을 예약하세요", "certResolver": "인증서 해결사", "certResolverDescription": "이 리소스에 사용할 인증서 해결사를 선택하세요.", "selectCertResolver": "인증서 해결사 선택", From ae39bc0ecd327bf943fd745146c0988f1aeb59ca Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:13:54 -0400 Subject: [PATCH 142/494] New translations en-us.json (Dutch) [ci skip] --- messages/nl-NL.json | 113 ++++++++++++++++++++++++-------------------- 1 file changed, 61 insertions(+), 52 deletions(-) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index f914a3c80..0c1249ff1 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -13,7 +13,7 @@ "orgDisplayName": "Dit is de weergavenaam van de organisatie.", "orgId": "Organisatie ID", "setupIdentifierMessage": "Dit is de unieke identificatie voor de organisatie.", - "setupErrorIdentifier": "Organisatie-ID is al in gebruik. Kies een andere.", + "setupErrorIdentifier": "Organisatie ID is al in gebruik, verzin iets anders.", "componentsErrorNoMemberCreate": "U bent momenteel geen lid van een organisatie. Maak een organisatie aan om aan de slag te gaan.", "componentsErrorNoMember": "U bent momenteel geen lid van een organisatie.", "welcome": "Welkom bij Pangolin!", @@ -78,7 +78,7 @@ "siteManageSites": "Sites beheren", "siteDescription": "Maak en beheer sites om verbinding met privénetwerken in te schakelen", "sitesBannerTitle": "Verbind elk netwerk", - "sitesBannerDescription": "Een site is een verbinding met een extern netwerk waarmee Pangolin toegang biedt tot bronnen, zowel openbaar als privé, aan gebruikers overal. Installeer de sitedatacenterconnector (Newt) overal waar je een binaire of container kunt uitvoeren om de verbinding tot stand te brengen.", + "sitesBannerDescription": "Een site is een verbinding met een extern netwerk waarmee Pangolin toegang biedt voor gebruikers voor bronnen, zowel openbaar als privé. Installeer de sitedatacenterconnector (Newt) overal waar je een binaire of container kunt uitvoeren om de verbinding tot stand te brengen.", "sitesBannerButtonText": "Site installeren", "approvalsBannerTitle": "Toegang tot het apparaat goedkeuren of weigeren", "approvalsBannerDescription": "Bekijk en keur toestelverzoeken goed of weiger toegang van gebruikers. Wanneer apparaatgoedkeuringen vereist zijn, moeten gebruikers de goedkeuring van beheerders krijgen voordat hun apparaten verbinding kunnen maken met de bronnen van uw organisatie.", @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Bevestig Verwijdering Deelbare Link", "shareQuestionRemove": "Weet u zeker dat u deze deel link wilt verwijderen?", "shareMessageRemove": "Zodra verwijderd, zal de link niet meer werken en zal iedereen die het gebruikt de toegang tot de bron verliezen.", - "shareTokenDescription": "De toegangstoken kan op twee manieren worden doorgegeven: als queryparameter of in de aanvraagheaders. Deze moeten worden doorgegeven van de client op elk verzoek voor geverifieerde toegang.", + "shareTokenDescription": "Het toegangstoken kan als queryparameter of in aanvraagheaders worden meegegeven. Standaard moet het bij elke aanvraag worden verzonden. Als sessie-persistentie is ingeschakeld, ruilt de eerste aanvraag deze in voor een sessiecookie.", "accessToken": "Toegangs-token", "usageExamples": "Voorbeelden van gebruik", "tokenId": "Token ID", @@ -196,8 +196,14 @@ "shareTitleOptional": "Titel (optioneel)", "sharePathOptional": "Pad (optioneel)", "sharePathDescription": "De link zal gebruikers naar dit pad doorsturen na authenticatie.", + "shareAssociateUserOptional": "Gebruiker koppelen (optioneel)", + "shareAssociateUserDescription": "Wanneer ingesteld, worden aanvragen met deze link toegeschreven aan de gebruiker in toegangslogboeken en identiteitsheaders. De link wordt verwijderd als de gebruiker de organisatie verlaat.", + "userSelect": "Selecteer gebruiker", + "usersNotFound": "Geen gebruikers gevonden", "expireIn": "Vervalt in", "neverExpire": "Nooit verlopen", + "sharePersistSession": "Sessie behouden na eerste gebruik", + "sharePersistSessionDescription": "Wanneer ingeschakeld, zet de eerste aanvraag met dit token via een queryparameter of header een sessiecookie zodat latere aanvragen het token niet nodig hebben. Laat dit uit voor API-clients die het token bij elke aanvraag moeten verzenden.", "shareExpireDescription": "Vervaltijd is hoe lang de link bruikbaar is en geeft toegang tot de bron. Na deze tijd zal de link niet meer werken en zullen gebruikers die deze link hebben gebruikt de toegang tot de pagina verliezen.", "shareSeeOnce": "U kunt deze link slechts één keer zien. Zorg ervoor dat u deze kopieert.", "shareAccessHint": "Iedereen met deze link heeft toegang tot de bron. Deel deze met zorg.", @@ -219,12 +225,12 @@ "shareErrorSelectResource": "Selecteer een bron", "proxyResourceTitle": "Openbare bronnen beheren", "proxyResourceDescription": "Creëer en beheer bronnen die openbaar toegankelijk zijn via een webbrowser", - "publicResourcesBannerTitle": "Web-gebaseerde Openbare Toegang", - "publicResourcesBannerDescription": "Openbare bronnen zijn HTTPS-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot privébronnen hoeven ze geen client-software te hebben en kunnen ze identiteit- en context bewuste toegangsmiddelen bevatten.", + "publicResourcesBannerTitle": "Webgebaseerde openbare bronnen", + "publicResourcesBannerDescription": "Openbare bronnen zijn HTTPS proxy's die te bereiken zijn voor iedereen op het internet via een webbrowser. Het is mogelijk om deze te beveiligen door identiteitscontrole of een toegangsbeleid. In tegenstelling tot privébronnen hoeven ze geen clientsoftware te installeren.", "clientResourceTitle": "Privébronnen beheren", "clientResourceDescription": "Creëer en beheer bronnen die alleen toegankelijk zijn via een verbonden client", - "privateResourcesBannerTitle": "Zero-Trust Private Access", - "privateResourcesBannerDescription": "Privé bronnen maken gebruik van zero-trust-beveiliging, wat ervoor zorgt dat gebruikers en machines alleen toegang kunnen krijgen tot middelen die jij specifiek toestaat. Verbind gebruikersapparaten of machineclients om deze middelen te benaderen via een veilig virtueel priv��netwerk.", + "privateResourcesBannerTitle": "Privébronnen via Zero Trust", + "privateResourcesBannerDescription": "Privébronnen maken gebruik van zero trust beveiliging, wat ervoor zorgt dat gebruikers en machines alleen toegang kunnen krijgen tot resources die je specifiek toestaat. Verbind gebruikersapparaten of machines om deze middelen te benaderen via een veilig, virtueel privénetwerk.", "resourcesSearch": "Zoek bronnen...", "resourceAdd": "Bron toevoegen", "resourceErrorDelte": "Fout bij verwijderen document", @@ -253,7 +259,7 @@ "resourcePolicyUsersRolesDescription": "Bepaal welke gebruikers en rollen geassocieerde bronnen kunnen bezoeken", "rulesResourcePolicyDescription": "Stel regels in om toegang te regelen tot bronnen die zijn gekoppeld aan dit beleid", "authentication": "Authenticatie", - "protected": "Beschermd", + "protected": "Beveiligd", "notProtected": "Niet beveiligd", "resourceMessageRemove": "Eenmaal verwijderd, zal het bestand niet langer toegankelijk zijn. Alle doelen die gekoppeld zijn aan het hulpbron, zullen ook verwijderd worden.", "resourceQuestionRemove": "Weet u zeker dat u het document van de organisatie wilt verwijderen?", @@ -324,7 +330,7 @@ "labelEdit": "Label bewerken", "editLabelDialogTitle": "Label bijwerken", "editLabelDialogDescription": "Bewerk een nieuw label dat aan deze organisatie kan worden gekoppeld", - "labelDeleteConfirm": "Bevestigen Verwijderen Label", + "labelDeleteConfirm": "Label verwijderen", "labelErrorDelete": "Kan label niet verwijderen", "labelMessageRemove": "Deze handeling is definitief. Alle sites, bronnen en klanten met dit label zullen worden onttakeld.", "labelQuestionRemove": "Weet u zeker dat u het label uit de organisatie wilt verwijderen?", @@ -409,40 +415,40 @@ "years": "Jaar", "day": "{count, plural, one {# dag} other {# dagen}}", "apiKeysTitle": "API Key Informatie", - "apiKeysConfirmCopy2": "Bevestig dat u de API-sleutel hebt gekopieerd.", - "apiKeysErrorCreate": "Fout bij maken API-sleutel", + "apiKeysConfirmCopy2": "Bevestig dat u de API sleutel hebt gekopieerd.", + "apiKeysErrorCreate": "Fout bij maken API sleutel", "apiKeysErrorSetPermission": "Fout instellen permissies", - "apiKeysCreate": "API-sleutel genereren", - "apiKeysCreateDescription": "Een nieuwe API-sleutel voor de organisatie genereren", + "apiKeysCreate": "API sleutel genereren", + "apiKeysCreateDescription": "Een nieuwe API sleutel voor de organisatie genereren", "apiKeysGeneralSettings": "Machtigingen", - "apiKeysGeneralSettingsDescription": "Bepaal wat deze API-sleutel kan doen", - "apiKeysList": "Nieuwe API-sleutel", - "apiKeysSave": "De API-sleutel opslaan", + "apiKeysGeneralSettingsDescription": "Bepaal wat deze API sleutel kan doen", + "apiKeysList": "Nieuwe API sleutel", + "apiKeysSave": "De API sleutel opslaan", "apiKeysSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een veilige plek.", - "apiKeysInfo": "De API-sleutel is:", - "apiKeysConfirmCopy": "Ik heb de API-sleutel gekopieerd", + "apiKeysInfo": "De API sleutel is:", + "apiKeysConfirmCopy": "Ik heb de API sleutel gekopieerd", "generate": "Genereren", "done": "Voltooid", - "apiKeysSeeAll": "Alle API-sleutels bekijken", + "apiKeysSeeAll": "Alle API sleutels bekijken", "apiKeysPermissionsErrorLoadingActions": "Fout bij het laden van API key acties", "apiKeysPermissionsErrorUpdate": "Fout instellen permissies", "apiKeysPermissionsUpdated": "Permissies bijgewerkt", "apiKeysPermissionsUpdatedDescription": "De bevoegdheden zijn bijgewerkt.", "apiKeysPermissionsGeneralSettings": "Machtigingen", - "apiKeysPermissionsGeneralSettingsDescription": "Bepaal wat deze API-sleutel kan doen", + "apiKeysPermissionsGeneralSettingsDescription": "Bepaal wat deze API sleutel kan doen", "apiKeysPermissionsSave": "Rechten opslaan", "apiKeysPermissionsTitle": "Machtigingen", "apiKeys": "API sleutels", - "searchApiKeys": "API-sleutels zoeken...", - "apiKeysAdd": "API-sleutel genereren", - "apiKeysErrorDelete": "Fout bij verwijderen API-sleutel", - "apiKeysErrorDeleteMessage": "Fout bij verwijderen API-sleutel", - "apiKeysQuestionRemove": "Weet u zeker dat u de API-sleutel van de organisatie wilt verwijderen?", - "apiKeysMessageRemove": "Eenmaal verwijderd, kan de API-sleutel niet meer worden gebruikt.", - "apiKeysDeleteConfirm": "Bevestig Verwijderen API-sleutel", - "apiKeysDelete": "API-sleutel verwijderen", - "apiKeysManage": "API-sleutels beheren", - "apiKeysDescription": "API-sleutels worden gebruikt om te verifiëren met de integratie-API", + "searchApiKeys": "API sleutels zoeken...", + "apiKeysAdd": "API sleutel genereren", + "apiKeysErrorDelete": "Fout bij verwijderen API sleutel", + "apiKeysErrorDeleteMessage": "Fout bij verwijderen API sleutel", + "apiKeysQuestionRemove": "Weet u zeker dat u de API sleutel van de organisatie wilt verwijderen?", + "apiKeysMessageRemove": "Eenmaal verwijderd, kan de API sleutel niet meer worden gebruikt.", + "apiKeysDeleteConfirm": "Bevestig Verwijderen API sleutel", + "apiKeysDelete": "API sleutel verwijderen", + "apiKeysManage": "API sleutels beheren", + "apiKeysDescription": "API sleutels worden gebruikt om toegang te verifiëren met de integratie API ", "provisioningKeysTitle": "Vertrekkende sleutel", "provisioningKeysManage": "Beheren van Provisioning Sleutels", "provisioningKeysDescription": "Provisionerende sleutels worden gebruikt om geautomatiseerde sitebepaling voor uw organisatie te verifiëren.", @@ -906,7 +912,7 @@ "resourcesUsersRolesAccess": "Gebruiker en rol-gebaseerde toegangsbeheer", "resourcesErrorUpdate": "Bron wisselen mislukt", "resourcesErrorUpdateDescription": "Er is een fout opgetreden tijdens het bijwerken van het document", - "access": "Toegangsrechten", + "access": "Link", "accessControl": "Toegangs controle", "shareLink": "{resource} Deelbare Link", "resourceSelect": "Selecteer resource", @@ -928,7 +934,7 @@ "newtVersion": "Versie", "architecture": "Architectuur", "sites": "Sites", - "siteWgAnyClients": "Gebruik een willekeurige WireGuard client om verbinding te maken. Je zult interne bronnen moeten aanspreken met behulp van de peer IP.", + "siteWgAnyClients": "Gebruik een willekeurige WireGuard-client om verbinding te maken. U moet privébronnen benaderen met de peer IP.", "siteWgCompatibleAllClients": "Compatibel met alle WireGuard clients", "siteWgManualConfigurationRequired": "Handmatige configuratie vereist", "userErrorNotAdminOrOwner": "Gebruiker is geen beheerder of eigenaar", @@ -1480,11 +1486,11 @@ "actionCheckOrgId": "ID controleren", "actionCreateOrg": "Nieuwe organisatie aanmaken", "actionDeleteOrg": "Verwijder organisatie", - "actionListApiKeys": "API-sleutels weergeven", + "actionListApiKeys": "API sleutels weergeven", "actionListApiKeyActions": "Lijst van API Key Acties", "actionSetApiKeyActions": "Stel API Key Toegestane Acties", - "actionCreateApiKey": "API-sleutel aanmaken", - "actionDeleteApiKey": "API-sleutel verwijderen", + "actionCreateApiKey": "API sleutel aanmaken", + "actionDeleteApiKey": "API sleutel verwijderen", "actionCreateIdp": "IDP aanmaken", "actionUpdateIdp": "IDP bijwerken", "actionDeleteIdp": "Verwijder IDP", @@ -1550,7 +1556,7 @@ "commandPaletteCreateProxyResource": "Maak Openbare Bron", "commandPaletteCreatePrivateResource": "Maak Privébron", "commandPaletteCreateUser": "Gebruiker aanmaken", - "commandPaletteCreateApiKey": "API-sleutel aanmaken", + "commandPaletteCreateApiKey": "API sleutel aanmaken", "commandPaletteCreateMachineClient": "Machineclient aanmaken", "commandPaletteCreateAlertRule": "Alarmregel aanmaken", "commandPaletteCreateIdentityProvider": "Identiteitsprovider aanmaken", @@ -1598,10 +1604,10 @@ "authErrorNoUpdate": "Geen authenticatie informatie om bij te werken", "orgErrorNoUpdate": "Geen org om bij te werken", "orgErrorNoProvided": "Geen org opgegeven", - "apiKeysErrorNoUpdate": "Geen API-sleutel om bij te werken", + "apiKeysErrorNoUpdate": "Geen API sleutel om bij te werken", "sidebarOverview": "Overzicht.", "sidebarHome": "Startpagina", - "sidebarSites": "Werkruimtes", + "sidebarSites": "Sites", "sidebarApprovals": "Goedkeuringsverzoeken", "sidebarResources": "Bronnen", "sidebarProxyResources": "Openbaar", @@ -1671,8 +1677,8 @@ "commandBilling": "Facturering", "commandEnterpriseLicenses": "Licentie", "commandSettings": "Instellingen", - "commandLauncher": "Startprogramma", - "commandResourceLauncher": "Bron Startprogramma", + "commandLauncher": "Startpagina", + "commandResourceLauncher": "Bron startpagina", "commandSearchResults": "Zoekresultaten", "alertingTitle": "Waarschuwingen", "alertingDescription": "Definieer bronnen, triggers en acties voor meldingen", @@ -1830,7 +1836,7 @@ "standaloneHcFilterSiteIdFallback": "Site {id}", "standaloneHcFilterResourceIdFallback": "Bron {id}", "blueprints": "Blauwdrukken", - "blueprintsLog": "Log Blueprints", + "blueprintsLog": "Log blauwafdrukken", "blueprintsDescription": "Bekijk eerdere blauwdruktoepassingen en hun resultaten of pas een nieuwe blauwdruk toe", "blueprintAdd": "Blauwdruk toevoegen", "blueprintGoBack": "Bekijk alle Blauwdrukken", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "Subdomein: {subdomain}", "domainPickerNamespace": "Naamruimte: {namespace}", "domainPickerShowMore": "Meer weergeven", + "domainPickerNoDomainsAvailableTitle": "Geen domeinen beschikbaar", + "domainPickerNoDomainsAvailableDescription": "Je hebt nog geen domeinen ingesteld. Maak een domein aan om door te gaan.", + "domainPickerNoDomainsAvailableAction": "Ga naar Domeinen", "regionSelectorTitle": "Selecteer Regio", "domainPickerRemoteExitNodeWarning": "Opgegeven domeinen worden niet ondersteund wanneer websites verbinding maken met externe sluitnodes. Gebruik in plaats daarvan een aangepast domein. Om bronnen beschikbaar te maken op externe nodes.", "regionSelectorInfo": "Het selecteren van een regio helpt ons om betere prestaties te leveren voor uw locatie. U hoeft niet in dezelfde regio als uw server te zijn.", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "Openbaar", "resourcesTableClientResources": "Privé", "resourcesTableNoProxyResourcesFound": "Geen proxybronnen gevonden.", - "resourcesTableNoInternalResourcesFound": "Geen interne bronnen gevonden.", + "resourcesTableNoInternalResourcesFound": "Geen privébronnen gevonden.", "resourcesTableDestination": "Bestemming", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Alias adres", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "Annuleren", "editInternalResourceDialogSaveResource": "Sla bron op", "editInternalResourceDialogSuccess": "Succes", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Interne bron succesvol bijgewerkt", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Privébron succesvol bijgewerkt", "editInternalResourceDialogError": "Fout", - "editInternalResourceDialogFailedToUpdateInternalResource": "Het bijwerken van de interne bron is mislukt", + "editInternalResourceDialogFailedToUpdateInternalResource": "Fout bij het bijwerken van de privébron", "editInternalResourceDialogNameRequired": "Naam is verplicht", "editInternalResourceDialogNameMaxLength": "Naam mag niet langer zijn dan 255 tekens", "editInternalResourceDialogProxyPortMin": "Proxy poort moet minstens 1 zijn", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Een optionele interne DNS-alias voor dit document.", "createInternalResourceDialogNoSitesAvailable": "Geen sites beschikbaar", - "createInternalResourceDialogNoSitesAvailableDescription": "U moet ten minste één Newt-site hebben met een geconfigureerd subnet om interne bronnen aan te maken.", + "createInternalResourceDialogNoSitesAvailableDescription": "U moet ten minste één Newt-site hebben met een geconfigureerd subnet om privébronnen aan te maken.", "createInternalResourceDialogClose": "Sluiten", "createInternalResourceDialogCreateClientResource": "Privé bron maken", "createInternalResourceDialogCreateClientResourceDescription": "Maak een nieuwe bron aan die alleen toegankelijk is voor klanten die verbonden zijn met de organisatie", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "Annuleren", "createInternalResourceDialogCreateResource": "Bron aanmaken", "createInternalResourceDialogSuccess": "Succes", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Interne bron succesvol aangemaakt", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Privébron succesvol aangemaakt", "createInternalResourceDialogError": "Fout", - "createInternalResourceDialogFailedToCreateInternalResource": "Het aanmaken van de interne bron is mislukt", + "createInternalResourceDialogFailedToCreateInternalResource": "Fout bij het aanmaken van de privébron", "createInternalResourceDialogNameRequired": "Naam is verplicht", "createInternalResourceDialogNameMaxLength": "Naam mag niet langer zijn dan 255 tekens", "createInternalResourceDialogPleaseSelectSite": "Selecteer alstublieft een site", @@ -3089,8 +3098,8 @@ "sourceAddress": "Bron adres", "destinationAddress": "Adres bestemming", "duration": "Duur", - "licenseRequiredToUse": "Een Enterprise Edition licentie of Pangolin Cloud is vereist om deze functie te gebruiken. Boek een demo of POC trial.", - "ossEnterpriseEditionRequired": "De Enterprise Edition is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in Pangolin Cloud. Boek een demo of POC trial.", + "licenseRequiredToUse": "Een Enterprise Edition licentie of Pangolin Cloud is vereist om deze functie te gebruiken. Boek een gratis demo of POC trial om meer te leren.", + "ossEnterpriseEditionRequired": "De Enterprise Edition is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in Pangolin Cloud. Boek een gratis demo of POC trial om meer te leren.", "certResolver": "Certificaat Resolver", "certResolverDescription": "Selecteer de certificaat resolver die moet worden gebruikt voor deze resource.", "selectCertResolver": "Certificaat Resolver selecteren", @@ -3311,7 +3320,7 @@ "internalResourceAuthDaemonPort": "Daemon poort (optioneel)", "orgAuthWhatsThis": "Waar kan ik mijn organisatie-ID vinden?", "learnMore": "Meer informatie", - "backToHome": "Ga terug naar startpagina", + "backToHome": "Ga terug naar de startpagina", "needToSignInToOrg": "Moet u de identiteit provider van uw organisatie gebruiken?", "maintenanceMode": "Onderhoudspagina", "maintenanceModeDescription": "Toon een onderhoudspagina aan bezoekers", @@ -3524,7 +3533,7 @@ "httpDestAuthNoneDescription": "Stuurt verzoeken zonder toestemmingskop.", "httpDestAuthBearerTitle": "Betere Token", "httpDestAuthBearerDescription": "Voegt een Authorization: Bearer '' header toe aan elk verzoek.", - "httpDestAuthBearerPlaceholder": "Uw API-sleutel of -token", + "httpDestAuthBearerPlaceholder": "Uw API sleutel of token", "httpDestAuthBasicTitle": "Basis authenticatie", "httpDestAuthBasicDescription": "Voegt een Authorization: Basic '' header toe. Verstrek inloggegevens als gebruikersnaam:wachtwoord.", "httpDestAuthBasicPlaceholder": "Gebruikersnaam:wachtwoord", @@ -3678,7 +3687,7 @@ "memberPortalResourceDisabled": "Bron Uitgeschakeld", "memberPortalShowingResources": "Toont {start}-{end} van {total} bronnen", "resourceLauncherTitle": "Bron Launcher", - "resourceSidebarLauncherTitle": "Startprogramma", + "resourceSidebarLauncherTitle": "Startpagina", "resourceLauncherDescription": "Bekijk alle beschikbare bronnen en start ze vanuit één centraal punt", "resourceLauncherSearchPlaceholder": "Zoek naar bronnen...", "resourceLauncherDefaultView": "Standaard", From a1aa3d96d4f475f94737778f9e99f08ad74a8048 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:13:56 -0400 Subject: [PATCH 143/494] New translations en-us.json (Polish) [ci skip] --- messages/pl-PL.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 1e998dcdd..3b6390698 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Potwierdź usunięcie linku do udostępnienia", "shareQuestionRemove": "Czy na pewno chcesz usunąć ten link udostępniania?", "shareMessageRemove": "Po usunięciu, link przestanie działać i wszyscy korzystający z niego stracą dostęp do zasobu.", - "shareTokenDescription": "Token dostępu może być przekazywany na dwa sposoby: jako parametr zapytania lub w nagłówkach żądania. Muszą być przekazywane z klienta na każde żądanie uwierzytelnionego dostępu.", + "shareTokenDescription": "Token dostępu można przekazać jako parametr zapytania lub w nagłówkach żądania. Domyślnie musi być wysyłany w każdym żądaniu. Jeśli trwałość sesji jest włączona, pierwsze żądanie wymienia go na ciasteczko sesji.", "accessToken": "Token dostępu", "usageExamples": "Przykłady użycia", "tokenId": "Identyfikator tokena", @@ -196,8 +196,14 @@ "shareTitleOptional": "Tytuł (opcjonalnie)", "sharePathOptional": "Ścieżka (opcjonalnie)", "sharePathDescription": "Link przekieruje użytkowników do tej ścieżki po uwierzytelnieniu.", + "shareAssociateUserOptional": "Powiąż użytkownika (opcjonalnie)", + "shareAssociateUserDescription": "Po ustawieniu, żądania korzystające z tego linku są przypisywane do użytkownika w logach dostępu i nagłówkach tożsamości. Link jest usuwany, jeśli użytkownik opuszcza organizację.", + "userSelect": "Wybierz użytkownika", + "usersNotFound": "Nie znaleziono użytkowników", "expireIn": "Wygasa za", "neverExpire": "Nigdy nie wygasa", + "sharePersistSession": "Utrzymaj sesję po pierwszym użyciu", + "sharePersistSessionDescription": "Gdy ta opcja jest włączona, pierwsze żądanie z tym tokenem przez parametr zapytania lub nagłówek ustawia ciasteczko sesji, dzięki czemu późniejsze żądania nie wymagają tokena. Pomijaj dla klienta API, który powinien wysyłać token w każdym żądaniu.", "shareExpireDescription": "Czas wygaśnięcia to jak długo link będzie mógł być użyty i zapewni dostęp do zasobu. Po tym czasie link nie będzie już działał, a użytkownicy, którzy użyli tego linku, utracą dostęp do zasobu.", "shareSeeOnce": "Możesz zobaczyć ten link tylko raz. Pamiętaj, aby go skopiować.", "shareAccessHint": "Każdy z tym linkiem może uzyskać dostęp do zasobu. Podziel się nim ostrożnie.", @@ -928,7 +934,7 @@ "newtVersion": "Wersja", "architecture": "Architektura", "sites": "Witryny", - "siteWgAnyClients": "Użyj dowolnego klienta WireGuard, aby się połączyć. Będziesz musiał przekierować wewnętrzne zasoby za pomocą adresu IP.", + "siteWgAnyClients": "Użyj dowolnego klienta WireGuard do połączenia. Będziesz musiał adresować prywatne zasoby używając IP współpracownika.", "siteWgCompatibleAllClients": "Kompatybilny ze wszystkimi klientami WireGuard", "siteWgManualConfigurationRequired": "Wymagana konfiguracja ręczna", "userErrorNotAdminOrOwner": "Użytkownik nie jest administratorem ani właścicielem", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "Subdomena: {subdomain}", "domainPickerNamespace": "Przestrzeń nazw: {namespace}", "domainPickerShowMore": "Pokaż więcej", + "domainPickerNoDomainsAvailableTitle": "Brak dostępnych domen", + "domainPickerNoDomainsAvailableDescription": "Nie masz jeszcze skonfigurowanych żadnych domen. Utwórz domenę, aby kontynuować.", + "domainPickerNoDomainsAvailableAction": "Przejdź do Domena", "regionSelectorTitle": "Wybierz region", "domainPickerRemoteExitNodeWarning": "Podane domeny nie są obsługiwane, gdy witryny łączą się ze zdalnymi węzłami wyjścia. Aby zasoby były dostępne w węzłach zdalnych, użyj domeny niestandardowej.", "regionSelectorInfo": "Wybór regionu pomaga nam zapewnić lepszą wydajność dla Twojej lokalizacji. Nie musisz być w tym samym regionie co Twój serwer.", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "Publiczne", "resourcesTableClientResources": "Prywatny", "resourcesTableNoProxyResourcesFound": "Nie znaleziono zasobów proxy.", - "resourcesTableNoInternalResourcesFound": "Nie znaleziono wewnętrznych zasobów.", + "resourcesTableNoInternalResourcesFound": "Nie znaleziono prywatnych zasobów.", "resourcesTableDestination": "Miejsce docelowe", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Adres aliasu", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "Anuluj", "editInternalResourceDialogSaveResource": "Zapisz zasób", "editInternalResourceDialogSuccess": "Sukces", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Wewnętrzny zasób zaktualizowany pomyślnie", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Prywatny zasób został pomyślnie zaktualizowany", "editInternalResourceDialogError": "Błąd", - "editInternalResourceDialogFailedToUpdateInternalResource": "Nie udało się zaktualizować wewnętrznego zasobu", + "editInternalResourceDialogFailedToUpdateInternalResource": "Nie udało się zaktualizować prywatnego zasobu", "editInternalResourceDialogNameRequired": "Nazwa jest wymagana", "editInternalResourceDialogNameMaxLength": "Nazwa nie może mieć więcej niż 255 znaków", "editInternalResourceDialogProxyPortMin": "Port proxy musi wynosić przynajmniej 1", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Opcjonalny wewnętrzny alias DNS dla tego zasobu.", "createInternalResourceDialogNoSitesAvailable": "Brak dostępnych stron", - "createInternalResourceDialogNoSitesAvailableDescription": "Musisz mieć co najmniej jedną stronę Newt z skonfigurowanym podsiecią, aby tworzyć wewnętrzne zasoby.", + "createInternalResourceDialogNoSitesAvailableDescription": "Musisz mieć co najmniej jedną lokalizację Newt z skonfigurowaną podsiecią, aby tworzyć prywatne zasoby.", "createInternalResourceDialogClose": "Zamknij", "createInternalResourceDialogCreateClientResource": "Utwórz zasób prywatny", "createInternalResourceDialogCreateClientResourceDescription": "Utwórz nowy zasób, który będzie dostępny tylko dla klientów podłączonych do organizacji", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "Anuluj", "createInternalResourceDialogCreateResource": "Utwórz zasób", "createInternalResourceDialogSuccess": "Sukces", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Wewnętrzny zasób utworzony pomyślnie", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Prywatny zasób został pomyślnie utworzony", "createInternalResourceDialogError": "Błąd", - "createInternalResourceDialogFailedToCreateInternalResource": "Nie udało się utworzyć wewnętrznego zasobu", + "createInternalResourceDialogFailedToCreateInternalResource": "Nie udało się utworzyć prywatnego zasobu", "createInternalResourceDialogNameRequired": "Nazwa jest wymagana", "createInternalResourceDialogNameMaxLength": "Nazwa nie może mieć więcej niż 255 znaków", "createInternalResourceDialogPleaseSelectSite": "Proszę wybrać stronę", @@ -3089,8 +3098,8 @@ "sourceAddress": "Adres źródłowy", "destinationAddress": "Adres docelowy", "duration": "Czas trwania", - "licenseRequiredToUse": "Do korzystania z tej funkcji wymagana jest licencja Enterprise Edition lub Pangolin Cloud . Zarezerwuj wersję demonstracyjną lub wersję próbną POC.", - "ossEnterpriseEditionRequired": "Enterprise Edition jest wymagany do korzystania z tej funkcji. Ta funkcja jest również dostępna w Pangolin Cloud. Zarezerwuj demo lub okres próbny POC.", + "licenseRequiredToUse": "Do korzystania z tej funkcji wymagana jest licencja Enterprise Edition lub Pangolin Cloud. Zarezerwuj darmową wersję demo lub próbę POC, aby dowiedzieć się więcej.", + "ossEnterpriseEditionRequired": "Enterprise Edition jest wymagany do korzystania z tej funkcji. Ta funkcja jest również dostępna w Pangolin Cloud. Zarezerwuj darmową wersję demo lub próbę POC, aby dowiedzieć się więcej.", "certResolver": "Rozwiązywanie certyfikatów", "certResolverDescription": "Wybierz resolver certyfikatów do użycia dla tego zasobu.", "selectCertResolver": "Wybierz Resolver certyfikatów", From 97789d9e2e56a24396e278811937fd9b81ac5e99 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:13:57 -0400 Subject: [PATCH 144/494] New translations en-us.json (Portuguese) [ci skip] --- messages/pt-PT.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 9d3dd1320..adaf12e96 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Confirmar exclusão do Link Compartilhável", "shareQuestionRemove": "Tem certeza de que deseja excluir este link de compartilhamento?", "shareMessageRemove": "Uma vez excluído, o link não funcionará mais e qualquer pessoa que o utilizar perderá o acesso ao recurso.", - "shareTokenDescription": "O token de acesso pode ser passado de duas maneiras: como um parâmetro de consulta ou nos cabeçalhos da solicitação. Estes devem ser passados do cliente em todas as solicitações para acesso autenticado.", + "shareTokenDescription": "O token de acesso pode ser passado como um parâmetro de consulta ou nos cabeçalhos da solicitação. Por padrão, ele deve ser enviado em todas as solicitações. Se a persistência da sessão estiver ativada, a primeira solicitação o troca por um cookie de sessão.", "accessToken": "Token de acesso", "usageExamples": "Exemplos de uso", "tokenId": "ID do Token", @@ -196,8 +196,14 @@ "shareTitleOptional": "Título (opcional)", "sharePathOptional": "Caminho (opcional)", "sharePathDescription": "O link redirecionará os usuários para este caminho após a autenticação.", + "shareAssociateUserOptional": "Associar Usuário (opcional)", + "shareAssociateUserDescription": "Quando definido, as solicitações usando este link são atribuídas ao usuário nos registros de acesso e cabeçalhos de identidade. O link é removido se o usuário sair da organização.", + "userSelect": "Selecionar um usuário", + "usersNotFound": "Nenhum usuário encontrado", "expireIn": "Expira em", "neverExpire": "Nunca expirar", + "sharePersistSession": "Persistir sessão após o primeiro uso", + "sharePersistSessionDescription": "Quando ativado, a primeira solicitação com este token por meio de um parâmetro de consulta ou cabeçalho define um cookie de sessão, para que solicitações posteriores não precisem do token. Mantenha desativado para clientes de API que devem enviar o token em todas as solicitações.", "shareExpireDescription": "Tempo de expiração é quanto tempo o link será utilizável e oferecerá acesso ao recurso. Após este tempo, o link não funcionará mais, e os utilizadores que usaram este link perderão acesso ao recurso.", "shareSeeOnce": "Você só poderá ver este link uma vez. Certifique-se de copiá-lo.", "shareAccessHint": "Qualquer um com este link pode aceder o recurso. Compartilhe com cuidado.", @@ -928,7 +934,7 @@ "newtVersion": "Versão", "architecture": "Arquitetura", "sites": "sites", - "siteWgAnyClients": "Use qualquer cliente do WireGuard para se conectar. Você terá que endereçar recursos internos usando o IP de pares.", + "siteWgAnyClients": "Use qualquer cliente WireGuard para conectar-se. Você terá que endereçar recursos privados usando o IP do par.", "siteWgCompatibleAllClients": "Compatível com todos os clientes WireGuard", "siteWgManualConfigurationRequired": "Configuração manual necessária", "userErrorNotAdminOrOwner": "Usuário não é administrador ou proprietário", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "Subdomínio: {subdomain}", "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Mostrar Mais", + "domainPickerNoDomainsAvailableTitle": "Nenhum domínio disponível", + "domainPickerNoDomainsAvailableDescription": "Você ainda não configurou nenhum domínio. Crie um domínio para continuar.", + "domainPickerNoDomainsAvailableAction": "Ir para Domínios", "regionSelectorTitle": "Selecionar Região", "domainPickerRemoteExitNodeWarning": "Domínios fornecidos não são suportados quando os sites se conectam a nós de saída remota. Para recursos disponíveis em nós remotos, use um domínio personalizado.", "regionSelectorInfo": "Selecionar uma região nos ajuda a fornecer melhor desempenho para sua localização. Você não precisa estar na mesma região que seu servidor.", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "Público", "resourcesTableClientResources": "Privado", "resourcesTableNoProxyResourcesFound": "Nenhum recurso de proxy encontrado.", - "resourcesTableNoInternalResourcesFound": "Nenhum recurso interno encontrado.", + "resourcesTableNoInternalResourcesFound": "Nenhum recurso privado encontrado.", "resourcesTableDestination": "Destino", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Endereço do Pseudônimo", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "Cancelar", "editInternalResourceDialogSaveResource": "Guardar Recurso", "editInternalResourceDialogSuccess": "Sucesso", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Recurso interno atualizado com sucesso", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Recurso privado atualizado com sucesso", "editInternalResourceDialogError": "Erro", - "editInternalResourceDialogFailedToUpdateInternalResource": "Falha ao atualizar recurso interno", + "editInternalResourceDialogFailedToUpdateInternalResource": "Falha ao atualizar recurso privado", "editInternalResourceDialogNameRequired": "Nome é obrigatório", "editInternalResourceDialogNameMaxLength": "Nome deve ser inferior a 255 caracteres", "editInternalResourceDialogProxyPortMin": "Porta de proxy deve ser pelo menos 1", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Um alias de DNS interno opcional para este recurso.", "createInternalResourceDialogNoSitesAvailable": "Nenhum Site Disponível", - "createInternalResourceDialogNoSitesAvailableDescription": "Você precisa ter pelo menos um site Newt com uma sub-rede configurada para criar recursos internos.", + "createInternalResourceDialogNoSitesAvailableDescription": "Você precisa ter pelo menos um site Newt com uma sub-rede configurada para criar recursos privados.", "createInternalResourceDialogClose": "Fechar", "createInternalResourceDialogCreateClientResource": "Criar Recurso Privado", "createInternalResourceDialogCreateClientResourceDescription": "Criar um novo recurso que só será acessível para clientes conectados à organização", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "Cancelar", "createInternalResourceDialogCreateResource": "Criar Recurso", "createInternalResourceDialogSuccess": "Sucesso", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Recurso interno criado com sucesso", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Recurso privado criado com sucesso", "createInternalResourceDialogError": "Erro", - "createInternalResourceDialogFailedToCreateInternalResource": "Falha ao criar recurso interno", + "createInternalResourceDialogFailedToCreateInternalResource": "Falha ao criar recurso privado", "createInternalResourceDialogNameRequired": "Nome é obrigatório", "createInternalResourceDialogNameMaxLength": "Nome deve ser inferior a 255 caracteres", "createInternalResourceDialogPleaseSelectSite": "Por favor, selecione um site", @@ -3089,8 +3098,8 @@ "sourceAddress": "Endereço de origem", "destinationAddress": "Endereço de destino", "duration": "Duração", - "licenseRequiredToUse": "Uma licença Enterprise Edition ou Pangolin Cloud é necessária para usar este recurso. Reserve um teste de demonstração ou POC.", - "ossEnterpriseEditionRequired": "O Enterprise Edition é necessário para usar este recurso. Este recurso também está disponível no Pangolin Cloud. Reserve uma demonstração ou avaliação POC.", + "licenseRequiredToUse": "Uma licença Enterprise Edition ou Pangolin Cloud é necessária para usar este recurso. Reserve um teste de demonstração ou POC para saber mais.", + "ossEnterpriseEditionRequired": "O Enterprise Edition é necessário para usar este recurso. Este recurso também está disponível no Pangolin Cloud. Reserve uma demonstração ou avaliação POC para saber mais.", "certResolver": "Resolvedor de Certificado", "certResolverDescription": "Selecione o resolvedor de certificados para este recurso.", "selectCertResolver": "Selecionar solucionador de certificado", From 31032654500c29f29344c9111d18f3b9f3a27bfb Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:13:59 -0400 Subject: [PATCH 145/494] New translations en-us.json (Russian) [ci skip] --- messages/ru-RU.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 287e61497..1ffb2f391 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Подтвердить удаление общей ссылки", "shareQuestionRemove": "Вы уверены, что хотите удалить эту общую ссылку?", "shareMessageRemove": "После удаления ссылка перестанет работать, и все, кто ее использует, потеряют доступ к ресурсу.", - "shareTokenDescription": "Токен доступа может быть передан двумя способами: как параметр запроса или в заголовках запроса. Они должны быть переданы от клиента по каждому запросу для аутентифицированного доступа.", + "shareTokenDescription": "Токен доступа может быть передан в виде параметра запроса или в заголовках запроса. По умолчанию, он должен отправляться при каждом запросе. Если включена устойчивость сеанса, первый запрос обменяет его на сеансовый файл cookie.", "accessToken": "Токен доступа", "usageExamples": "Примеры использования", "tokenId": "ID токена", @@ -196,8 +196,14 @@ "shareTitleOptional": "Заголовок (необязательно)", "sharePathOptional": "Путь (необязательно)", "sharePathDescription": "Ссылка перенаправит пользователей на этот путь после аутентификации.", + "shareAssociateUserOptional": "Связать пользователя (необязательно)", + "shareAssociateUserDescription": "При установке, запросы с этой ссылкой записываются за пользователем в журналах доступа и заголовках идентификации. Ссылка удаляется, если пользователь покидает организацию.", + "userSelect": "Выберите пользователя", + "usersNotFound": "Пользователи не найдены", "expireIn": "Срок действия", "neverExpire": "Бессрочный доступ", + "sharePersistSession": "Сохранять сеанс после первого использования", + "sharePersistSessionDescription": "При включении, первый запрос с этим токеном через параметр запроса или заголовок устанавливает сеансовый файл cookie, так что последующие запросы не требуют токена. Оставьте для API-клиентов, которые должны отправлять токен при каждом запросе.", "shareExpireDescription": "Срок действия - это период, в течение которого ссылка будет работать и предоставлять доступ к ресурсу. После этого времени ссылка перестанет работать, и пользователи, использовавшие эту ссылку, потеряют доступ к ресурсу.", "shareSeeOnce": "Вы сможете увидеть эту ссылку только один раз. Обязательно скопируйте ее.", "shareAccessHint": "Любой, у кого есть эта ссылка, может получить доступ к ресурсу. Делитесь ею с осторожностью.", @@ -928,7 +934,7 @@ "newtVersion": "Версия", "architecture": "Архитектура", "sites": "Сайты", - "siteWgAnyClients": "Для подключения используйте любой клиент WireGuard. Вы должны будете адресовать внутренние ресурсы, используя IP адрес пира.", + "siteWgAnyClients": "Используйте любой клиент WireGuard для подключения. Вам придётся обращаться к частным ресурсам, используя IP узла.", "siteWgCompatibleAllClients": "Совместим со всеми клиентами WireGuard", "siteWgManualConfigurationRequired": "Требуется ручная настройка", "userErrorNotAdminOrOwner": "Пользователь не является администратором или владельцем", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "Поддомен: {subdomain}", "domainPickerNamespace": "Пространство имен: {namespace}", "domainPickerShowMore": "Показать еще", + "domainPickerNoDomainsAvailableTitle": "Нет доступных доменов", + "domainPickerNoDomainsAvailableDescription": "У вас еще не настроены домены. Создайте домен, чтобы продолжить.", + "domainPickerNoDomainsAvailableAction": "Перейти к доменам", "regionSelectorTitle": "Выберите регион", "domainPickerRemoteExitNodeWarning": "Предоставленные домены не поддерживаются при подключении сайтов к удаленным узлам. Для доступа к ресурсам на удаленных узлах используйте пользовательский домен.", "regionSelectorInfo": "Выбор региона помогает нам обеспечить лучшее качество обслуживания для вашего расположения. Вам необязательно находиться в том же регионе, что и ваш сервер.", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "Публичный", "resourcesTableClientResources": "Приватный", "resourcesTableNoProxyResourcesFound": "Проксированных ресурсов не найдено.", - "resourcesTableNoInternalResourcesFound": "Внутренних ресурсов не найдено.", + "resourcesTableNoInternalResourcesFound": "Частные ресурсы не найдены.", "resourcesTableDestination": "Пункт назначения", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Псевдоним адреса", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "Отмена", "editInternalResourceDialogSaveResource": "Сохранить ресурс", "editInternalResourceDialogSuccess": "Успешно", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Внутренний ресурс успешно обновлен", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Частный ресурс успешно обновлен", "editInternalResourceDialogError": "Ошибка", - "editInternalResourceDialogFailedToUpdateInternalResource": "Не удалось обновить внутренний ресурс", + "editInternalResourceDialogFailedToUpdateInternalResource": "Не удалось обновить частный ресурс", "editInternalResourceDialogNameRequired": "Имя обязательно", "editInternalResourceDialogNameMaxLength": "Имя не должно быть длиннее 255 символов", "editInternalResourceDialogProxyPortMin": "Порт прокси должен быть не менее 1", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.", "createInternalResourceDialogNoSitesAvailable": "Нет доступных сайтов", - "createInternalResourceDialogNoSitesAvailableDescription": "Вам необходимо иметь хотя бы один сайт Newt с настроенной подсетью для создания внутреннего ресурса.", + "createInternalResourceDialogNoSitesAvailableDescription": "Вам необходимо иметь хотя бы один сайт Newt с настроенной подсетью для создания частных ресурсов.", "createInternalResourceDialogClose": "Закрыть", "createInternalResourceDialogCreateClientResource": "Создать приватный ресурс", "createInternalResourceDialogCreateClientResourceDescription": "Создать новый ресурс, который будет доступен только клиентам, подключенным к организации", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "Отмена", "createInternalResourceDialogCreateResource": "Создать ресурс", "createInternalResourceDialogSuccess": "Успешно", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Внутренний ресурс успешно создан", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Частный ресурс успешно создан", "createInternalResourceDialogError": "Ошибка", - "createInternalResourceDialogFailedToCreateInternalResource": "Не удалось создать внутренний ресурс", + "createInternalResourceDialogFailedToCreateInternalResource": "Не удалось создать частный ресурс", "createInternalResourceDialogNameRequired": "Имя обязательно", "createInternalResourceDialogNameMaxLength": "Имя должно содержать менее 255 символов", "createInternalResourceDialogPleaseSelectSite": "Пожалуйста, выберите сайт", @@ -3089,8 +3098,8 @@ "sourceAddress": "Адрес источника", "destinationAddress": "Адрес назначения", "duration": "Продолжительность", - "licenseRequiredToUse": "Требуется лицензия на Enterprise Edition или Pangolin Cloud для использования этой функции. Забронируйте демонстрацию или пробный POC.", - "ossEnterpriseEditionRequired": "Enterprise Edition требуется для использования этой функции. Эта функция также доступна в Pangolin Cloud. Забронируйте демонстрацию или пробный POC.", + "licenseRequiredToUse": "Требуется лицензия на Enterprise Edition или Pangolin Cloud для использования этой функции. Забронируйте демонстрацию или пробный POC, чтобы узнать больше.", + "ossEnterpriseEditionRequired": "Enterprise Edition требуется для использования этой функции. Эта функция также доступна в Pangolin Cloud. Забронируйте демонстрацию или пробный POC, чтобы узнать больше.", "certResolver": "Резольвер сертификата", "certResolverDescription": "Выберите резолвер сертификата, который будет использоваться для этого ресурса.", "selectCertResolver": "Выберите резолвер сертификата", From e658e007e1329334a4632703fe69cce5e161a3d6 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:14:01 -0400 Subject: [PATCH 146/494] New translations en-us.json (Turkish) [ci skip] --- messages/tr-TR.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 55f09d447..2144b7aa5 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Paylaşılabilir Bağlantıyı Silmeyi Onayla", "shareQuestionRemove": "Bu paylaşım bağlantısını silmek istediğinizden emin misiniz?", "shareMessageRemove": "Silindikten sonra, bağlantı artık çalışmayacak ve kullanan herkes kaynağa erişimini kaybedecek.", - "shareTokenDescription": "Erişim jetonunuz iki şekilde iletilebilir: sorgu parametresi olarak veya istek başlıklarında. Kimlik doğrulanmış erişim için her istekten müşteri tarafından iletilmelidir.", + "shareTokenDescription": "Erişim belirteci bir sorgu parametresi olarak veya istek başlıkları içinden gönderilebilir. Varsayılan olarak her istekte gönderilmelidir. Oturum kalıcılığı etkinse, ilk istek oturum çereziyle değiştirilir.", "accessToken": "Erişim Jetonu", "usageExamples": "Kullanım Örnekleri", "tokenId": "Jeton ID", @@ -196,8 +196,14 @@ "shareTitleOptional": "Başlık (isteğe bağlı)", "sharePathOptional": "Yol (isteğe bağlı)", "sharePathDescription": "Bağlantıdan sonra kullanıcıları bu yola yönlendirecek bağlantıyı tanımlayın.", + "shareAssociateUserOptional": "Kullanıcıyla İlişkilendir (isteğe bağlı)", + "shareAssociateUserDescription": "Ayarladığında, bu bağlantıyı kullanan istekler erişim günlüklerinde ve kimlik başlıklarında kullanıcıya atanır. Kullanıcı kuruluşu terk ederse bağlantı kaldırılır.", + "userSelect": "Kullanıcı seçin", + "usersNotFound": "Kullanıcı bulunamadı", "expireIn": "Süresi Dolacak", "neverExpire": "Hiçbir Zaman Sona Ermez", + "sharePersistSession": "İlk kullanım sonrası oturumu kalıcı yap", + "sharePersistSessionDescription": "Etkinleştirildiğinde, bu belirteçle yapılan ilk istek, sorgu parametresi veya başlık üzerinden, bir oturum çerezi ayarlayarak sonraki isteklerde belirteç gerektirmeyecek. Her istek için belirteç göndermesi gereken API müşterileri için bırakın.", "shareExpireDescription": "Son kullanma süresi, bağlantının kullanılabilir ve kaynağa erişim sağlayacak süresidir. Bu süreden sonra bağlantı çalışmayı durduracak ve bu bağlantıyı kullanan kullanıcılar kaynağa erişimini kaybedecektir.", "shareSeeOnce": "Bu bağlantıyı yalnızca bir kez görebileceksiniz. Kopyaladığınızdan emin olun.", "shareAccessHint": "Bu bağlantıya sahip olan herkes kaynağa erişebilir. Dikkatle paylaşın.", @@ -928,7 +934,7 @@ "newtVersion": "Sürüm", "architecture": "Mimari", "sites": "Siteler", - "siteWgAnyClients": "Herhangi bir WireGuard istemcisi kullanarak bağlanın. Dahili kaynaklara eş IP adresini kullanarak erişmeniz gerekecek.", + "siteWgAnyClients": "Bağlanmak için herhangi bir WireGuard istemcisi kullanın. Özel kaynaklara eş IP adresini kullanarak erişmeniz gerekecek.", "siteWgCompatibleAllClients": "Tüm WireGuard istemcileriyle uyumlu", "siteWgManualConfigurationRequired": "Manuel yapılandırma gerekli", "userErrorNotAdminOrOwner": "Kullanıcı yönetici veya sahibi değil", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "Alt Alan: {subdomain}", "domainPickerNamespace": "Ad Alanı: {namespace}", "domainPickerShowMore": "Daha Fazla Göster", + "domainPickerNoDomainsAvailableTitle": "Kullanılacak alan adı yok", + "domainPickerNoDomainsAvailableDescription": "Henüz ayarlanmış bir alan adınız yok. Devam etmek için bir alan adı oluşturun.", + "domainPickerNoDomainsAvailableAction": "Alan Adlarına Git", "regionSelectorTitle": "Bölge Seç", "domainPickerRemoteExitNodeWarning": "Belirtilen alan adları, siteler uzak çıkış düğümlerine bağlandığında desteklenmez. Kaynakların uzak düğümlerde kullanılabilir olması için özel bir alan adı kullanın.", "regionSelectorInfo": "Bir bölge seçmek, konumunuz için daha iyi performans sağlamamıza yardımcı olur. Sunucunuzla aynı bölgede olmanıza gerek yoktur.", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "Herkese Açık", "resourcesTableClientResources": "Özel", "resourcesTableNoProxyResourcesFound": "Hiçbir proxy kaynağı bulunamadı.", - "resourcesTableNoInternalResourcesFound": "Hiçbir dahili kaynak bulunamadı.", + "resourcesTableNoInternalResourcesFound": "Özel kaynak bulunamadı.", "resourcesTableDestination": "Hedef", "resourcesTableAlias": "Takma Ad", "resourcesTableAliasAddress": "Alias Adresi", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "İptal", "editInternalResourceDialogSaveResource": "Kaynağı Kaydet", "editInternalResourceDialogSuccess": "Başarı", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Dahili kaynak başarıyla güncellendi", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Özel kaynak başarıyla güncellendi", "editInternalResourceDialogError": "Hata", - "editInternalResourceDialogFailedToUpdateInternalResource": "Dahili kaynak güncellenemedi", + "editInternalResourceDialogFailedToUpdateInternalResource": "Özel kaynak güncellenemedi", "editInternalResourceDialogNameRequired": "Ad gerekli", "editInternalResourceDialogNameMaxLength": "Ad 255 karakterden kısa olmalıdır", "editInternalResourceDialogProxyPortMin": "Proxy bağlantı noktası en az 1 olmalıdır", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "Takma Ad", "editInternalResourceDialogAliasDescription": "Bu kaynak için isteğe bağlı dahili DNS takma adı.", "createInternalResourceDialogNoSitesAvailable": "Site Bulunamadı", - "createInternalResourceDialogNoSitesAvailableDescription": "Dahili kaynak oluşturmak için en az bir Newt sitesine ve alt ağa sahip olmalısınız.", + "createInternalResourceDialogNoSitesAvailableDescription": "Özel kaynaklar oluşturmak için alt ağı yapılandırılmış en az bir Newt sitesine sahip olmalısınız.", "createInternalResourceDialogClose": "Kapat", "createInternalResourceDialogCreateClientResource": "Özel Kaynak Oluştur", "createInternalResourceDialogCreateClientResourceDescription": "Seçilen siteye bağlı istemcilere erişilebilir olacak yeni bir kaynak oluşturun", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "İptal", "createInternalResourceDialogCreateResource": "Kaynak Oluştur", "createInternalResourceDialogSuccess": "Başarı", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Dahili kaynak başarıyla oluşturuldu", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Özel kaynak başarıyla oluşturuldu", "createInternalResourceDialogError": "Hata", - "createInternalResourceDialogFailedToCreateInternalResource": "Dahili kaynak oluşturulamadı", + "createInternalResourceDialogFailedToCreateInternalResource": "Özel kaynak oluşturulamadı", "createInternalResourceDialogNameRequired": "Ad gerekli", "createInternalResourceDialogNameMaxLength": "Ad 255 karakterden kısa olmalıdır", "createInternalResourceDialogPleaseSelectSite": "Lütfen bir site seçin", @@ -3089,8 +3098,8 @@ "sourceAddress": "Kaynak Adresi", "destinationAddress": "Hedef Adresi", "duration": "Süre", - "licenseRequiredToUse": "Bu özelliği kullanmak için bir Enterprise Edition lisansı veya Pangolin Cloud gereklidir. Tanıtım veya POC denemesi ayarlayın.", - "ossEnterpriseEditionRequired": "Bu özelliği kullanmak için Enterprise Edition gereklidir. Bu özellik ayrıca Pangolin Cloud’da da mevcuttur. Tanıtım veya POC denemesi ayarlayın.", + "licenseRequiredToUse": "Bu özelliği kullanmak için bir Enterprise Edition lisansı veya Pangolin Cloud gereklidir. Tanıtım veya POC denemesi ayarlayın", + "ossEnterpriseEditionRequired": "Bu özelliği kullanmak için Enterprise Edition gereklidir. Bu özellik ayrıca Pangolin Cloud'da da mevcuttur. Tanıtım veya POC denemesi ayarlayın", "certResolver": "Sertifika Çözücü", "certResolverDescription": "Bu kaynak için kullanılacak sertifika çözücüsünü seçin.", "selectCertResolver": "Sertifika Çözücü Seçin", From 699bc8ddb46555901df46ba587959c1c4d935ea1 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:14:03 -0400 Subject: [PATCH 147/494] New translations en-us.json (Chinese Simplified) [ci skip] --- messages/zh-CN.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 2f9b8805c..060ac4e22 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "确认删除共享链接", "shareQuestionRemove": "您确定要删除这个共享链接吗?", "shareMessageRemove": "删除后,该链接将不再可用,使用它的任何人将失去对资源的访问权限。", - "shareTokenDescription": "访问令牌可以通过两种方式传递:作为查询参数或请求标题。 每次验证访问请求都必须从客户端传递。", + "shareTokenDescription": "访问令牌可以作为查询参数或请求头传递。默认情况下,它必须在每次请求时发送。如果启用了会话持久性,则第一个请求会将其交换为会话cookie。", "accessToken": "访问令牌", "usageExamples": "用法示例", "tokenId": "令牌 ID", @@ -196,8 +196,14 @@ "shareTitleOptional": "标题 (可选)", "sharePathOptional": "路径(可选)", "sharePathDescription": "认证后,链接将把用户重定向到此路径。", + "shareAssociateUserOptional": "关联用户(可选)", + "shareAssociateUserDescription": "设置后,使用此链接的请求将在访问日志和身份头中归属于用户。如果用户离开组织,则该链接将被删除。", + "userSelect": "选择用户", + "usersNotFound": "未找到用户", "expireIn": "过期时间", "neverExpire": "永不过期", + "sharePersistSession": "首次使用后会话持久化", + "sharePersistSessionDescription": "启用后,通过查询参数或头部携带此令牌的首次请求会设置会话cookie,因此后续请求不需要令牌。对于应在每次请求中发送令牌的API客户端,请保持关闭。", "shareExpireDescription": "过期时间是链接可以使用并提供对资源的访问时间。 此时间后,链接将不再工作,使用此链接的用户将失去对资源的访问。", "shareSeeOnce": "您只能看到一次此链接。请确保复制它。", "shareAccessHint": "任何具有此链接的人都可以访问该资源。小心地分享它。", @@ -928,7 +934,7 @@ "newtVersion": "版本", "architecture": "架构", "sites": "站点", - "siteWgAnyClients": "使用任何 WireGuard 客户端连接。您必须使用对等IP解决内部资源问题。", + "siteWgAnyClients": "使用任何WireGuard客户端进行连接。您必须使用对等IP来处理私有资源。", "siteWgCompatibleAllClients": "与所有WireGuard客户端兼容", "siteWgManualConfigurationRequired": "需要手动配置", "userErrorNotAdminOrOwner": "用户不是管理员或所有者", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "子域:{subdomain}", "domainPickerNamespace": "命名空间:{namespace}", "domainPickerShowMore": "显示更多", + "domainPickerNoDomainsAvailableTitle": "无可用域", + "domainPickerNoDomainsAvailableDescription": "您还没有设置任何域。创建一个域以继续。", + "domainPickerNoDomainsAvailableAction": "转到域", "regionSelectorTitle": "选择区域", "domainPickerRemoteExitNodeWarning": "当站点连接到远程退出节点时不支持所提供的域。为了资源可在远程节点上使用,请使用自定义域名。", "regionSelectorInfo": "选择区域以帮助提升您所在地的性能。您不必与服务器在相同的区域。", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "", "resourcesTableClientResources": "私有资源", "resourcesTableNoProxyResourcesFound": "未找到代理资源。", - "resourcesTableNoInternalResourcesFound": "未找到内部资源。", + "resourcesTableNoInternalResourcesFound": "未找到私有资源。", "resourcesTableDestination": "目标", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "别名地址", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "取消", "editInternalResourceDialogSaveResource": "保存资源", "editInternalResourceDialogSuccess": "成功", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "内部资源更新成功", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "私有资源更新成功", "editInternalResourceDialogError": "错误", - "editInternalResourceDialogFailedToUpdateInternalResource": "更新内部资源失败", + "editInternalResourceDialogFailedToUpdateInternalResource": "更新私有资源失败", "editInternalResourceDialogNameRequired": "名称为必填项", "editInternalResourceDialogNameMaxLength": "名称长度必须小于255个字符", "editInternalResourceDialogProxyPortMin": "代理端口必须至少为1", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。", "createInternalResourceDialogNoSitesAvailable": "暂无可用站点", - "createInternalResourceDialogNoSitesAvailableDescription": "您需要至少配置一个子网的Newt站点来创建内部资源。", + "createInternalResourceDialogNoSitesAvailableDescription": "您需要至少配置一个子网的Newt站点来创建私有资源。", "createInternalResourceDialogClose": "关闭", "createInternalResourceDialogCreateClientResource": "创建私有资源", "createInternalResourceDialogCreateClientResourceDescription": "创建一个新资源只能为连接到组织的客户端访问", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "取消", "createInternalResourceDialogCreateResource": "创建资源", "createInternalResourceDialogSuccess": "成功", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "内部资源创建成功", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "私有资源创建成功", "createInternalResourceDialogError": "错误", - "createInternalResourceDialogFailedToCreateInternalResource": "创建内部资源失败", + "createInternalResourceDialogFailedToCreateInternalResource": "创建私有资源失败", "createInternalResourceDialogNameRequired": "名称为必填项", "createInternalResourceDialogNameMaxLength": "名称长度必须小于255个字符", "createInternalResourceDialogPleaseSelectSite": "请选择一个站点", @@ -3089,8 +3098,8 @@ "sourceAddress": "源地址", "destinationAddress": "目的地址", "duration": "期限", - "licenseRequiredToUse": "使用此功能需要企业版许可证或Pangolin Cloud预约演示或POC试用。", - "ossEnterpriseEditionRequired": "需要 Enterprise Edition 才能使用此功能。 此功能也可在 Pangolin Cloud上获取。 预订演示或POC 试用。", + "licenseRequiredToUse": "使用此功能需要企业版许可证或Pangolin Cloud预约演示或POC试用以了解更多。", + "ossEnterpriseEditionRequired": "需要企业版才能使用此功能。此功能也可在Pangolin Cloud上获取。预约演示或POC试用以了解更多。", "certResolver": "证书解决器", "certResolverDescription": "选择用于此资源的证书解析器。", "selectCertResolver": "选择证书解析", From f2dfd939bc7b30ee475ab8f429bb6b0841f3fcca Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:14:05 -0400 Subject: [PATCH 148/494] New translations en-us.json (Norwegian Bokmal) [ci skip] --- messages/nb-NO.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 8dd687d09..e309b88b1 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Bekreft sletting av delbar lenke", "shareQuestionRemove": "Er du sikker på at du vil slette denne delingslenken?", "shareMessageRemove": "Når slettet, vil lenken ikke lenger fungere, og alle som bruker den vil miste tilgang til ressursen.", - "shareTokenDescription": "Adgangstoken kan sendes på to måter: som en spørringsparameter eller i forespørselsoverskriftene. Disse må sendes fra klienten på hver forespørsel om autentisert tilgang.", + "shareTokenDescription": "Tilgangstokenn kan sendes som en spørringsparameter eller i forespørselshoder. Som standard må det sendes med hver forespørsel. Hvis sesjonsvedholdenhet er aktivert, byttes den første forespørselen mot en sesjons-cookie.", "accessToken": "Tilgangsnøkkel", "usageExamples": "Brukseksempler", "tokenId": "Token-ID", @@ -196,8 +196,14 @@ "shareTitleOptional": "Tittel (valgfritt)", "sharePathOptional": "Bane (valgfritt)", "sharePathDescription": "Lenken vil videresende brukere til denne stien etter autentisering.", + "shareAssociateUserOptional": "Tilknytt bruker (valgfritt)", + "shareAssociateUserDescription": "Når den er satt, blir forespørsler som bruker denne koblingen tilskrevet brukeren i tilgangslogger og identitetshoder. Koblingen fjernes hvis brukeren forlater organisasjonen.", + "userSelect": "Velg bruker", + "usersNotFound": "Ingen brukere funnet", "expireIn": "Utløper om", "neverExpire": "Utløper aldri", + "sharePersistSession": "Behold sesjonen etter første bruk", + "sharePersistSessionDescription": "Når den er aktivert, setter den første forespørselen med dette tokenet via en spørringsparameter eller et hode en sesjons-cookie slik at senere forespørsler ikke trenger tokenet. La det være av for API-klienter som skal sende tokenet ved hver forespørsel.", "shareExpireDescription": "Utløpstid er hvor lenge lenken vil være brukbar og gi tilgang til ressursen. Etter denne tiden vil lenken ikke lenger fungere, og brukere som brukte denne lenken vil miste tilgangen til ressursen.", "shareSeeOnce": "Du vil bare kunne se denne linken én gang. Pass på å kopiere den.", "shareAccessHint": "Alle med denne lenken kan få tilgang til ressursen. Del forsiktig.", @@ -928,7 +934,7 @@ "newtVersion": "Versjon", "architecture": "Arkitektur", "sites": "Områder", - "siteWgAnyClients": "Bruk hvilken som helst WireGuard klient til å koble til. Du må adressere interne ressurser ved hjelp av peer IP.", + "siteWgAnyClients": "Bruk hvilken som helst WireGuard-klient for å koble til. Du må adressere private ressurser ved å bruke peer-IP.", "siteWgCompatibleAllClients": "Kompatibel med alle WireGuard-klienter", "siteWgManualConfigurationRequired": "Manuell konfigurasjon påkrevd", "userErrorNotAdminOrOwner": "Bruker er ikke administrator eller eier", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "Underdomene: {subdomain}", "domainPickerNamespace": "Navnerom: {namespace}", "domainPickerShowMore": "Vis mer", + "domainPickerNoDomainsAvailableTitle": "Ingen domener tilgjengelig", + "domainPickerNoDomainsAvailableDescription": "Du har ikke satt opp noen domener ennå. Opprett et domene for å fortsette.", + "domainPickerNoDomainsAvailableAction": "Gå til domener", "regionSelectorTitle": "Velg Region", "domainPickerRemoteExitNodeWarning": "Tilbudte domener støttes ikke når sider kobles til eksterne avkjøringsnoder. For ressurser som skal være tilgjengelige på eksterne noder, brukes et egendefinert domene i stedet.", "regionSelectorInfo": "Å velge en region hjelper oss med å gi bedre ytelse for din lokasjon. Du trenger ikke være i samme region som serveren.", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "Offentlig", "resourcesTableClientResources": "Privat", "resourcesTableNoProxyResourcesFound": "Ingen proxy-ressurser funnet.", - "resourcesTableNoInternalResourcesFound": "Ingen interne ressurser funnet.", + "resourcesTableNoInternalResourcesFound": "Ingen private ressurser funnet.", "resourcesTableDestination": "Destinasjon", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Alias adresse", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "Avbryt", "editInternalResourceDialogSaveResource": "Lagre ressurs", "editInternalResourceDialogSuccess": "Suksess", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Intern ressurs oppdatert vellykket", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Privat ressurs oppdatert vellykket", "editInternalResourceDialogError": "Feil", - "editInternalResourceDialogFailedToUpdateInternalResource": "Mislyktes å oppdatere intern ressurs", + "editInternalResourceDialogFailedToUpdateInternalResource": "Feil ved oppdatering av privat ressurs", "editInternalResourceDialogNameRequired": "Navn er påkrevd", "editInternalResourceDialogNameMaxLength": "Navn kan ikke være lengre enn 255 tegn", "editInternalResourceDialogProxyPortMin": "Proxy-port må være minst 1", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Et valgfritt internt DNS-alias for denne ressursen.", "createInternalResourceDialogNoSitesAvailable": "Ingen tilgjengelige steder", - "createInternalResourceDialogNoSitesAvailableDescription": "Du må ha minst ett Newt-område med et konfigureret delnett for å lage interne ressurser.", + "createInternalResourceDialogNoSitesAvailableDescription": "Du må ha minst ett Newt-område med et subnett konfigurert for å opprette private ressurser.", "createInternalResourceDialogClose": "Lukk", "createInternalResourceDialogCreateClientResource": "Opprett privat ressurs", "createInternalResourceDialogCreateClientResourceDescription": "Opprett en ny ressurs som bare vil være tilgjengelig for kunder som er koblet til organisasjonen", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "Avbryt", "createInternalResourceDialogCreateResource": "Opprett ressurs", "createInternalResourceDialogSuccess": "Suksess", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Intern ressurs opprettet vellykket", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Privat ressurs opprettet vellykket", "createInternalResourceDialogError": "Feil", - "createInternalResourceDialogFailedToCreateInternalResource": "Kunne ikke opprette intern ressurs", + "createInternalResourceDialogFailedToCreateInternalResource": "Kunne ikke opprette privat ressurs", "createInternalResourceDialogNameRequired": "Navn er påkrevd", "createInternalResourceDialogNameMaxLength": "Navn kan ikke være lengre enn 255 tegn", "createInternalResourceDialogPleaseSelectSite": "Vennligst velg et område", @@ -3089,8 +3098,8 @@ "sourceAddress": "Kilde adresse", "destinationAddress": "Måladresse (Automatic Translation)", "duration": "Varighet", - "licenseRequiredToUse": "En Enterprise Edition lisens eller Pangolin Cloud er påkrevd for å bruke denne funksjonen. Bestill en demo eller POC prøveversjon.", - "ossEnterpriseEditionRequired": "Enterprise Edition er nødvendig for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i Pangolin Cloud. Bestill en demo eller POC studie.", + "licenseRequiredToUse": "En Enterprise Edition lisens eller Pangolin Cloud er nødvendig for å bruke denne funksjonen. Bestill en gratis demo eller POC prøve for å lære mer.", + "ossEnterpriseEditionRequired": "Enterprise Edition er nødvendig for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i Pangolin Cloud. Bestill en gratis demo eller POC prøve for å lære mer.", "certResolver": "Sertifikat løser", "certResolverDescription": "Velg sertifikatløser som skal brukes for denne ressursen.", "selectCertResolver": "Velg sertifikatløser", From 7625cc208d338429da89ed3998262bf5dbeb026f Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:14:07 -0400 Subject: [PATCH 149/494] New translations en-us.json (French) [ci skip] --- messages/fr-FR.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 5a931fa70..e5749c8c9 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Confirmer la suppression du lien partageable", "shareQuestionRemove": "Êtes-vous sûr de vouloir supprimer ce lien de partage ?", "shareMessageRemove": "Une fois supprimé, le lien ne fonctionnera plus et toute personne l'utilisant perdra l'accès à la ressource.", - "shareTokenDescription": "Le jeton d'accès peut être passé de deux façons : en tant que paramètre de requête ou dans les en-têtes de la requête. Elles doivent être transmises par le client à chaque demande d'accès authentifié.", + "shareTokenDescription": "Le jeton d'accès peut être transmis comme paramètre de requête ou dans les en-têtes de requête. Par défaut, il doit être envoyé à chaque requête. Si la persistance de session est activée, la première requête l'échange contre un cookie de session.", "accessToken": "Jeton d'accès", "usageExamples": "Exemples d'utilisation", "tokenId": "ID du jeton", @@ -196,8 +196,14 @@ "shareTitleOptional": "Titre (facultatif)", "sharePathOptional": "Chemin (optionnel)", "sharePathDescription": "Le lien redirigera les utilisateurs vers ce chemin après l'authentification.", + "shareAssociateUserOptional": "Associer un utilisateur (facultatif)", + "shareAssociateUserDescription": "Lorsqu'il est défini, les requêtes utilisant ce lien sont attribuées à l'utilisateur dans les journaux d'accès et les en-têtes d'identité. Le lien est supprimé si l'utilisateur quitte l'organisation.", + "userSelect": "Sélectionner un utilisateur", + "usersNotFound": "Aucun utilisateur trouvé", "expireIn": "Expire dans", "neverExpire": "N'expire jamais", + "sharePersistSession": "Persister la session après la première utilisation", + "sharePersistSessionDescription": "Lorsqu'elle est activée, la première requête avec ce jeton via un paramètre de requête ou un en-tête définit un cookie de session afin que les requêtes ultérieures n'aient pas besoin du jeton. Désactivez-le pour les clients API qui doivent envoyer le jeton à chaque requête.", "shareExpireDescription": "Le délai d'expiration correspond à la période pendant laquelle le lien sera utilisable et permettra d'accéder à la ressource. Passé ce délai, le lien ne fonctionnera plus et les utilisateurs qui l'ont utilisé perdront l'accès à la ressource.", "shareSeeOnce": "Vous ne pourrez voir ce lien qu'une seule fois. N'oubliez pas de le copier.", "shareAccessHint": "N'importe qui avec ce lien peut accéder à la ressource. Partagez-le avec précaution.", @@ -928,7 +934,7 @@ "newtVersion": "Version", "architecture": "Architecture", "sites": "Nœuds", - "siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser des ressources internes en utilisant l'adresse IP du pair.", + "siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser les ressources privées en utilisant l'IP du pair.", "siteWgCompatibleAllClients": "Compatible avec tous les clients WireGuard", "siteWgManualConfigurationRequired": "Configuration manuelle requise", "userErrorNotAdminOrOwner": "L'utilisateur n'est pas un administrateur ou un propriétaire", @@ -1989,6 +1995,9 @@ "domainPickerSubdomain": "Sous-domaine : {subdomain}", "domainPickerNamespace": "Espace de noms : {namespace}", "domainPickerShowMore": "Afficher plus", + "domainPickerNoDomainsAvailableTitle": "Aucun domaine disponible", + "domainPickerNoDomainsAvailableDescription": "Vous n'avez pas encore configuré de domaine. Créez un domaine pour continuer.", + "domainPickerNoDomainsAvailableAction": "Aller aux domaines", "regionSelectorTitle": "Sélectionner Région", "domainPickerRemoteExitNodeWarning": "Les domaines fournis ne sont pas pris en charge lorsque les sites se connectent à des nœuds de sortie distants. Pour que les ressources soient disponibles sur des nœuds distants, utilisez un domaine personnalisé à la place.", "regionSelectorInfo": "Sélectionner une région nous aide à offrir de meilleures performances pour votre localisation. Vous n'avez pas besoin d'être dans la même région que votre serveur.", @@ -2352,7 +2361,7 @@ "resourcesTableProxyResources": "Publique", "resourcesTableClientResources": "Privé", "resourcesTableNoProxyResourcesFound": "Aucune ressource proxy trouvée.", - "resourcesTableNoInternalResourcesFound": "Aucune ressource interne trouvée.", + "resourcesTableNoInternalResourcesFound": "Aucune ressource privée trouvée.", "resourcesTableDestination": "Destination", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Adresse de l'alias", @@ -2375,9 +2384,9 @@ "editInternalResourceDialogCancel": "Abandonner", "editInternalResourceDialogSaveResource": "Enregistrer la ressource", "editInternalResourceDialogSuccess": "Succès", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Ressource interne mise à jour avec succès", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Ressource privée mise à jour avec succès", "editInternalResourceDialogError": "Erreur", - "editInternalResourceDialogFailedToUpdateInternalResource": "Échec de la mise à jour de la ressource interne", + "editInternalResourceDialogFailedToUpdateInternalResource": "Échec de la mise à jour de la ressource privée", "editInternalResourceDialogNameRequired": "Le nom est requis", "editInternalResourceDialogNameMaxLength": "Le nom doit être inférieur à 255 caractères", "editInternalResourceDialogProxyPortMin": "Le port proxy doit être d'au moins 1", @@ -2403,7 +2412,7 @@ "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Un alias DNS interne optionnel pour cette ressource.", "createInternalResourceDialogNoSitesAvailable": "Aucun site disponible", - "createInternalResourceDialogNoSitesAvailableDescription": "Vous devez avoir au moins un site Newt avec un sous-réseau configuré pour créer des ressources internes.", + "createInternalResourceDialogNoSitesAvailableDescription": "Vous devez avoir au moins un site Newt avec un sous-réseau configuré pour créer des ressources privées.", "createInternalResourceDialogClose": "Fermer", "createInternalResourceDialogCreateClientResource": "Créer une ressource privée", "createInternalResourceDialogCreateClientResourceDescription": "Créer une nouvelle ressource qui ne sera accessible qu'aux clients connectés à l'organisation", @@ -2432,9 +2441,9 @@ "createInternalResourceDialogCancel": "Abandonner", "createInternalResourceDialogCreateResource": "Créer une ressource", "createInternalResourceDialogSuccess": "Succès", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Ressource interne créée avec succès", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Ressource privée créée avec succès", "createInternalResourceDialogError": "Erreur", - "createInternalResourceDialogFailedToCreateInternalResource": "Échec de la création de la ressource interne", + "createInternalResourceDialogFailedToCreateInternalResource": "Échec de la création de la ressource privée", "createInternalResourceDialogNameRequired": "Le nom est requis", "createInternalResourceDialogNameMaxLength": "Le nom doit être inférieur à 255 caractères", "createInternalResourceDialogPleaseSelectSite": "Veuillez sélectionner un site", @@ -3089,8 +3098,8 @@ "sourceAddress": "Adresse source", "destinationAddress": "Adresse de destination", "duration": "Durée", - "licenseRequiredToUse": "Une licence Enterprise Edition ou Pangolin Cloud est requise pour utiliser cette fonctionnalité. Réservez une démonstration ou une évaluation de POC.", - "ossEnterpriseEditionRequired": "La version Enterprise Edition est requise pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans Pangolin Cloud. Réservez une démo ou un essai POC.", + "licenseRequiredToUse": "Une licence Enterprise Edition ou Pangolin Cloud est requise pour utiliser cette fonctionnalité. Réservez une démo gratuite ou un essai POC pour en savoir plus.", + "ossEnterpriseEditionRequired": "La version Enterprise Edition est requise pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans Pangolin Cloud. Réservez une démo gratuite ou un essai POC pour en savoir plus.", "certResolver": "Résolveur de certificat", "certResolverDescription": "Sélectionnez le solveur de certificat à utiliser pour cette ressource.", "selectCertResolver": "Sélectionnez le résolveur de certificat", From b0e274f5a9f8879f01f036b91dc4b90f0769d0f3 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 30 Jul 2026 11:14:34 -0400 Subject: [PATCH 150/494] Fix 100% showing on resources with no hc anymore --- src/components/UptimeBar.tsx | 4 +++- src/components/UptimeMiniBar.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/UptimeBar.tsx b/src/components/UptimeBar.tsx index 37e38bcaa..1c933f478 100644 --- a/src/components/UptimeBar.tsx +++ b/src/components/UptimeBar.tsx @@ -134,7 +134,9 @@ export default function UptimeBar({ if (!data) return null; - const allNoData = data.days.every((d) => d.status === "no_data"); + const allNoData = data.days.every( + (d) => d.status === "no_data" || d.status === "unknown" + ); return (
diff --git a/src/components/UptimeMiniBar.tsx b/src/components/UptimeMiniBar.tsx index 454f44d66..cbdf7a6ea 100644 --- a/src/components/UptimeMiniBar.tsx +++ b/src/components/UptimeMiniBar.tsx @@ -124,7 +124,9 @@ export function UptimeMiniBar({ if (!data) return null; - const allNoData = data.days.every((d) => d.status === "no_data"); + const allNoData = data.days.every( + (d) => d.status === "no_data" || d.status === "unknown" + ); return (
From f015fb592bb011e518efad7f56441b509174d969 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 30 Jul 2026 17:55:58 +0100 Subject: [PATCH 151/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Prioritize=20varia?= =?UTF-8?q?nt=20in=20place=20of=20idp.type=20if=20available?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/auth/login/page.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 92097512c..31a626281 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -91,6 +91,7 @@ export default async function Page(props: { let loginIdps: LoginFormIDP[] = []; let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null = null; + if (!useSmartLogin) { // Load IdPs for DashboardLoginForm (OSS or org-only IdP mode) if (build === "oss" || env.app.identityProviderMode !== "org") { @@ -117,12 +118,12 @@ export default async function Page(props: { `/idp/${persistedData.idpId}` ); - const idp = idpRes.data.data.idp; + const res = idpRes.data.data; lastUsedIdpForSmartLogin = { - idpId: idp.idpId, - name: idp.name, - variant: idp.type, + idpId: res.idp.idpId, + name: res.idp.name, + variant: res.idpOidcConfig?.variant ?? res.idp.type, orgId: persistedData.orgId, lastUsed: true }; From e7098963d6ee46bc1204ab4d5b89f1d5d882d3fb Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 30 Jul 2026 17:56:15 +0100 Subject: [PATCH 152/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Code=20Refactoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/IdpLoginButtons.tsx | 89 ++++++++++++++---------------- src/components/OrgIdpTable.tsx | 82 +++++++++++++-------------- 2 files changed, 80 insertions(+), 91 deletions(-) diff --git a/src/components/IdpLoginButtons.tsx b/src/components/IdpLoginButtons.tsx index 0851a0dd0..0adccc9c6 100644 --- a/src/components/IdpLoginButtons.tsx +++ b/src/components/IdpLoginButtons.tsx @@ -114,59 +114,52 @@ export default function IdpLoginButtons({
{params.get("gotoapp") ? ( - <> - - + ) : ( - <> - {idps.map((idp) => { - const effectiveType = - idp.variant || idp.name.toLowerCase(); + idps.map((idp) => { + const effectiveType = + idp.variant || idp.name.toLowerCase(); - return ( -
+ + + {idp.name} + - {idp.lastUsed && ( -
- - {t("idpLastUsed")} - -
- )} -
- ); - })} - + {idp.lastUsed && ( +
+ + {t("idpLastUsed")} + +
+ )} +
+ ); + }) )}
diff --git a/src/components/OrgIdpTable.tsx b/src/components/OrgIdpTable.tsx index f72bc5341..4fb39f513 100644 --- a/src/components/OrgIdpTable.tsx +++ b/src/components/OrgIdpTable.tsx @@ -1,17 +1,6 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; -import { IdpDataTable } from "@app/components/OrgIdpDataTable"; -import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Credenza, CredenzaBody, @@ -22,37 +11,42 @@ import { CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; +import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner"; +import IdpTypeBadge from "@app/components/IdpTypeBadge"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; +import { IdpDataTable } from "@app/components/OrgIdpDataTable"; +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; import { - ArrowRight, - ArrowUpDown, - MoreHorizontal -} from "lucide-react"; -import { useMemo, useState } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { toast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { useRouter } from "next/navigation"; + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; -import Link from "next/link"; -import { useTranslations } from "next-intl"; -import IdpTypeBadge from "@app/components/IdpTypeBadge"; -import IdpTypeIcon from "@app/components/IdpTypeIcon"; -import { useQuery } from "@tanstack/react-query"; -import { useDebounce } from "use-debounce"; -import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types"; -import { cn } from "@app/lib/cn"; -import { Badge } from "@app/components/ui/badge"; +import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { toast } from "@app/hooks/useToast"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner"; +import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { useDebounce } from "use-debounce"; export type IdpRow = { idpId: number; @@ -483,15 +477,17 @@ export default function IdpTable({ idps, orgId }: Props) { {group.name}
- {group.sources.map((src) => ( - - {src.orgName} - - ))} + {group.sources.map( + (src) => ( + + {src.orgName} + + ) + )}
From c8c8d74452db56a2cd89a250eaf19cfc0eb41c0e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 30 Jul 2026 21:52:51 +0100 Subject: [PATCH 153/494] =?UTF-8?q?=F0=9F=90=9B=20resolve=20certificates?= =?UTF-8?q?=20for=20non=20exact=20domains=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../certificates/getBatchedCertificates.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/server/private/routers/certificates/getBatchedCertificates.ts b/server/private/routers/certificates/getBatchedCertificates.ts index 7149de036..91e074d97 100644 --- a/server/private/routers/certificates/getBatchedCertificates.ts +++ b/server/private/routers/certificates/getBatchedCertificates.ts @@ -73,16 +73,19 @@ async function query(orgId: string, domainList: string[]) { .where(and(inArray(certificates.domain, domainList))); // All non resolved domain certificates might be `ns` or `wildcard`, - // which means exact domain certificates do not - const nonAvailableCertificates = existingCertificates - .filter((cert) => !domainList.includes(cert.domain)) - .map((cert) => cert.domain); + // which means exact domain certificates do not exist + const foundDomains = new Set( + existingCertificates.map((cert) => cert.domain) + ); + const domainsWithMissingCertificates = domainList.filter( + (domain) => !foundDomains.has(domain) + ); - if (nonAvailableCertificates.length > 0) { + if (domainsWithMissingCertificates.length > 0) { const domainLevelDownSet = new Set(); const wildcardDomainSet = new Set(); - for (const domain of nonAvailableCertificates) { + for (const domain of domainsWithMissingCertificates) { const domainLevelDown = domain.split(".").slice(1).join("."); const wildcardPrefixed = `*.${domainLevelDown}`; domainLevelDownSet.add(domainLevelDown); @@ -131,6 +134,7 @@ async function query(orgId: string, domainList: string[]) { for (const domain of domainList) { const domainLevelDown = domain.split(".").slice(1).join("."); const wildcardPrefixed = `*.${domainLevelDown}`; + certificateMap[domain] = existingCertificates.find( (cert) => From 47522b7e3ae385935bd00735e3804a866563e4b6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 30 Jul 2026 21:53:31 +0100 Subject: [PATCH 154/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20set=20cert=20error?= =?UTF-8?q?=20if=20initial=20value=20is=20`null`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useCertificate.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/useCertificate.ts b/src/hooks/useCertificate.ts index a71c3ef70..1a4ff46f4 100644 --- a/src/hooks/useCertificate.ts +++ b/src/hooks/useCertificate.ts @@ -111,7 +111,8 @@ export function useCertificate({ let certError: string | null = null; if (restartCert.isError) { certError = "Failed to restart"; - } else if (isError) { + } else if (isError || initialCertValue === null) { + // Null value means failed to get the certificate certError = "Failed"; } From 13caad18c734fb7a9dda4790c73dfe8e9cd1eb1e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 30 Jul 2026 21:53:46 +0100 Subject: [PATCH 155/494] =?UTF-8?q?=F0=9F=8E=A8=20format=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/queries.ts | 56 ++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/src/lib/queries.ts b/src/lib/queries.ts index f51ca0ac0..96024c3a0 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,4 +1,10 @@ +import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; +import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; import { build } from "@server/build"; +import { + StatusHistoryResponse, + type BatchedStatusHistoryResponse +} from "@server/lib/statusHistory"; import type { ListAlertRulesResponse } from "@server/routers/alertRule/types"; import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; import type { @@ -7,6 +13,7 @@ import type { QueryConnectionAuditLogResponse, QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; +import type { GetCertificateResponse } from "@server/routers/certificates/types"; import type { ListClientsResponse, ListUserDevicesResponse @@ -16,15 +23,30 @@ import type { ListDomainsResponse } from "@server/routers/domain"; import type { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; +import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; +import type { + LauncherResource, + ListLauncherGroupsResponse, + ListLauncherLabelsResponse, + ListLauncherResourcesResponse, + ListLauncherScaleResponse, + ListLauncherSitesResponse, + ListLauncherViewsResponse +} from "@server/routers/launcher/types"; +import type { GetResourcePolicyResponse } from "@server/routers/policy"; import type { - GetResourceWhitelistResponse, GetResourcePoliciesResponse, + GetResourceWhitelistResponse, ListResourceNamesResponse, - ListResourcesResponse, ListResourceRolesResponse, ListResourceRulesResponse, + ListResourcesResponse, ListResourceUsersResponse } from "@server/routers/resource"; +import type { GetResourceResponse } from "@server/routers/resource/getResource"; +import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo"; +import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; import type { ListRolesResponse } from "@server/routers/role"; import type { ListSitesResponse } from "@server/routers/site"; import type { @@ -33,6 +55,7 @@ import type { ListSiteResourceRolesResponse, ListSiteResourceUsersResponse } from "@server/routers/siteResource"; +import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource"; import type { ListTargetsResponse } from "@server/routers/target"; import type { ListUsersResponse } from "@server/routers/user"; import type ResponseT from "@server/types/Response"; @@ -42,37 +65,12 @@ import { queryOptions } from "@tanstack/react-query"; import { isAxiosError, type AxiosResponse } from "axios"; -import z, { meta } from "zod"; +import z from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; -import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; -import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; -import { - StatusHistoryResponse, - type BatchedStatusHistoryResponse -} from "@server/lib/statusHistory"; -import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; -import type { GetResourcePolicyResponse } from "@server/routers/policy"; -import type { - ListLauncherGroupsResponse, - ListLauncherLabelsResponse, - ListLauncherResourcesResponse, - ListLauncherScaleResponse, - ListLauncherSitesResponse, - ListLauncherViewsResponse, - LauncherListQuery, - LauncherResource, - LauncherViewConfig -} from "@server/routers/launcher/types"; -import type { GetResourceResponse } from "@server/routers/resource/getResource"; -import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo"; -import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource"; -import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; -import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; -import type { GetCertificateResponse } from "@server/routers/certificates/types"; -export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; +export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; export type ProductUpdate = { link: string | null; From af87edf3a6b6528463f8710d976ab923808c394a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 30 Jul 2026 22:06:38 +0100 Subject: [PATCH 156/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20use=20`const`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/private/routers/certificates/getBatchedCertificates.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/private/routers/certificates/getBatchedCertificates.ts b/server/private/routers/certificates/getBatchedCertificates.ts index 91e074d97..1c390b7dd 100644 --- a/server/private/routers/certificates/getBatchedCertificates.ts +++ b/server/private/routers/certificates/getBatchedCertificates.ts @@ -46,7 +46,7 @@ const getCertificateQuerySchema = z.object({ async function query(orgId: string, domainList: string[]) { // Try to get CNAME certificates first - let existingCertificates = await db + const existingCertificates = await db .select({ certId: certificates.certId, domain: certificates.domain, From 7d7c54107dbcae6e89e1c7848ad4788eaeeecfc7 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 31 Jul 2026 14:20:19 -0400 Subject: [PATCH 157/494] add first seen and last seen columns to user devices table --- server/routers/client/listUserDevices.ts | 23 +++++-- .../[orgId]/settings/clients/user/page.tsx | 4 +- src/components/UserDevicesTable.tsx | 68 ++++++++++++++++++- 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index fb3004921..cc006565f 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -67,12 +67,12 @@ const listUserDevicesSchema = z.strictObject({ }), query: z.string().optional(), sort_by: z - .enum(["megabytesIn", "megabytesOut"]) + .enum(["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["megabytesIn", "megabytesOut"], + enum: ["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"], description: "Field to sort by" }), order: z @@ -183,7 +183,9 @@ function queryUserDevicesBase() { fingerprintArch: currentFingerprint.arch, fingerprintSerialNumber: currentFingerprint.serialNumber, fingerprintUsername: currentFingerprint.username, - fingerprintHostname: currentFingerprint.hostname + fingerprintHostname: currentFingerprint.hostname, + firstSeen: currentFingerprint.firstSeen, + lastSeen: currentFingerprint.lastSeen }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) @@ -389,14 +391,23 @@ export async function listUserDevices( const countQuery = db.$count(baseQuery.as("filtered_clients")); + const sortColumn = + sort_by === "firstSeen" + ? currentFingerprint.firstSeen + : sort_by === "lastSeen" + ? currentFingerprint.lastSeen + : sort_by + ? clients[sort_by] + : undefined; + const listDevicesQuery = baseQuery .limit(pageSize) .offset(pageSize * (page - 1)) .orderBy( - sort_by + sortColumn ? order === "asc" - ? asc(clients[sort_by]) - : desc(clients[sort_by]) + ? asc(sortColumn) + : desc(sortColumn) : asc(clients.clientId) ); diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index 880019177..5cc904fee 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -104,7 +104,9 @@ export default async function ClientsPage(props: ClientsPageProps) { archived: Boolean(client.archived), blocked: Boolean(client.blocked), approvalState: client.approvalState, - fingerprint + fingerprint, + firstSeen: client.firstSeen ?? null, + lastSeen: client.lastSeen ?? null }; }; diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 6b881f4e9..957df9218 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -77,6 +77,8 @@ export type ClientRow = { username: string | null; hostname: string | null; } | null; + firstSeen: number | null; + lastSeen: number | null; }; type ClientTableProps = { @@ -112,7 +114,9 @@ export default function UserDevicesTable({ const defaultUserColumnVisibility = { subnet: false, - niceId: false + niceId: false, + firstSeen: false, + lastSeen: false }; const refreshData = () => { @@ -621,6 +625,68 @@ export default function UserDevicesTable({ accessorKey: "subnet", friendlyName: t("address"), header: () => {t("address")} + }, + { + accessorKey: "firstSeen", + friendlyName: t("firstSeen"), + header: () => { + const firstSeenOrder = getSortDirection( + "firstSeen", + searchParams + ); + + const Icon = + firstSeenOrder === "asc" + ? ArrowDown01Icon + : firstSeenOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + }, + cell: ({ row }) => { + const firstSeen = row.original.firstSeen; + if (!firstSeen) return "-"; + return new Date(firstSeen * 1000).toLocaleString(); + } + }, + { + accessorKey: "lastSeen", + friendlyName: t("lastSeen"), + header: () => { + const lastSeenOrder = getSortDirection( + "lastSeen", + searchParams + ); + + const Icon = + lastSeenOrder === "asc" + ? ArrowDown01Icon + : lastSeenOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + }, + cell: ({ row }) => { + const lastSeen = row.original.lastSeen; + if (!lastSeen) return "-"; + return new Date(lastSeen * 1000).toLocaleString(); + } } ]; From 1e521b0b54ffc850dab59804d8436dc83fe452c3 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 3 Aug 2026 10:35:26 -0400 Subject: [PATCH 158/494] Dont double log error --- server/routers/gerbil/updateHolePunch.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts index fad3f277d..ca40fd08b 100644 --- a/server/routers/gerbil/updateHolePunch.ts +++ b/server/routers/gerbil/updateHolePunch.ts @@ -112,7 +112,9 @@ export async function updateHolePunch( destinations: destinations }); } catch (error) { - logger.error(error); + if (!(error instanceof Error && error.message === "Exit node not allowed")) { + logger.error(error); + } return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, From 3ef3ede7df085ec2e6f83a8b58568ce20455c70b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 3 Aug 2026 11:41:22 -0400 Subject: [PATCH 159/494] dont pass orgId in generate oidc url --- src/components/IdpLoginButtons.tsx | 9 ++++++--- src/components/SmartLoginForm.tsx | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/IdpLoginButtons.tsx b/src/components/IdpLoginButtons.tsx index 0adccc9c6..54daa6d63 100644 --- a/src/components/IdpLoginButtons.tsx +++ b/src/components/IdpLoginButtons.tsx @@ -26,12 +26,14 @@ type IdpLoginButtonsProps = { idps: LoginFormIDP[]; redirect?: string; orgId?: string; + passOrgIdToOidcUrl?: boolean; }; export default function IdpLoginButtons({ idps, redirect, - orgId + orgId, + passOrgIdToOidcUrl = true }: IdpLoginButtonsProps) { const [error, setError] = useState(null); const t = useTranslations(); @@ -68,12 +70,13 @@ export default function IdpLoginButtons({ let redirectToUrl: string | undefined; try { - console.log("generating", idpId, redirect || "/", orgId); + const oidcOrgId = passOrgIdToOidcUrl ? orgId : undefined; + console.log("generating", idpId, redirect || "/", oidcOrgId); const safeRedirect = cleanRedirect(redirect || "/"); const response = await generateOidcUrlProxy( idpId, safeRedirect, - orgId + oidcOrgId ); if (response.error) { diff --git a/src/components/SmartLoginForm.tsx b/src/components/SmartLoginForm.tsx index de1955771..a56ad6e75 100644 --- a/src/components/SmartLoginForm.tsx +++ b/src/components/SmartLoginForm.tsx @@ -303,6 +303,7 @@ export default function SmartLoginForm({ )} From e84da6a8dfa9e660b660e44675f9bab359637f67 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 3 Aug 2026 14:07:57 -0400 Subject: [PATCH 160/494] Add rate limits to sensitve pages --- .../private/lib/traefik/getTraefikConfig.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index d47352e04..03b7bfa73 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -58,6 +58,8 @@ import { build } from "@server/build"; const redirectHttpsMiddlewareName = "redirect-to-https"; const redirectToRootMiddlewareName = "redirect-to-root"; const badgerMiddlewareName = "badger"; +const landingRateLimitMiddlewareName = "landing-ratelimit"; +const bgRateLimitMiddlewareName = "bg-ratelimit"; // Define extended target type with site information type TargetWithSite = Target & { @@ -432,6 +434,18 @@ export async function getTraefikConfig( replacement: "${1}://${2}/auth/org", permanent: false } + }, + [landingRateLimitMiddlewareName]: { + rateLimit: { + average: 3, + burst: 6 + } + }, + [bgRateLimitMiddlewareName]: { + rateLimit: { + average: 3, + burst: 6 + } } } } @@ -1055,6 +1069,7 @@ export async function getTraefikConfig( config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares = [ badgerMiddlewareName, + bgRateLimitMiddlewareName, ...additionalMiddlewares ]; @@ -1539,6 +1554,7 @@ export async function getTraefikConfig( entryPoints: [ config.getRawConfig().traefik.https_entrypoint ], + middlewares: [landingRateLimitMiddlewareName], service: "landing-service", rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, priority: 203, @@ -1557,7 +1573,10 @@ export async function getTraefikConfig( entryPoints: [ config.getRawConfig().traefik.https_entrypoint ], - middlewares: [redirectToRootMiddlewareName], + middlewares: [ + landingRateLimitMiddlewareName, + redirectToRootMiddlewareName + ], service: "landing-service", rule: `Host(\`${fullDomain}\`)`, priority: 202, From 7f2b3eb481f7fce099322ab4173563d0a5184b44 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 3 Aug 2026 14:34:11 -0400 Subject: [PATCH 161/494] Move rate linmit to file --- server/lib/readConfigFile.ts | 19 ++++++++++++++++++- .../private/lib/traefik/getTraefikConfig.ts | 10 ++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 6b6ac95aa..cb4b83ccf 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -258,7 +258,24 @@ export const configSchema = z pp_transport_prefix: z .string() .optional() - .default("pp-transport-v") + .default("pp-transport-v"), + rate_limit: z + .object({ + average: z + .number() + .positive() + .gt(0) + .optional() + .default(10), + burst: z + .number() + .positive() + .gt(0) + .optional() + .default(16) + }) + .optional() + .prefault({}) }) .optional() .prefault({}), diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 03b7bfa73..b1a34842a 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -420,6 +420,8 @@ export async function getTraefikConfig( // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); } + const traefikRateLimit = config.getRawConfig().traefik.rate_limit; + const config_output: any = { http: { middlewares: { @@ -437,14 +439,14 @@ export async function getTraefikConfig( }, [landingRateLimitMiddlewareName]: { rateLimit: { - average: 3, - burst: 6 + average: traefikRateLimit.average, + burst: traefikRateLimit.burst } }, [bgRateLimitMiddlewareName]: { rateLimit: { - average: 3, - burst: 6 + average: traefikRateLimit.average, + burst: traefikRateLimit.burst } } } From efd2792197e3ea1c01e393721c70192bdb7e5754 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 3 Aug 2026 17:57:36 -0400 Subject: [PATCH 162/494] bump default rate limit --- server/lib/readConfigFile.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index cb4b83ccf..5774456a7 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -266,13 +266,13 @@ export const configSchema = z .positive() .gt(0) .optional() - .default(10), + .default(30), burst: z .number() .positive() .gt(0) .optional() - .default(16) + .default(50) }) .optional() .prefault({}) From f079714cafb4f2307a1fac8d9c6105a005cea58f Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 4 Aug 2026 10:07:52 -0400 Subject: [PATCH 163/494] Dont redirect when the browser agent is not real --- server/routers/badger/verifySession.ts | 54 ++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 33c75b101..99e0d9812 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -127,6 +127,9 @@ export async function verifyResourceSession( // Extract HTTP Basic Auth credentials if present const clientHeaderAuth = extractBasicAuth(headers); + const clientUserAgent = headers?.["user-agent"] || headers?.["User-Agent"]; + const clientIsBrowser = isBrowserUserAgent(clientUserAgent); + const clientIp = requestIp ? stripPortFromHost(requestIp, badgerVersion) : undefined; @@ -313,9 +316,14 @@ export async function verifyResourceSession( return allowed(res, undefined, dontStripSession); } - const redirectPath = `/auth/resource/${encodeURIComponent( - resource.resourceGuid - )}?redirect=${encodeURIComponent(originalRequestURL)}`; + // Only offer a browser redirect to clients that can actually follow one and log in + // (an interactive browser). Non-browser clients (curl, scripts, bots, etc.) just get + // an unauthorized response from Badger instead of a login redirect URL. + const redirectPath = clientIsBrowser + ? `/auth/resource/${encodeURIComponent( + resource.resourceGuid + )}?redirect=${encodeURIComponent(originalRequestURL)}` + : undefined; // check for access token in headers if ( @@ -1476,6 +1484,46 @@ async function getCountryCodeFromIp(ip: string): Promise { return cachedCountryCode; } +// Permissive by default: only reject known non-browser clients or a missing +// User-Agent (real browsers always send one). This avoids blocking real +// browsers whose UA string doesn't match a hardcoded allow-list. +const NON_BROWSER_USER_AGENT_PATTERNS = [ + /curl/, + /wget/, + /python-requests/, + /python-urllib/, + /go-http-client/, + /okhttp/, + /axios/, + /node-fetch/, + /postmanruntime/, + /insomnia/, + /libwww-perl/, + /java\//, + /ruby/, + /php/, + /bot/, + /spider/, + /crawler/, + /headlesschrome/, + /phantomjs/, + /httpclient/, + /prometheus/, + /go-resty/, + /apache-httpclient/, + /scrapy/ +]; + +function isBrowserUserAgent(userAgent: string | undefined): boolean { + if (!userAgent) { + return false; + } + + const ua = userAgent.toLowerCase(); + + return !NON_BROWSER_USER_AGENT_PATTERNS.some((pattern) => pattern.test(ua)); +} + function extractBasicAuth( headers: Record | undefined ): string | undefined { From ba24e1c4f5ec5c8b404bd35746a986923fcc50f8 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 30 Jul 2026 11:57:48 -0400 Subject: [PATCH 164/494] Add exit node selection to the clients --- server/lib/calculateUserClientsForOrgs.ts | 14 --- .../lib/exitNodes/calculateExitNodeWeight.ts | 40 ++++++++ server/lib/exitNodes/exitNodes.ts | 11 ++- server/lib/exitNodes/index.ts | 1 + server/private/lib/exitNodes/exitNodes.ts | 11 ++- server/routers/client/createClient.ts | 15 --- server/routers/client/createUserClient.ts | 6 -- ...s => handleNewtExitNodesRequestMessage.ts} | 40 +++----- .../routers/newt/handleNewtRegisterMessage.ts | 11 +-- server/routers/newt/index.ts | 2 +- .../olm/handleOlmExitNodesRequestMessage.ts | 93 +++++++++++++++++++ .../routers/olm/handleOlmRegisterMessage.ts | 22 ++++- server/routers/ws/messageHandlers.ts | 4 +- 13 files changed, 190 insertions(+), 80 deletions(-) create mode 100644 server/lib/exitNodes/calculateExitNodeWeight.ts rename server/routers/newt/{handleNewtPingRequestMessage.ts => handleNewtExitNodesRequestMessage.ts} (64%) create mode 100644 server/routers/olm/handleOlmExitNodesRequestMessage.ts diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 39585500a..5770029de 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -339,19 +339,6 @@ export async function calculateUserClientsForOrgs( continue; } - // Get exit nodes for this org - const exitNodesList = await getExitNodes(orgId); - - if (exitNodesList.length === 0) { - logger.warn( - `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found` - ); - continue; - } - - const randomExitNode = - exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; - // Get next available subnet const { value: newSubnet, release: releaseSubnetLock } = await getNextAvailableClientSubnet(orgId, trx); @@ -370,7 +357,6 @@ export async function calculateUserClientsForOrgs( const newClientData: InferInsertModel = { userId, orgId: userOrg.orgId, - exitNodeId: randomExitNode.exitNodeId, name: olm.name || "User Client", subnet: updatedSubnet, olmId: olm.olmId, diff --git a/server/lib/exitNodes/calculateExitNodeWeight.ts b/server/lib/exitNodes/calculateExitNodeWeight.ts new file mode 100644 index 000000000..a740e7a89 --- /dev/null +++ b/server/lib/exitNodes/calculateExitNodeWeight.ts @@ -0,0 +1,40 @@ +import { db, sites, clients } from "@server/db"; +import { and, eq, count } from "drizzle-orm"; + +// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS) +// higher = more desirable +// like saying, this node has x% of its capacity left +export async function calculateExitNodeWeight( + exitNodeId: number, + maxConnections: number | null | undefined +): Promise { + if (maxConnections === null || maxConnections === undefined) { + return 1; + } + + const [[siteConnections], [clientConnections]] = await Promise.all([ + db + .select({ count: count() }) + .from(sites) + .where( + and(eq(sites.exitNodeId, exitNodeId), eq(sites.online, true)) + ), + db + .select({ count: count() }) + .from(clients) + .where( + and( + eq(clients.exitNodeId, exitNodeId), + eq(clients.online, true) + ) + ) + ]); + + const currentConnections = siteConnections.count + clientConnections.count; + + if (currentConnections >= maxConnections) { + return null; + } + + return (maxConnections - currentConnections) / maxConnections; +} diff --git a/server/lib/exitNodes/exitNodes.ts b/server/lib/exitNodes/exitNodes.ts index f405a1114..823fdde97 100644 --- a/server/lib/exitNodes/exitNodes.ts +++ b/server/lib/exitNodes/exitNodes.ts @@ -1,6 +1,5 @@ import { db, exitNodes, Transaction } from "@server/db"; import logger from "@server/logger"; -import { ExitNodePingResult } from "@server/routers/newt"; import { eq } from "drizzle-orm"; export async function verifyExitNodeOrgAccess( @@ -52,6 +51,16 @@ export async function listExitNodes( return allExitNodes; } +export type ExitNodePingResult = { + exitNodeId: number; + latencyMs: number; + weight: number; + error?: string; + exitNodeName: string; + endpoint: string; + wasPreviouslyConnected: boolean; +}; + export function selectBestExitNode( pingResults: ExitNodePingResult[] ): ExitNodePingResult | null { diff --git a/server/lib/exitNodes/index.ts b/server/lib/exitNodes/index.ts index d1477a683..459995074 100644 --- a/server/lib/exitNodes/index.ts +++ b/server/lib/exitNodes/index.ts @@ -2,3 +2,4 @@ export * from "./exitNodes"; export * from "./exitNodeComms"; export * from "./subnet"; export * from "./getCurrentExitNodeId"; +export * from "./calculateExitNodeWeight"; diff --git a/server/private/lib/exitNodes/exitNodes.ts b/server/private/lib/exitNodes/exitNodes.ts index 1f9517725..976f52cff 100644 --- a/server/private/lib/exitNodes/exitNodes.ts +++ b/server/private/lib/exitNodes/exitNodes.ts @@ -25,7 +25,6 @@ import { Transaction } from "@server/db"; import logger from "@server/logger"; -import { ExitNodePingResult } from "@server/routers/newt"; import { eq, and, or, ne, isNull, inArray } from "drizzle-orm"; import axios from "axios"; import config from "../config"; @@ -330,6 +329,16 @@ export async function listExitNodes( return exitNodesList; } +export type ExitNodePingResult = { + exitNodeId: number; + latencyMs: number; + weight: number; + error?: string; + exitNodeName: string; + endpoint: string; + wasPreviouslyConnected: boolean; +}; + /** * Selects the most suitable exit node from a list of ping results. * diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index 11c7c9ec2..d84a30301 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -255,20 +255,6 @@ export async function createClient( let newClient: Client | null = null; await db.transaction(async (trx) => { - // TODO: more intelligent way to pick the exit node - const exitNodesList = await listExitNodes(orgId); - const randomExitNode = - exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; - - if (!randomExitNode) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}` - ) - ); - } - const [adminRole] = await trx .select() .from(roles) @@ -287,7 +273,6 @@ export async function createClient( .insert(clients) .values({ niceId, - exitNodeId: randomExitNode.exitNodeId, orgId, name, subnet: updatedSubnet, diff --git a/server/routers/client/createUserClient.ts b/server/routers/client/createUserClient.ts index 70027e4a5..bc1b25ebf 100644 --- a/server/routers/client/createUserClient.ts +++ b/server/routers/client/createUserClient.ts @@ -222,11 +222,6 @@ export async function createUserClient( let newClient: Client | null = null; await db.transaction(async (trx) => { - // TODO: more intelligent way to pick the exit node - const exitNodesList = await listExitNodes(orgId); - const randomExitNode = - exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; - const [adminRole] = await trx .select() .from(roles) @@ -244,7 +239,6 @@ export async function createUserClient( [newClient] = await trx .insert(clients) .values({ - exitNodeId: randomExitNode.exitNodeId, orgId, niceId, name, diff --git a/server/routers/newt/handleNewtPingRequestMessage.ts b/server/routers/newt/handleNewtExitNodesRequestMessage.ts similarity index 64% rename from server/routers/newt/handleNewtPingRequestMessage.ts rename to server/routers/newt/handleNewtExitNodesRequestMessage.ts index f239dc4de..1adfd9f1c 100644 --- a/server/routers/newt/handleNewtPingRequestMessage.ts +++ b/server/routers/newt/handleNewtExitNodesRequestMessage.ts @@ -2,14 +2,17 @@ import { db, sites } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { exitNodes, Newt } from "@server/db"; import logger from "@server/logger"; -import { ne, eq, or, and, count } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { listExitNodes } from "#dynamic/lib/exitNodes"; +import { calculateExitNodeWeight } from "@server/lib/exitNodes"; -export const handleNewtPingRequestMessage: MessageHandler = async (context) => { +export const handleNewtExitNodesRequestMessage: MessageHandler = async ( + context +) => { const { message, client, sendToClient } = context; const newt = client as Newt; - logger.info("Handling ping request newt message!"); + logger.info("Handling exit nodes request newt message!"); if (!newt) { logger.warn("Newt not found"); @@ -54,32 +57,13 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => { const exitNodesPayload = await Promise.all( exitNodesList.map(async (node) => { - // (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS) - // higher = more desirable - // like saying, this node has x% of its capacity left + const weight = await calculateExitNodeWeight( + node.exitNodeId, + node.maxConnections + ); - let weight = 1; - const maxConnections = node.maxConnections; - if (maxConnections !== null && maxConnections !== undefined) { - const [currentConnections] = await db - .select({ - count: count() - }) - .from(sites) - .where( - and( - eq(sites.exitNodeId, node.exitNodeId), - eq(sites.online, true) - ) - ); - - if (currentConnections.count >= maxConnections) { - return null; - } - - weight = - (maxConnections - currentConnections.count) / - maxConnections; + if (weight === null) { + return null; } return { diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 0dc8380c8..27956e845 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -7,6 +7,7 @@ import logger from "@server/logger"; import config from "@server/lib/config"; import { findNextAvailableCidr } from "@server/lib/ip"; import { + ExitNodePingResult, selectBestExitNode, verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; @@ -15,16 +16,6 @@ import { lockManager } from "#dynamic/lib/lock"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { canCompress } from "@server/lib/clientVersionChecks"; -export type ExitNodePingResult = { - exitNodeId: number; - latencyMs: number; - weight: number; - error?: string; - exitNodeName: string; - endpoint: string; - wasPreviouslyConnected: boolean; -}; - export const handleNewtRegisterMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index 1f6090358..5665a5420 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -5,7 +5,7 @@ export * from "./handleNewtRegisterMessage"; export * from "./handleReceiveBandwidthMessage"; export * from "./handleNewtGetConfigMessage"; export * from "./handleSocketMessages"; -export * from "./handleNewtPingRequestMessage"; +export * from "./handleNewtExitNodesRequestMessage"; export * from "./handleApplyBlueprintMessage"; export * from "./handleNewtPingMessage"; export * from "./handleNewtDisconnectingMessage"; diff --git a/server/routers/olm/handleOlmExitNodesRequestMessage.ts b/server/routers/olm/handleOlmExitNodesRequestMessage.ts new file mode 100644 index 000000000..bb8c7a48f --- /dev/null +++ b/server/routers/olm/handleOlmExitNodesRequestMessage.ts @@ -0,0 +1,93 @@ +import { db, clients } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { exitNodes, Olm } from "@server/db"; +import logger from "@server/logger"; +import { eq } from "drizzle-orm"; +import { listExitNodes } from "#dynamic/lib/exitNodes"; +import { calculateExitNodeWeight } from "@server/lib/exitNodes"; + +export const handleOlmExitNodesRequestMessage: MessageHandler = async ( + context +) => { + const { message, client: olmClient, sendToClient } = context; + const olm = olmClient as Olm; + + logger.info("Handling exit nodes request olm message!"); + + if (!olm) { + logger.warn("olm not found"); + return; + } + + // Get the olm's orgId through the client relationship + if (!olm.clientId) { + logger.warn("olm clientId not found"); + return; + } + + const [client] = await db + .select({ orgId: clients.orgId }) + .from(clients) + .where(eq(clients.clientId, olm.clientId)) + .limit(1); + + if (!client || !client.orgId) { + logger.warn("client not found"); + return; + } + + const { noCloud, chainId } = message.data; + + const exitNodesList = await listExitNodes( + client.orgId, + true, + noCloud || false, + olm.clientId + ); // filter for only the online ones + + let lastExitNodeId = null; + if (olm.clientId) { + const [lastExitNode] = await db + .select() + .from(clients) + .where(eq(clients.clientId, olm.clientId)) + .limit(1); + lastExitNodeId = lastExitNode?.exitNodeId || null; + } + + const exitNodesPayload = await Promise.all( + exitNodesList.map(async (node) => { + const weight = await calculateExitNodeWeight( + node.exitNodeId, + node.maxConnections + ); + + if (weight === null) { + return null; + } + + return { + exitNodeId: node.exitNodeId, + exitNodeName: node.name, + endpoint: node.endpoint, + weight, + wasPreviouslyConnected: node.exitNodeId === lastExitNodeId + }; + }) + ); + + // filter out null values + const filteredExitNodes = exitNodesPayload.filter((node) => node !== null); + + return { + message: { + type: "olm/ping/exitNodes", + data: { + exitNodes: filteredExitNodes, + chainId: chainId + } + }, + broadcast: false, // Send to all clients + excludeSender: false // Include sender in broadcast + }; +}; diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index d386fe74e..6b0002b83 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -22,6 +22,7 @@ import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations"; +import { ExitNodePingResult, selectBestExitNode } from "#dynamic/lib/exitNodes"; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; @@ -49,6 +50,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { olmAgent, orgId, userToken, + pingResults, fingerprint, postures, chainId @@ -284,7 +286,22 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { return; } - if (client.pubKey !== publicKey || client.archived) { + let exitNodeId: number | undefined; + if (pingResults) { + const bestPingResult = selectBestExitNode( + pingResults as ExitNodePingResult[] + ); + if (!bestPingResult) { + logger.warn("No suitable exit node found based on ping results"); + } + exitNodeId = bestPingResult?.exitNodeId; + } + + if ( + client.pubKey !== publicKey || + client.archived || + client.exitNodeId !== exitNodeId + ) { logger.info( "[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...", { orgId: client.orgId, clientId: client.clientId } @@ -294,7 +311,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { .update(clients) .set({ pubKey: publicKey, - archived: false + archived: false, + exitNodeId: exitNodeId // this can be undefined if no exit node was selected, which is fine just means we cant talk to the node or connect to it }) .where(eq(clients.clientId, client.clientId)); diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index 496002142..6b8a14127 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -5,7 +5,7 @@ import { handleNewtGetConfigMessage, handleDockerStatusMessage, handleDockerContainersMessage, - handleNewtPingRequestMessage, + handleNewtExitNodesRequestMessage, handleApplyBlueprintMessage, handleNewtPingMessage, startNewtOfflineChecker, @@ -45,7 +45,7 @@ export const messageHandlers: Record = { "newt/receive-bandwidth": handleReceiveBandwidthMessage, "newt/socket/status": handleDockerStatusMessage, "newt/socket/containers": handleDockerContainersMessage, - "newt/ping/request": handleNewtPingRequestMessage, + "newt/ping/request": handleNewtExitNodesRequestMessage, "newt/blueprint/apply": handleApplyBlueprintMessage, "newt/healthcheck/status": handleHealthcheckStatusMessage, "ws/round-trip/complete": handleRoundTripMessage From 33dd10c670715573dacaa474ffae206fdddf187a Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 30 Jul 2026 12:13:09 -0400 Subject: [PATCH 165/494] Rename subnet for clarity, pick subnet on client --- server/db/sqlite/schema/schema.ts | 3 +- .../exitNodes/getUniqueSubnetForExitNode.ts | 41 ++++++++++++++ server/lib/exitNodes/index.ts | 1 + server/lib/rebuildClientAssociations.ts | 6 +-- server/lib/traefik/getTraefikConfig.ts | 2 +- .../private/lib/traefik/getTraefikConfig.ts | 2 +- .../routers/re-key/reGenerateSiteSecret.ts | 2 +- server/routers/gerbil/getAllRelays.ts | 8 +-- server/routers/gerbil/getConfig.ts | 4 +- server/routers/gerbil/updateHolePunch.ts | 2 +- .../newt/handleNewtGetConfigMessage.ts | 11 ++-- .../routers/newt/handleNewtRegisterMessage.ts | 53 ++++--------------- server/routers/olm/buildConfiguration.ts | 2 +- .../routers/olm/handleOlmRegisterMessage.ts | 52 ++++++++++++++++-- server/routers/site/createSite.ts | 8 +-- server/routers/site/listSites.ts | 2 +- server/routers/site/pickSiteDefaults.ts | 7 +-- server/routers/target/createTarget.ts | 2 +- .../sites/[niceId]/credentials/page.tsx | 5 +- 19 files changed, 135 insertions(+), 78 deletions(-) create mode 100644 server/lib/exitNodes/getUniqueSubnetForExitNode.ts diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index d0db18555..1e2d822e7 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -107,7 +107,7 @@ export const sites = sqliteTable("sites", { }), name: text("name").notNull(), pubKey: text("pubKey"), - subnet: text("subnet"), + exitNodeSubnet: text("exitNodeSubnet"), megabytesIn: integer("bytesIn").default(0), megabytesOut: integer("bytesOut").default(0), lastBandwidthUpdate: text("lastBandwidthUpdate"), @@ -599,6 +599,7 @@ export const clients = sqliteTable("clients", { pubKey: text("pubKey"), olmId: text("olmId"), // to lock it to a specific olm optionally subnet: text("subnet").notNull(), + exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node megabytesIn: integer("bytesIn"), megabytesOut: integer("bytesOut"), lastBandwidthUpdate: text("lastBandwidthUpdate"), diff --git a/server/lib/exitNodes/getUniqueSubnetForExitNode.ts b/server/lib/exitNodes/getUniqueSubnetForExitNode.ts new file mode 100644 index 000000000..728049eb1 --- /dev/null +++ b/server/lib/exitNodes/getUniqueSubnetForExitNode.ts @@ -0,0 +1,41 @@ +import { db, ExitNode, Transaction, sites, clients } from "@server/db"; +import { eq } from "drizzle-orm"; +import config from "@server/lib/config"; +import { findNextAvailableCidr } from "@server/lib/ip"; +import { lockManager } from "#dynamic/lib/lock"; + +export async function getUniqueSubnetForExitNode( + exitNode: ExitNode, + trx: Transaction | typeof db = db +): Promise { + const lockKey = `subnet-allocation:${exitNode.exitNodeId}`; + + return await lockManager.withLock( + lockKey, + async () => { + const [sitesQuery, clientsQuery] = await Promise.all([ + trx + .select({ subnet: sites.exitNodeSubnet }) + .from(sites) + .where(eq(sites.exitNodeId, exitNode.exitNodeId)), + trx + .select({ subnet: clients.exitNodeSubnet }) + .from(clients) + .where(eq(clients.exitNodeId, exitNode.exitNodeId)) + ]); + + const blockSize = config.getRawConfig().gerbil.site_block_size; + const subnets = [...sitesQuery, ...clientsQuery] + .map((row) => row.subnet) + .filter( + (subnet): subnet is string => + !!subnet && + /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet) + ); + subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`)); + + return findNextAvailableCidr(subnets, blockSize, exitNode.address); + }, + 5000 // 5 second lock TTL - subnet allocation should be quick + ); +} diff --git a/server/lib/exitNodes/index.ts b/server/lib/exitNodes/index.ts index 459995074..18474e27d 100644 --- a/server/lib/exitNodes/index.ts +++ b/server/lib/exitNodes/index.ts @@ -3,3 +3,4 @@ export * from "./exitNodeComms"; export * from "./subnet"; export * from "./getCurrentExitNodeId"; export * from "./calculateExitNodeWeight"; +export * from "./getUniqueSubnetForExitNode"; diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index eae579634..f8b83f5f7 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -966,7 +966,7 @@ export async function updateClientSiteDestinations( .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); for (const site of sitesData) { - if (!site.sites.subnet) { + if (!site.sites.exitNodeSubnet) { logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`); continue; } @@ -1002,7 +1002,7 @@ export async function updateClientSiteDestinations( sourcePort: parsedEndpoint.port, destinations: [ { - destinationIP: site.sites.subnet.split("/")[0], + destinationIP: site.sites.exitNodeSubnet.split("/")[0], destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated } ] @@ -1010,7 +1010,7 @@ export async function updateClientSiteDestinations( } else { // add to the existing destinations destinations.destinations.push({ - destinationIP: site.sites.subnet.split("/")[0], + destinationIP: site.sites.exitNodeSubnet.split("/")[0], destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated }); } diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index c63b5b718..3e84cd570 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -87,7 +87,7 @@ export async function getTraefikConfig( siteId: sites.siteId, siteType: sites.type, siteOnline: sites.online, - subnet: sites.subnet, + subnet: sites.exitNodeSubnet, exitNodeId: sites.exitNodeId, // Domain cert resolver fields domainCertResolver: domains.certResolver, diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index b1a34842a..4be4c6d2b 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -136,7 +136,7 @@ export async function getTraefikConfig( siteId: sites.siteId, siteType: sites.type, siteOnline: sites.online, - subnet: sites.subnet, + subnet: sites.exitNodeSubnet, exitNodeId: sites.exitNodeId, // Namespace domainNamespaceId: domainNamespaces.domainNamespaceId, diff --git a/server/private/routers/re-key/reGenerateSiteSecret.ts b/server/private/routers/re-key/reGenerateSiteSecret.ts index 6e69eb3f1..2bb29b202 100644 --- a/server/private/routers/re-key/reGenerateSiteSecret.ts +++ b/server/private/routers/re-key/reGenerateSiteSecret.ts @@ -178,7 +178,7 @@ export async function reGenerateSiteSecret( ); } - if (site.exitNodeId && site.subnet) { + if (site.exitNodeId && site.exitNodeSubnet) { await deletePeer(site.exitNodeId, site.pubKey!); // the old pubkey await addPeer(site.exitNodeId, { publicKey: pubKey, diff --git a/server/routers/gerbil/getAllRelays.ts b/server/routers/gerbil/getAllRelays.ts index 23d538e62..a9a6037e1 100644 --- a/server/routers/gerbil/getAllRelays.ts +++ b/server/routers/gerbil/getAllRelays.ts @@ -100,7 +100,7 @@ export async function generateRelayMappings(exitNode: ExitNode) { // Filter to sites with the required fields up front so the rest of the // function can safely treat endpoint/subnet/listenPort as defined. const validSites = sitesRes.filter( - (s) => s.endpoint && s.subnet && s.listenPort + (s) => s.endpoint && s.exitNodeSubnet && s.listenPort ); if (validSites.length === 0) { @@ -136,7 +136,7 @@ export async function generateRelayMappings(exitNode: ExitNode) { if ( peer.orgId == null || !peer.endpoint || - !peer.subnet || + !peer.exitNodeSubnet || !peer.listenPort ) { continue; @@ -183,7 +183,7 @@ export async function generateRelayMappings(exitNode: ExitNode) { // Process each site using the pre-fetched data. for (const site of validSites) { const siteDestination: PeerDestination = { - destinationIP: site.subnet!.split("/")[0], + destinationIP: site.exitNodeSubnet!.split("/")[0], destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated }; @@ -207,7 +207,7 @@ export async function generateRelayMappings(exitNode: ExitNode) { continue; } addDestination(site.endpoint!, { - destinationIP: peer.subnet!.split("/")[0], + destinationIP: peer.exitNodeSubnet!.split("/")[0], destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated }); } diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index 1557edce9..77921c39f 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -89,7 +89,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) { and( eq(sites.exitNodeId, exitNode.exitNodeId), isNotNull(sites.pubKey), - isNotNull(sites.subnet) + isNotNull(sites.exitNodeSubnet) ) ); @@ -103,7 +103,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) { } else if (site.type === "newt") { return { publicKey: site.pubKey, - allowedIps: [site.subnet!] + allowedIps: [site.exitNodeSubnet!] }; } return { diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts index ca40fd08b..54906e240 100644 --- a/server/routers/gerbil/updateHolePunch.ts +++ b/server/routers/gerbil/updateHolePunch.ts @@ -188,7 +188,7 @@ export async function updateAndGenerateEndpointDestinations( .select({ siteId: sites.siteId, newtId: newts.newtId, - subnet: sites.subnet, + subnet: sites.exitNodeSubnet, listenPort: sites.listenPort, publicKey: sites.publicKey, endpoint: clientSitesAssociationsCache.endpoint, diff --git a/server/routers/newt/handleNewtGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts index cb8b8d931..169c8a0f0 100644 --- a/server/routers/newt/handleNewtGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -95,16 +95,16 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { .limit(1); if ( exitNode.reachableAt && - existingSite.subnet && + existingSite.exitNodeSubnet && existingSite.listenPort ) { const payload = { oldDestination: { - destinationIP: existingSite.subnet?.split("/")[0], + destinationIP: existingSite.exitNodeSubnet?.split("/")[0], destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated }, newDestination: { - destinationIP: site.subnet?.split("/")[0], + destinationIP: site.exitNodeSubnet?.split("/")[0], destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated } }; @@ -132,7 +132,10 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { ({ targets: dedupedTargets, certs } = dedupeCertsForTargets(targets)); } - const targetsToSend = await convertTargetsIfNecessary(newt.newtId, dedupedTargets); // for backward compatibility with old newt versions that don't support the new target format + const targetsToSend = await convertTargetsIfNecessary( + newt.newtId, + dedupedTargets + ); // for backward compatibility with old newt versions that don't support the new target format return { message: { diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 27956e845..b04df1c83 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -1,18 +1,17 @@ -import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db"; +import { db, newts, remoteExitNodes } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { exitNodes, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; import { addPeer, deletePeer } from "../gerbil/peers"; import logger from "@server/logger"; import config from "@server/lib/config"; -import { findNextAvailableCidr } from "@server/lib/ip"; import { ExitNodePingResult, selectBestExitNode, verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; +import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes"; import { fetchContainers } from "./dockerSocket"; -import { lockManager } from "#dynamic/lib/lock"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { canCompress } from "@server/lib/clientVersionChecks"; @@ -85,9 +84,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { fetchContainers(newt.newtId); } - let siteSubnet = oldSite.subnet; + let siteSubnet = oldSite.exitNodeSubnet; let exitNodeIdToQuery = oldSite.exitNodeId; - if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) { + if ( + exitNodeId && + (oldSite.exitNodeId !== exitNodeId || !oldSite.exitNodeSubnet) + ) { // This effectively moves the exit node to the new one exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId @@ -106,7 +108,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { return; } - const newSubnet = await getUniqueSubnetForSite(exitNode); + const newSubnet = await getUniqueSubnetForExitNode(exitNode); if (!newSubnet) { logger.error( @@ -122,7 +124,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .set({ pubKey: publicKey, exitNodeId: exitNodeId, - subnet: newSubnet + exitNodeSubnet: newSubnet }) .where(eq(sites.siteId, siteId)) .returning(); @@ -241,40 +243,3 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { excludeSender: false // Include sender in broadcast }; }; - -async function getUniqueSubnetForSite( - exitNode: ExitNode, - trx: Transaction | typeof db = db -): Promise { - const lockKey = `subnet-allocation:${exitNode.exitNodeId}`; - - return await lockManager.withLock( - lockKey, - async () => { - const sitesQuery = await trx - .select({ - subnet: sites.subnet - }) - .from(sites) - .where(eq(sites.exitNodeId, exitNode.exitNodeId)); - - const blockSize = config.getRawConfig().gerbil.site_block_size; - const subnets = sitesQuery - .map((site) => site.subnet) - .filter( - (subnet) => - subnet && - /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet) - ) - .filter((subnet) => subnet !== null); - subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`)); - const newSubnet = findNextAvailableCidr( - subnets, - blockSize, - exitNode.address - ); - return newSubnet; - }, - 5000 // 5 second lock TTL - subnet allocation should be quick - ); -} diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index 37355eae5..ed1b0cfae 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -167,7 +167,7 @@ export async function buildSiteConfigurationForOlmClient( peerOps.push(deletePeer(site.siteId, client.pubKey!)); } - if (!site.subnet) { + if (!site.exitNodeSubnet) { logger.debug(`Site ${site.siteId} has no subnet, skipping`); continue; } diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 6b0002b83..c0c395fb5 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -22,7 +22,12 @@ import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations"; -import { ExitNodePingResult, selectBestExitNode } from "#dynamic/lib/exitNodes"; +import { + ExitNodePingResult, + selectBestExitNode, + verifyExitNodeOrgAccess +} from "#dynamic/lib/exitNodes"; +import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes"; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; @@ -297,10 +302,50 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { exitNodeId = bestPingResult?.exitNodeId; } + let clientSubnet = client.exitNodeSubnet; + if ( + exitNodeId && + (client.exitNodeId !== exitNodeId || !client.exitNodeSubnet) + ) { + const { exitNode, hasAccess } = await verifyExitNodeOrgAccess( + exitNodeId, + client.orgId + ); + + if (!exitNode) { + logger.warn("[handleOlmRegisterMessage] Exit node not found", { + orgId: client.orgId, + clientId: client.clientId + }); + return; + } + + if (!hasAccess) { + logger.warn( + "[handleOlmRegisterMessage] Not authorized to use this exit node", + { orgId: client.orgId, clientId: client.clientId } + ); + return; + } + + const newSubnet = await getUniqueSubnetForExitNode(exitNode); + + if (!newSubnet) { + logger.error( + `[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`, + { orgId: client.orgId, clientId: client.clientId } + ); + return; + } + + clientSubnet = newSubnet; + } + if ( client.pubKey !== publicKey || client.archived || - client.exitNodeId !== exitNodeId + client.exitNodeId !== exitNodeId || + client.exitNodeSubnet !== clientSubnet ) { logger.info( "[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...", @@ -312,7 +357,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { .set({ pubKey: publicKey, archived: false, - exitNodeId: exitNodeId // this can be undefined if no exit node was selected, which is fine just means we cant talk to the node or connect to it + exitNodeId: exitNodeId, // this can be undefined if no exit node was selected, which is fine just means we cant talk to the node or connect to it + exitNodeSubnet: clientSubnet }) .where(eq(clients.clientId, client.clientId)); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index b24608609..574751e93 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -311,13 +311,13 @@ export async function createSite( // lets also make sure there is no overlap with other sites on the exit node const sitesQuery = await db .select({ - subnet: sites.subnet + subnet: sites.exitNodeSubnet }) .from(sites) .where( and( eq(sites.exitNodeId, exitNodeId), - eq(sites.subnet, subnet) + eq(sites.exitNodeSubnet, subnet) ) ); @@ -427,7 +427,7 @@ export async function createSite( exitNodeId, name, niceId: updatedNiceId!, - subnet, + exitNodeSubnet: subnet, type, pubKey: pubKey || null, status: "approved" @@ -444,7 +444,7 @@ export async function createSite( type, dockerSocketEnabled: false, online: true, - subnet: "0.0.0.0/32", + exitNodeSubnet: "0.0.0.0/32", status: "approved" }) .returning(); diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index cdc3a5630..0d1a588a1 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -125,7 +125,7 @@ function querySitesBase() { niceId: sites.niceId, name: sites.name, pubKey: sites.pubKey, - subnet: sites.subnet, + subnet: sites.exitNodeSubnet, megabytesIn: sites.megabytesIn, megabytesOut: sites.megabytesOut, orgName: orgs.name, diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index ea04a4ada..c65749112 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -43,7 +43,6 @@ const PickSiteDefaultsResponseDataSchema = z.object({ clientAddress: z.string().optional() }); - registry.registerPath({ method: "get", path: "/org/{orgId}/pick-site-defaults", @@ -60,7 +59,9 @@ registry.registerPath({ description: "Successful response", content: { "application/json": { - schema: createApiResponseSchema(PickSiteDefaultsResponseDataSchema) + schema: createApiResponseSchema( + PickSiteDefaultsResponseDataSchema + ) } } } @@ -108,7 +109,7 @@ export async function pickSiteDefaults( // list all of the sites on that exit node const sitesQuery = await db .select({ - subnet: sites.subnet + subnet: sites.exitNodeSubnet }) .from(sites) .where(eq(sites.exitNodeId, randomExitNode.exitNodeId)); diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index cf2cd6b5f..84fa00cec 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -263,7 +263,7 @@ export async function createTarget( // make sure the target is within the site subnet if ( site.type == "wireguard" && - !isIpInCidr(targetData.ip, site.subnet!) + !isIpInCidr(targetData.ip, site.exitNodeSubnet!) ) { return next( createHttpError( diff --git a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx index 41b8f62eb..62b166939 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx @@ -72,8 +72,7 @@ export default function CredentialsPage() { const { data: latestVersions } = useQuery( productUpdatesQueries.latestVersion(true) ); - const newtVersion = - latestVersions?.data?.newt?.latestVersion ?? "latest"; + const newtVersion = latestVersions?.data?.newt?.latestVersion ?? "latest"; // Fetch site defaults for wireguard sites to show in obfuscated config useEffect(() => { @@ -354,7 +353,7 @@ export default function CredentialsPage() { text={generateObfuscatedWireGuardConfig( { subnet: - site?.subnet || + site?.exitNodeSubnet || siteDefaults?.subnet || null, address: From fa8b9216350a1585de10ea2879516d46a1428958 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 30 Jul 2026 13:57:07 -0400 Subject: [PATCH 166/494] Fix postgres schema exit node subnet --- server/db/pg/schema/schema.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 17a58d94d..4e0719284 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -99,6 +99,7 @@ export const sites = pgTable( name: varchar("name").notNull(), pubKey: varchar("pubKey"), subnet: varchar("subnet"), + exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node megabytesIn: real("bytesIn").default(0), megabytesOut: real("bytesOut").default(0), lastBandwidthUpdate: varchar("lastBandwidthUpdate"), @@ -1181,7 +1182,7 @@ export const clients = pgTable( olmId: text("olmId"), // to lock it to a specific olm optionally name: varchar("name").notNull(), pubKey: varchar("pubKey"), - subnet: varchar("subnet").notNull(), + exitNodeSubnet: varchar("exitNodeSubnet").notNull(), megabytesIn: real("bytesIn"), megabytesOut: real("bytesOut"), lastBandwidthUpdate: varchar("lastBandwidthUpdate"), From 4dbb04bfb81934168b3214473fb6d801befc35cd Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 30 Jul 2026 13:57:45 -0400 Subject: [PATCH 167/494] Handle the ping exit node request backward mode for olm and install handler --- server/routers/olm/handleOlmRegisterMessage.ts | 8 ++++++++ server/routers/olm/index.ts | 1 + server/routers/ws/messageHandlers.ts | 4 +++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index c0c395fb5..f97c3328c 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -58,9 +58,17 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { pingResults, fingerprint, postures, + backwardsCompatible, chainId } = message.data; + if (backwardsCompatible) { + logger.debug( + "[handleOlmRegisterMessage] Backwards compatible mode detected - not sending connect message and waiting for ping response." + ); + return; + } + if (!olm.clientId) { logger.warn("[handleOlmRegisterMessage] Olm client ID not found"); sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId); diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index e11d4e48e..f02672b77 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -15,3 +15,4 @@ export * from "./handleOlmServerInitAddPeerHandshake"; export * from "./offlineChecker"; export * from "./handleOlmUnLocalMessage"; export * from "./handleOlmLocalMessage"; +export * from "./handleOlmExitNodesRequestMessage"; diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index 6b8a14127..fff2bf7c4 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -22,7 +22,8 @@ import { handleOlmDisconnectingMessage, handleOlmServerInitAddPeerHandshake, handleOlmLocalMessage, - handleOlmUnLocalMessage + handleOlmUnLocalMessage, + handleOlmExitNodesRequestMessage } from "../olm"; import { handleHealthcheckStatusMessage } from "../target"; import { handleRoundTripMessage } from "./handleRoundTripMessage"; @@ -37,6 +38,7 @@ export const messageHandlers: Record = { "olm/wg/local": handleOlmLocalMessage, "olm/wg/unlocal": handleOlmUnLocalMessage, "olm/ping": handleOlmPingMessage, + "olm/ping/request": handleOlmExitNodesRequestMessage, "olm/disconnecting": handleOlmDisconnectingMessage, "newt/disconnecting": handleNewtDisconnectingMessage, "newt/ping": handleNewtPingMessage, From 2cdeb7c104e9aea4761accfba7f47111ebe0e0b1 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 30 Jul 2026 17:34:13 -0400 Subject: [PATCH 168/494] Add inference resource type --- server/db/pg/schema/schema.ts | 7 ++++-- server/db/sqlite/schema/schema.ts | 9 ++++++-- .../routers/olm/handleOlmRegisterMessage.ts | 23 +++++++++++++++---- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 4e0719284..0bbf88631 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -195,7 +195,10 @@ export const resources = pgTable( postAuthPath: text("postAuthPath"), health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" wildcard: boolean("wildcard").notNull().default(false), - mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc + mode: text("mode") + .default("http") + .$type<"rdp" | "ssh" | "http" | "vnc" | "inference">() + .notNull(), pamMode: varchar("pamMode", { length: 32 }) .$type<"passthrough" | "push">() .default("passthrough"), @@ -429,7 +432,7 @@ export const siteResources = pgTable( name: varchar("name").notNull(), ssl: boolean("ssl").notNull().default(false), mode: varchar("mode") - .$type<"host" | "cidr" | "http" | "ssh">() + .$type<"host" | "cidr" | "http" | "ssh" | "inference">() .notNull(), // "host" | "cidr" | "http" scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 1e2d822e7..f13afa2cf 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -203,7 +203,10 @@ export const resources = sqliteTable("resources", { postAuthPath: text("postAuthPath"), health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false), - mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc + mode: text("mode") + .default("http") + .$type<"rdp" | "ssh" | "http" | "vnc" | "inference">() + .notNull(), // rdp, ssh, http, vnc, inference pamMode: text("pamMode") .$type<"passthrough" | "push">() .default("passthrough"), @@ -425,7 +428,9 @@ export const siteResources = sqliteTable("siteResources", { niceId: text("niceId").notNull(), name: text("name").notNull(), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), - mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http" + mode: text("mode") + .$type<"host" | "cidr" | "http" | "ssh" | "inference">() + .notNull(), // "host" | "cidr" | "http" scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index f97c3328c..8d148380c 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -1,4 +1,4 @@ -import { db, orgs, primaryDb } from "@server/db"; +import { db, ExitNode, orgs, primaryDb } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, @@ -311,14 +311,15 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { } let clientSubnet = client.exitNodeSubnet; + let exitNode: ExitNode | null = null; if ( exitNodeId && (client.exitNodeId !== exitNodeId || !client.exitNodeSubnet) ) { - const { exitNode, hasAccess } = await verifyExitNodeOrgAccess( - exitNodeId, - client.orgId - ); + const { exitNode: exitNodeResult, hasAccess } = + await verifyExitNodeOrgAccess(exitNodeId, client.orgId); + + exitNode = exitNodeResult; if (!exitNode) { logger.warn("[handleOlmRegisterMessage] Exit node not found", { @@ -466,6 +467,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { sites: siteConfigurations, tunnelIP: client.subnet, utilitySubnet: org.utilitySubnet, + exitNode: + exitNode && client.exitNodeSubnet + ? { + endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, + relayPort: + config.getRawConfig().gerbil + .clients_start_port, + publicKey: exitNode.publicKey, + serverIP: exitNode.address.split("/")[0], + tunnelIP: client.exitNodeSubnet.split("/")[0] + } + : undefined, chainId: chainId } }, From 093097c6193576d3264daf60b6c774fcb842c0fc Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 31 Jul 2026 10:54:18 -0400 Subject: [PATCH 169/494] send if we need to connect to the gerbil or not --- server/db/pg/schema/schema.ts | 3 +++ server/db/sqlite/schema/schema.ts | 5 +++++ server/routers/olm/buildConfiguration.ts | 22 +++++++++++++------ .../routers/olm/handleOlmRegisterMessage.ts | 16 +++++++++----- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 0bbf88631..9f74788d7 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -428,6 +428,9 @@ export const siteResources = pgTable( onDelete: "restrict" } ), + requiresExitNodeConnection: boolean("requiresExitNodeConnection") + .notNull() + .default(false), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), ssl: boolean("ssl").notNull().default(false), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index f13afa2cf..ec1b9e6f4 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -425,6 +425,11 @@ export const siteResources = sqliteTable("siteResources", { () => networks.networkId, { onDelete: "restrict" } ), + requiresExitNodeConnection: integer("requiresExitNodeConnection", { + mode: "boolean" + }) + .notNull() + .default(false), niceId: text("niceId").notNull(), name: text("name").notNull(), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index ed1b0cfae..736363a5f 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -48,10 +48,6 @@ export async function buildSiteConfigurationForOlmClient( ) .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); - if (sitesData.length === 0) { - return siteConfigurations; - } - // Batch-fetch every site resource this client has access to across ALL sites // in a single query, then group by siteId in memory. This avoids issuing one // query per site (which would be N round-trips for N sites). @@ -68,8 +64,8 @@ export async function buildSiteConfigurationForOlmClient( clientSiteResourcesAssociationsCache.siteResourceId ) ) - .innerJoin(networks, eq(siteResources.networkId, networks.networkId)) - .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) + .leftJoin(networks, eq(siteResources.networkId, networks.networkId)) + .leftJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) .where( and( eq( @@ -80,8 +76,20 @@ export async function buildSiteConfigurationForOlmClient( ) ); + const haveInferenceResources = allClientSiteResources.some( + (row) => row.siteResource.requiresExitNodeConnection === true + ); + + if (sitesData.length === 0) { + return { siteConfigurations, haveInferenceResources }; + } + const siteResourcesBySiteId = new Map(); for (const row of allClientSiteResources) { + if (!row.siteId) { + // because we are doing a leftJoin above to get the inference resources without a network / sites + continue; + } const arr = siteResourcesBySiteId.get(row.siteId); if (arr) { arr.push(row.siteResource); @@ -226,5 +234,5 @@ export async function buildSiteConfigurationForOlmClient( }); } - return siteConfigurations; + return { siteConfigurations, haveInferenceResources }; } diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 8d148380c..7fa270fa2 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -337,6 +337,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { return; } + // TODO: IF WE DO NOT HAVE AN INFERENCE RESOURCE DO WE NEED TO BE HOLDING A SUBNET ON THE CLIENT? + const newSubnet = await getUniqueSubnetForExitNode(exitNode); if (!newSubnet) { @@ -452,12 +454,13 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { // NOTE: its important that the client here is the old client and the public key is the new key await waitForClientRebuildIdle(olm.clientId); - const siteConfigurations = await buildSiteConfigurationForOlmClient( - client, - publicKey, - relay, - jitMode - ); + const { siteConfigurations, haveInferenceResources } = + await buildSiteConfigurationForOlmClient( + client, + publicKey, + relay, + jitMode + ); // Return connect message with all site configurations return { @@ -470,6 +473,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { exitNode: exitNode && client.exitNodeSubnet ? { + connect: haveInferenceResources, // we do not need to connect to the exit node if we do not have inference resources endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, relayPort: config.getRawConfig().gerbil From deb2d5ce2a7bd61a5b5aa7a7e3ac3a46a3b2b613 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 31 Jul 2026 11:06:30 -0400 Subject: [PATCH 170/494] Update site resources to handle new inference --- .../siteResource/createSiteResource.ts | 53 +++++++++++-------- .../siteResource/updateSiteResource.ts | 28 ++++++++-- 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index d3e32babd..d220a4b08 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -49,7 +49,7 @@ const createSiteResourceSchema = z name: z.string().min(1).max(255), niceId: z.string().optional(), // protocol: z.enum(["tcp", "udp"]).optional(), - mode: z.enum(["host", "cidr", "http", "ssh"]), + mode: z.enum(["host", "cidr", "http", "ssh", "inference"]), ssl: z.boolean().optional(), // only used for http mode scheme: z.enum(["http", "https"]).optional(), siteIds: z.array(z.int()).optional(), @@ -171,6 +171,9 @@ const createSiteResourceSchema = z ) .refine( (data) => { + if (data.mode == "inference") { + return true; + } return ( (data.siteIds !== undefined && data.siteIds.length > 0) || data.siteId !== undefined @@ -533,21 +536,24 @@ export async function createSiteResource( let newSiteResource: SiteResource | undefined; try { await db.transaction(async (trx) => { - const [network] = await trx - .insert(networks) - .values({ - scope: "resource", - orgId: orgId - }) - .returning(); + let network: typeof networks.$inferSelect | undefined; + if (mode !== "inference") { + [network] = await trx + .insert(networks) + .values({ + scope: "resource", + orgId: orgId + }) + .returning(); - if (!network) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - `Failed to create network` - ) - ); + if (!network) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Failed to create network` + ) + ); + } } let tcpPortRangeStringAdjusted = tcpPortRangeString; @@ -566,7 +572,7 @@ export async function createSiteResource( name, mode, ssl, - networkId: network.networkId, + networkId: network ? network.networkId : null, destination: destination, // the ssh can be null scheme, destinationPort, @@ -582,7 +588,8 @@ export async function createSiteResource( (mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false domainId, subdomain: finalSubdomain, - fullDomain + fullDomain, + requiresExitNodeConnection: mode === "inference" // in the future we might want to have different modes that do this }; if (isLicensedSshPam) { if (authDaemonPort !== undefined) @@ -600,11 +607,13 @@ export async function createSiteResource( //////////////////// update the associations //////////////////// - for (const siteId of siteIds) { - await trx.insert(siteNetworks).values({ - siteId: siteId, - networkId: network.networkId - }); + if (network) { + for (const siteId of siteIds) { + await trx.insert(siteNetworks).values({ + siteId: siteId, + networkId: network.networkId + }); + } } const [adminRole] = await trx diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 7b0ef0783..1b79e7b16 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -50,7 +50,7 @@ const updateSiteResourceSchema = z ) .optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), - mode: z.enum(["host", "cidr", "http", "ssh"]).optional(), + mode: z.enum(["host", "cidr", "http", "ssh", "inference"]).optional(), ssl: z.boolean().optional(), scheme: z.enum(["http", "https"]).nullish(), destinationPort: z.int().positive().nullish(), @@ -173,6 +173,9 @@ const updateSiteResourceSchema = z ) .refine( (data) => { + if (data.mode == "inference") { + return true; + } // if neither is provided, the existing site associations are left unchanged if (data.siteIds === undefined && data.siteId === undefined) { return true; @@ -579,13 +582,15 @@ export async function updateSiteResource( disableIcmp: mode !== undefined ? disableIcmp || - (mode == "http" || mode == "ssh" - ? true - : false) + (mode == "http" || mode == "ssh" ? true : false) : disableIcmp, domainId, subdomain: finalSubdomain, fullDomain, + networkId: + mode === "inference" ? null : undefined, + requiresExitNodeConnection: + mode !== undefined ? mode === "inference" : undefined, ...sshPamSet }) .where(and(eq(siteResources.siteResourceId, siteResourceId))) @@ -593,7 +598,20 @@ export async function updateSiteResource( //////////////////// update the associations //////////////////// - if (siteIds !== undefined) { + if (mode === "inference") { + // inference resources are not attached to any site network + if (existingSiteResource.networkId) { + await trx + .delete(siteNetworks) + .where( + eq( + siteNetworks.networkId, + existingSiteResource.networkId + ) + ); + } + updatedSiteIds = []; + } else if (siteIds !== undefined) { // delete the site - site resources associations await trx .delete(siteNetworks) From a790fed297a0f8ae4f09075c99a4b8132cf808f4 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 31 Jul 2026 11:30:32 -0400 Subject: [PATCH 171/494] sync the exit node connection --- server/lib/rebuildClientAssociations.ts | 219 +++++++++++++++++++++++- 1 file changed, 217 insertions(+), 2 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index f8b83f5f7..ce0897aab 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -19,7 +19,7 @@ import { userOrgRoles, userSiteResources } from "@server/db"; -import { and, count, eq, inArray, ne } from "drizzle-orm"; +import { and, count, eq, inArray, isNotNull, ne } from "drizzle-orm"; import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers"; import { @@ -27,6 +27,9 @@ import { deletePeersBatch as olmDeletePeersBatch } from "@server/routers/olm/peers"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; +import { sendToClientsBatch } from "#dynamic/routers/ws"; +import { canCompress } from "@server/lib/clientVersionChecks"; +import config from "@server/lib/config"; import logger from "@server/logger"; import { generateAliasConfig, @@ -187,7 +190,12 @@ export async function getClientSiteResourceAccess( `rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]` ); - if (sitesList.length === 0) { + if (sitesList.length === 0 && siteResource.networkId !== null) { + // A site resource with a networkId is expected to have at least one + // site attached via siteNetworks. Resources with no networkId (e.g. + // inference-mode resources, which connect clients directly to the + // exit node instead of any site) are expected to have no sites, so + // don't warn for those. logger.warn( `No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}` ); @@ -687,6 +695,22 @@ async function rebuildClientAssociationsFromSiteResourceImpl( clientSiteResourcesToRemove, trx ); + + // If this resource requires clients to be connected to the exit node + // (e.g. an inference resource), re-sync the connect/disconnect state for + // every client whose access to it may have changed - both those who + // currently have access and those who just lost it. + if (siteResource.requiresExitNodeConnection) { + await syncClientExitNodeConnections( + Array.from( + new Set([ + ...mergedAllClientIds, + ...existingClientSiteResourceIds + ]) + ), + trx + ); + } } async function handleMessagesForSiteClients( @@ -1052,6 +1076,179 @@ export async function updateClientSiteDestinations( } } +// Determines, for each of the given clients, whether they currently have +// access to any enabled site resource with requiresExitNodeConnection set +// (e.g. an inference-mode resource) and tells the client's olm to connect to +// or disconnect from its assigned exit node accordingly. Site resources with +// requiresExitNodeConnection don't belong to any site/network, so this can't +// be derived from the per-site peer logic above - it has to be recomputed +// from the client's full current resource access every time that access +// changes. +async function syncClientExitNodeConnections( + clientIds: number[], + trx: Transaction | typeof db = db +): Promise { + const uniqueClientIds = Array.from(new Set(clientIds)); + if (uniqueClientIds.length === 0) { + return; + } + + // Only clients with an exit node assigned can be told to connect/disconnect. + const clientsData = await trx + .select({ + clientId: clients.clientId, + exitNodeId: clients.exitNodeId, + exitNodeSubnet: clients.exitNodeSubnet + }) + .from(clients) + .where( + and( + inArray(clients.clientId, uniqueClientIds), + isNotNull(clients.exitNodeId) + ) + ); + + if (clientsData.length === 0) { + return; + } + + const clientIdsWithExitNode = clientsData.map((c) => c.clientId); + + const requiresExitNodeRows = await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId + }) + .from(clientSiteResourcesAssociationsCache) + .innerJoin( + siteResources, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .where( + and( + inArray( + clientSiteResourcesAssociationsCache.clientId, + clientIdsWithExitNode + ), + eq(siteResources.enabled, true), + eq(siteResources.requiresExitNodeConnection, true) + ) + ); + + const needsConnectSet = new Set(requiresExitNodeRows.map((r) => r.clientId)); + + const exitNodeIds = Array.from( + new Set( + clientsData + .map((c) => c.exitNodeId) + .filter((id): id is number => id !== null) + ) + ); + + const exitNodeRows = + exitNodeIds.length > 0 + ? await trx + .select() + .from(exitNodes) + .where(inArray(exitNodes.exitNodeId, exitNodeIds)) + : []; + const exitNodeById = new Map(exitNodeRows.map((n) => [n.exitNodeId, n])); + + const olmRows = await trx + .select({ + clientId: olms.clientId, + olmId: olms.olmId, + version: olms.version + }) + .from(olms) + .where(inArray(olms.clientId, clientIdsWithExitNode)); + const olmByClientId = new Map( + olmRows + .filter((r) => r.clientId !== null) + .map((r) => [r.clientId as number, r]) + ); + + const relayPort = config.getRawConfig().gerbil.clients_start_port; + + const connectPayloads: { + clientId: string; + message: { type: string; data: any }; + options: { compress: boolean }; + }[] = []; + const disconnectPayloads: { + clientId: string; + message: { type: string; data: any }; + options: { compress: boolean }; + }[] = []; + + for (const client of clientsData) { + const olm = olmByClientId.get(client.clientId); + if (!olm) { + // No olm registered for this client yet/anymore, nothing to send. + continue; + } + + const needsConnect = needsConnectSet.has(client.clientId); + + if (needsConnect) { + const exitNode = client.exitNodeId + ? exitNodeById.get(client.exitNodeId) + : undefined; + if (!exitNode || !client.exitNodeSubnet) { + logger.warn( + `rebuildClientAssociations: [syncClientExitNodeConnections] client ${client.clientId} needs an exit node connection but has no exit node or subnet assigned` + ); + continue; + } + + connectPayloads.push({ + clientId: olm.olmId, + message: { + type: "olm/wg/exitnode/connect", + data: { + connect: true, + endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, + relayPort, + publicKey: exitNode.publicKey, + serverIP: exitNode.address.split("/")[0], + tunnelIP: client.exitNodeSubnet.split("/")[0] + } + }, + options: { compress: canCompress(olm.version, "olm") } + }); + } else { + disconnectPayloads.push({ + clientId: olm.olmId, + message: { + type: "olm/wg/exitnode/disconnect", + data: {} + }, + options: { compress: canCompress(olm.version, "olm") } + }); + } + } + + if (connectPayloads.length > 0) { + await sendToClientsBatch(connectPayloads).catch((error) => { + logger.error( + `rebuildClientAssociations: Error sending exit node connect messages:`, + error + ); + }); + } + + if (disconnectPayloads.length > 0) { + await sendToClientsBatch(disconnectPayloads).catch((error) => { + logger.error( + `rebuildClientAssociations: Error sending exit node disconnect messages:`, + error + ); + }); + } +} + async function handleSubnetProxyTargetUpdates( siteResource: SiteResource, sitesList: Site[], @@ -1709,6 +1906,20 @@ export async function handleMessagingForUpdatedSiteResource( ); } + // If this resource requires (or required) clients to be connected to the + // exit node (e.g. an inference resource), re-sync connect/disconnect + // state for every client currently associated with it - covers toggling + // requiresExitNodeConnection on update as well as enabling/disabling it. + if ( + updatedSiteResource.requiresExitNodeConnection || + existingSiteResource?.requiresExitNodeConnection + ) { + await syncClientExitNodeConnections( + mergedAllClients.map((c) => c.clientId), + trx + ); + } + logger.debug( `handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}` ); @@ -1990,6 +2201,10 @@ async function rebuildClientAssociationsFromClientImpl( resourcesToRemove, trx ); + + // Re-sync exit node connect/disconnect state based on this client's + // current full set of resource access (e.g. inference resources). + await syncClientExitNodeConnections([client.clientId], trx); } async function handleMessagesForClientSites( From aad2ed2719caeea5359d785eddb5ca04467a9070 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 31 Jul 2026 15:17:16 -0400 Subject: [PATCH 172/494] Get exit node every time --- .../routers/olm/handleOlmRegisterMessage.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 7fa270fa2..374c37631 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -1,4 +1,4 @@ -import { db, ExitNode, orgs, primaryDb } from "@server/db"; +import { db, ExitNode, exitNodes, orgs, primaryDb } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, @@ -311,15 +311,14 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { } let clientSubnet = client.exitNodeSubnet; - let exitNode: ExitNode | null = null; if ( exitNodeId && (client.exitNodeId !== exitNodeId || !client.exitNodeSubnet) ) { - const { exitNode: exitNodeResult, hasAccess } = - await verifyExitNodeOrgAccess(exitNodeId, client.orgId); - - exitNode = exitNodeResult; + const { exitNode, hasAccess } = await verifyExitNodeOrgAccess( + exitNodeId, + client.orgId + ); if (!exitNode) { logger.warn("[handleOlmRegisterMessage] Exit node not found", { @@ -451,6 +450,15 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { return; } + let exitNode: ExitNode | null = null; + if (exitNodeId) { + [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, exitNodeId)) + .limit(1); + } + // NOTE: its important that the client here is the old client and the public key is the new key await waitForClientRebuildIdle(olm.clientId); From 32ac8db8032f2b8c6d2a1b2551d87d27645f09c2 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 31 Jul 2026 16:21:55 -0400 Subject: [PATCH 173/494] Handle aliases when registering --- server/lib/ip.ts | 29 ++++++++++++----- server/routers/olm/buildConfiguration.ts | 31 +++++++++++++------ .../routers/olm/handleOlmRegisterMessage.ts | 12 ++++--- 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index e8f589424..bb21d28d6 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -528,7 +528,10 @@ export function generateRemoteSubnets( export type Alias = { alias: string | null; aliasAddress: string | null }; -export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { +export function generateAliasConfig( + allSiteResources: SiteResource[], + overrideIp?: string +): Alias[] { return allSiteResources .filter( (sr) => @@ -539,7 +542,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { ) .map((sr) => ({ alias: sr.alias || sr.fullDomain, - aliasAddress: sr.aliasAddress + aliasAddress: overrideIp || sr.aliasAddress })); } @@ -660,9 +663,10 @@ export type CertRef = { id: string; cert: string; key: string }; * certificate (e.g. a wildcard cert used by thousands of site resources) * only need that certificate sent once per sync message. */ -export function dedupeCertsForTargets( - targetsV2: SubnetProxyTargetV2[] -): { targets: SubnetProxyTargetV2[]; certs: CertRef[] } { +export function dedupeCertsForTargets(targetsV2: SubnetProxyTargetV2[]): { + targets: SubnetProxyTargetV2[]; + certs: CertRef[]; +} { const idByContent = new Map(); const certs: CertRef[] = []; @@ -674,7 +678,10 @@ export function dedupeCertsForTargets( const contentKey = `${target.tlsCert}|${target.tlsKey}`; let id = idByContent.get(contentKey); if (!id) { - id = createHash("sha1").update(contentKey).digest("hex").slice(0, 16); + id = createHash("sha1") + .update(contentKey) + .digest("hex") + .slice(0, 16); idByContent.set(contentKey, id); certs.push({ id, cert: target.tlsCert, key: target.tlsKey }); } @@ -708,7 +715,9 @@ export async function batchFetchCertsForSiteResources( ): Promise { const domains = new Set( allSiteResources - .filter((r) => r.enabled && r.mode === "http" && r.ssl && r.fullDomain) + .filter( + (r) => r.enabled && r.mode === "http" && r.ssl && r.fullDomain + ) .map((r) => r.fullDomain as string) ); @@ -852,7 +861,11 @@ export async function generateSubnetProxyTargetV2( new Set([siteResource.fullDomain]), true ); - if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) { + if ( + certs.length > 0 && + certs[0].certFile && + certs[0].keyFile + ) { tlsCert = certs[0].certFile; tlsKey = certs[0].keyFile; } else { diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index 736363a5f..af309f89a 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -19,6 +19,7 @@ import logger from "@server/logger"; import { and, eq, inArray } from "drizzle-orm"; import { addPeer, deletePeer } from "../newt/peers"; import config from "@server/lib/config"; +import { SiR } from "react-icons/si"; export async function buildSiteConfigurationForOlmClient( client: Client, @@ -38,6 +39,8 @@ export async function buildSiteConfigurationForOlmClient( aliases: Alias[]; }[] = []; + let exitNodeAliases: string[] = []; + // Get all sites data const sitesData = await db .select() @@ -76,16 +79,12 @@ export async function buildSiteConfigurationForOlmClient( ) ); - const haveInferenceResources = allClientSiteResources.some( - (row) => row.siteResource.requiresExitNodeConnection === true - ); - - if (sitesData.length === 0) { - return { siteConfigurations, haveInferenceResources }; - } - const siteResourcesBySiteId = new Map(); + let siteResourcesForExitNode = []; for (const row of allClientSiteResources) { + if (row.siteResource.requiresExitNodeConnection) { + siteResourcesForExitNode.push(row.siteResource); + } if (!row.siteId) { // because we are doing a leftJoin above to get the inference resources without a network / sites continue; @@ -98,6 +97,17 @@ export async function buildSiteConfigurationForOlmClient( } } + exitNodeAliases = siteResourcesForExitNode + .map((sr) => sr.alias) + .filter((a) => a != null); + + if (sitesData.length == 0) { + return { + siteConfigurations, + exitNodeAliases + }; + } + // Batch-fetch exit nodes for all sites in one query (only needed in relay mode). const exitNodesById = new Map(); if (!jitMode && relay) { @@ -234,5 +244,8 @@ export async function buildSiteConfigurationForOlmClient( }); } - return { siteConfigurations, haveInferenceResources }; + return { + siteConfigurations, + exitNodeAliases + }; } diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 374c37631..95c5e0d47 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -462,7 +462,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { // NOTE: its important that the client here is the old client and the public key is the new key await waitForClientRebuildIdle(olm.clientId); - const { siteConfigurations, haveInferenceResources } = + const { siteConfigurations, exitNodeAliases } = await buildSiteConfigurationForOlmClient( client, publicKey, @@ -470,6 +470,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { jitMode ); + logger.info( + `+++++++++++++++++++++++++++++++ ExitNode Aliases: ${exitNodeAliases}` + ); + // Return connect message with all site configurations return { message: { @@ -481,11 +485,9 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { exitNode: exitNode && client.exitNodeSubnet ? { - connect: haveInferenceResources, // we do not need to connect to the exit node if we do not have inference resources + aliases: exitNodeAliases, + connect: exitNodeAliases.length > 0, // we do not need to connect to the exit node if we do not have inference resources and right now all site resources on the exit node have an alias endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, - relayPort: - config.getRawConfig().gerbil - .clients_start_port, publicKey: exitNode.publicKey, serverIP: exitNode.address.split("/")[0], tunnelIP: client.exitNodeSubnet.split("/")[0] From 730078597e9921725f59b2fb87f0323db51c38be Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 31 Jul 2026 15:23:19 -0400 Subject: [PATCH 174/494] add basic schema --- server/db/pg/schema/schema.ts | 43 +++++++++++++++++++++++++++++ server/db/sqlite/schema/schema.ts | 46 +++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 9f74788d7..83f1e19bd 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1547,6 +1547,47 @@ export const statusHistory = pgTable( ] ); +export const aiProviders = pgTable("aiProviders", { + providerId: serial("providerId").primaryKey(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + name: varchar("name").notNull(), + type: varchar("type") + .$type<"openai" | "anthropic" | "bedrock" | "custom">() + .notNull(), + upstreamUrl: text("upstreamUrl"), + apiKey: text("apiKey"), + apiKeyLastChars: varchar("apiKeyLastChars"), + authType: varchar("authType").$type<"bearer">(), + skipTlsVerification: boolean("skipTlsVerification") + .notNull() + .default(false), + budgetAmount: real("budgetAmount"), + budgetUnit: varchar("budgetUnit").$type<"usd" | "tokens">(), + enabled: boolean("enabled").notNull().default(true), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull() +}); + +export const aiModels = pgTable( + "aiModels", + { + modelId: serial("modelId").primaryKey(), + providerId: integer("providerId") + .notNull() + .references(() => aiProviders.providerId, { onDelete: "cascade" }), + modelKey: varchar("modelKey").notNull(), + name: varchar("name").notNull(), + budgetAmount: real("budgetAmount"), + budgetUnit: varchar("budgetUnit").$type<"usd" | "tokens">(), + enabled: boolean("enabled").notNull().default(true), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull() + }, + (t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)] +); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -1631,3 +1672,5 @@ export type ResourcePolicy = InferSelectModel; export type RolePolicy = InferSelectModel; export type UserPolicy = InferSelectModel; export type ResourcePolicyRule = InferSelectModel; +export type AiProvider = InferSelectModel; +export type AiModel = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index ec1b9e6f4..97059d3cb 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -4,6 +4,7 @@ import { index, integer, primaryKey, + real, sqliteTable, text, unique @@ -1537,6 +1538,49 @@ export const statusHistory = sqliteTable( ] ); +export const aiProviders = sqliteTable("aiProviders", { + providerId: integer("providerId").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + name: text("name").notNull(), + type: text("type") + .$type<"openai" | "anthropic" | "bedrock" | "custom">() + .notNull(), + upstreamUrl: text("upstreamUrl"), + apiKey: text("apiKey"), + apiKeyLastChars: text("apiKeyLastChars"), + authType: text("authType").$type<"bearer">(), + skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" }) + .notNull() + .default(false), + budgetAmount: real("budgetAmount"), + budgetUnit: text("budgetUnit").$type<"usd" | "tokens">(), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt").notNull() +}); + +export const aiModels = sqliteTable( + "aiModels", + { + modelId: integer("modelId").primaryKey({ autoIncrement: true }), + providerId: integer("providerId") + .notNull() + .references(() => aiProviders.providerId, { onDelete: "cascade" }), + modelKey: text("modelKey").notNull(), + name: text("name").notNull(), + budgetAmount: real("budgetAmount"), + budgetUnit: text("budgetUnit").$type<"usd" | "tokens">(), + enabled: integer("enabled", { mode: "boolean" }) + .notNull() + .default(true), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt").notNull() + }, + (t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)] +); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -1619,3 +1663,5 @@ export type ResourcePolicyHeaderAuth = InferSelectModel< >; export type RolePolicy = InferSelectModel; export type UserPolicy = InferSelectModel; +export type AiProvider = InferSelectModel; +export type AiModel = InferSelectModel; From 694fe98131f1a839ffdc8d72c2fc64447f8a3193 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 31 Jul 2026 15:44:17 -0400 Subject: [PATCH 175/494] add crud for providers and models --- messages/en-US.json | 10 + server/auth/actions.ts | 12 +- server/index.ts | 4 + server/lib/aiProviderDefaults.ts | 48 ++++ server/middlewares/index.ts | 2 + server/middlewares/integration/index.ts | 2 + .../integration/verifyApiKeyAiModelAccess.ts | 113 ++++++++++ .../verifyApiKeyAiProviderAccess.ts | 115 ++++++++++ server/middlewares/verifyAiModelAccess.ts | 125 +++++++++++ server/middlewares/verifyAiProviderAccess.ts | 108 +++++++++ server/openApi.ts | 4 +- server/routers/aiProvider/createAiModel.ts | 157 +++++++++++++ server/routers/aiProvider/createAiProvider.ts | 141 ++++++++++++ server/routers/aiProvider/deleteAiModel.ts | 98 +++++++++ server/routers/aiProvider/deleteAiProvider.ts | 92 ++++++++ server/routers/aiProvider/getAiModel.ts | 94 ++++++++ server/routers/aiProvider/getAiProvider.ts | 88 ++++++++ server/routers/aiProvider/index.ts | 11 + server/routers/aiProvider/listAiModels.ts | 166 ++++++++++++++ server/routers/aiProvider/listAiProviders.ts | 152 +++++++++++++ server/routers/aiProvider/types.ts | 51 +++++ server/routers/aiProvider/updateAiModel.ts | 185 ++++++++++++++++ server/routers/aiProvider/updateAiProvider.ts | 208 ++++++++++++++++++ server/routers/aiProvider/validation.ts | 66 ++++++ server/routers/external.ts | 89 +++++++- server/routers/integration.ts | 87 ++++++++ src/components/PermissionsSelectBox.tsx | 16 ++ 27 files changed, 2241 insertions(+), 3 deletions(-) create mode 100644 server/lib/aiProviderDefaults.ts create mode 100644 server/middlewares/integration/verifyApiKeyAiModelAccess.ts create mode 100644 server/middlewares/integration/verifyApiKeyAiProviderAccess.ts create mode 100644 server/middlewares/verifyAiModelAccess.ts create mode 100644 server/middlewares/verifyAiProviderAccess.ts create mode 100644 server/routers/aiProvider/createAiModel.ts create mode 100644 server/routers/aiProvider/createAiProvider.ts create mode 100644 server/routers/aiProvider/deleteAiModel.ts create mode 100644 server/routers/aiProvider/deleteAiProvider.ts create mode 100644 server/routers/aiProvider/getAiModel.ts create mode 100644 server/routers/aiProvider/getAiProvider.ts create mode 100644 server/routers/aiProvider/index.ts create mode 100644 server/routers/aiProvider/listAiModels.ts create mode 100644 server/routers/aiProvider/listAiProviders.ts create mode 100644 server/routers/aiProvider/types.ts create mode 100644 server/routers/aiProvider/updateAiModel.ts create mode 100644 server/routers/aiProvider/updateAiProvider.ts create mode 100644 server/routers/aiProvider/validation.ts diff --git a/messages/en-US.json b/messages/en-US.json index aff3be28b..24d10ab95 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1424,6 +1424,16 @@ "actionDeleteSite": "Delete Site", "actionGetSite": "Get Site", "actionListSites": "List Sites", + "actionCreateAiProvider": "Create AI Provider", + "actionDeleteAiProvider": "Delete AI Provider", + "actionGetAiProvider": "Get AI Provider", + "actionListAiProviders": "List AI Providers", + "actionUpdateAiProvider": "Update AI Provider", + "actionCreateAiModel": "Create AI Model", + "actionDeleteAiModel": "Delete AI Model", + "actionGetAiModel": "Get AI Model", + "actionListAiModels": "List AI Models", + "actionUpdateAiModel": "Update AI Model", "actionApplyBlueprint": "Apply Blueprint", "actionListBlueprints": "List Blueprints", "actionGetBlueprint": "Get Blueprint", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 741d7a057..944adbff4 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -182,7 +182,17 @@ export enum ActionsEnum { setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth", setResourcePolicyWhitelist = "setResourcePolicyWhitelist", setResourcePolicyRules = "setResourcePolicyRules", - createOrgWideLauncherView = "createOrgWideLauncherView" + createOrgWideLauncherView = "createOrgWideLauncherView", + createAiProvider = "createAiProvider", + deleteAiProvider = "deleteAiProvider", + getAiProvider = "getAiProvider", + listAiProviders = "listAiProviders", + updateAiProvider = "updateAiProvider", + createAiModel = "createAiModel", + deleteAiModel = "deleteAiModel", + getAiModel = "getAiModel", + listAiModels = "listAiModels", + updateAiModel = "updateAiModel" } export async function checkUserActionPermission( diff --git a/server/index.ts b/server/index.ts index 53b3e9a69..88612b86a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -9,6 +9,8 @@ import { createIntegrationApiServer } from "./integrationApiServer"; import { ApiKey, ApiKeyOrg, + AiModel, + AiProvider, RemoteExitNode, Session, SiteResource, @@ -83,6 +85,8 @@ declare global { userOrgIds?: string[]; remoteExitNode?: RemoteExitNode; siteResource?: SiteResource; + aiProvider?: AiProvider; + aiModel?: AiModel; orgPolicyAllowed?: boolean; } } diff --git a/server/lib/aiProviderDefaults.ts b/server/lib/aiProviderDefaults.ts new file mode 100644 index 000000000..d89be99a0 --- /dev/null +++ b/server/lib/aiProviderDefaults.ts @@ -0,0 +1,48 @@ +export type AiProviderType = "openai" | "anthropic" | "bedrock" | "custom"; +export type AiProviderAuthType = "bearer"; +export type AiBudgetUnit = "usd" | "tokens"; + +type AiProviderDefaults = { + upstreamUrl: string; + authType: AiProviderAuthType; +}; + +export const AI_PROVIDER_DEFAULTS: Record< + Exclude, + AiProviderDefaults +> = { + openai: { + upstreamUrl: "https://api.openai.com/v1", + authType: "bearer" + }, + anthropic: { + upstreamUrl: "https://api.anthropic.com", + authType: "bearer" + }, + bedrock: { + upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + authType: "bearer" + } +}; + +export function resolveAiProviderConfig(input: { + type: AiProviderType; + upstreamUrl: string | null; + authType: AiProviderAuthType | null; +}): { + upstreamUrl: string | null; + authType: AiProviderAuthType | null; +} { + if (input.type === "custom") { + return { + upstreamUrl: input.upstreamUrl, + authType: input.authType + }; + } + + const defaults = AI_PROVIDER_DEFAULTS[input.type]; + return { + upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl, + authType: input.authType ?? defaults.authType + }; +} diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index a7f3ae125..8f6b50e33 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -27,6 +27,8 @@ export * from "./verifyUserHasAction"; export * from "./verifyApiKeyAccess"; export * from "./verifySiteProvisioningKeyAccess"; export * from "./verifyDomainAccess"; +export * from "./verifyAiProviderAccess"; +export * from "./verifyAiModelAccess"; export * from "./verifyUserIsOrgOwner"; export * from "./verifyUserFromResourceSession"; export * from "./verifySiteResourceAccess"; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index be9d400cf..63df4b2bb 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -16,5 +16,7 @@ export * from "./verifyApiKeyClientAccess"; export * from "./verifyApiKeySiteResourceAccess"; export * from "./verifyApiKeyIdpAccess"; export * from "./verifyApiKeyDomainAccess"; +export * from "./verifyApiKeyAiProviderAccess"; +export * from "./verifyApiKeyAiModelAccess"; export * from "./verifyApiKeyResourcePolicyAccess"; export * from "./verifyApiKeySiteProvisioningKeyAccess"; diff --git a/server/middlewares/integration/verifyApiKeyAiModelAccess.ts b/server/middlewares/integration/verifyApiKeyAiModelAccess.ts new file mode 100644 index 000000000..75a56f37e --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyAiModelAccess.ts @@ -0,0 +1,113 @@ +import { Request, Response, NextFunction } from "express"; +import { aiModels, aiProviders, apiKeyOrg, db } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyApiKeyAiModelAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const modelIdRaw = getFirstString(req.params.modelId); + const modelId = Number.parseInt(modelIdRaw ?? "", 10); + const providerIdRaw = getFirstString(req.params.providerId); + const providerId = Number.parseInt(providerIdRaw ?? "", 10); + const orgId = getFirstString(req.params.orgId); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (Number.isNaN(providerId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") + ); + } + + if (Number.isNaN(modelId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID") + ); + } + + const [row] = await db + .select({ + model: aiModels, + provider: aiProviders + }) + .from(aiModels) + .innerJoin( + aiProviders, + eq(aiModels.providerId, aiProviders.providerId) + ) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!row) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI model with ID ${modelId} not found` + ) + ); + } + + if (apiKey.isRoot) { + req.aiProvider = row.provider; + req.aiModel = row.model; + return next(); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + req.aiProvider = row.provider; + req.aiModel = row.model; + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying AI model access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts b/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts new file mode 100644 index 000000000..6b9f9b0ec --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts @@ -0,0 +1,115 @@ +import { Request, Response, NextFunction } from "express"; +import { aiProviders, apiKeyOrg, db } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyApiKeyAiProviderAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const providerIdRaw = getFirstString(req.params.providerId); + const providerId = Number.parseInt(providerIdRaw ?? "", 10); + const orgId = getFirstString(req.params.orgId); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (Number.isNaN(providerId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") + ); + } + + if (apiKey.isRoot) { + const [provider] = await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + req.aiProvider = provider; + return next(); + } + + const [provider] = await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + req.aiProvider = provider; + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying AI provider access" + ) + ); + } +} diff --git a/server/middlewares/verifyAiModelAccess.ts b/server/middlewares/verifyAiModelAccess.ts new file mode 100644 index 000000000..99693b982 --- /dev/null +++ b/server/middlewares/verifyAiModelAccess.ts @@ -0,0 +1,125 @@ +import { Request, Response, NextFunction } from "express"; +import { aiModels, aiProviders, db, userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyAiModelAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const modelIdRaw = getFirstString(req.params.modelId); + const modelId = Number.parseInt(modelIdRaw ?? "", 10); + const providerIdRaw = getFirstString(req.params.providerId); + const providerId = Number.parseInt(providerIdRaw ?? "", 10); + const orgId = getFirstString(req.params.orgId); + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (Number.isNaN(providerId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") + ); + } + + if (Number.isNaN(modelId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID") + ); + } + + const [row] = await db + .select({ + model: aiModels, + provider: aiProviders + }) + .from(aiModels) + .innerJoin( + aiProviders, + eq(aiModels.providerId, aiProviders.providerId) + ) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!row) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI model with ID ${modelId} not found` + ) + ); + } + + if (!req.userOrg) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "" + (policyCheck.error || "Unknown error") + ) + ); + } + } + + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); + req.aiProvider = row.provider; + req.aiModel = row.model; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying AI model access" + ) + ); + } +} diff --git a/server/middlewares/verifyAiProviderAccess.ts b/server/middlewares/verifyAiProviderAccess.ts new file mode 100644 index 000000000..604e3e893 --- /dev/null +++ b/server/middlewares/verifyAiProviderAccess.ts @@ -0,0 +1,108 @@ +import { Request, Response, NextFunction } from "express"; +import { aiProviders, db, userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyAiProviderAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const providerIdRaw = getFirstString(req.params.providerId); + const providerId = Number.parseInt(providerIdRaw ?? "", 10); + const orgId = getFirstString(req.params.orgId); + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (Number.isNaN(providerId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") + ); + } + + const [provider] = await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + if (!req.userOrg) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "" + (policyCheck.error || "Unknown error") + ) + ); + } + } + + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); + req.aiProvider = provider; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying AI provider access" + ) + ); + } +} diff --git a/server/openApi.ts b/server/openApi.ts index 27c26b2da..1bcaa23ab 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -28,7 +28,9 @@ export enum OpenAPITags { HealthCheck = "Health Check", PublicResourcePolicyLegacy = "Public Resource Policy (Legacy)", PublicResourceLegacy = "Public Resource (Legacy)", - PrivateResourceLegacy = "Private Resource (Legacy)" + PrivateResourceLegacy = "Private Resource (Legacy)", + AiProvider = "AI Provider", + AiModel = "AI Model" } // Order here controls the order tags are displayed in Swagger UI diff --git a/server/routers/aiProvider/createAiModel.ts b/server/routers/aiProvider/createAiModel.ts new file mode 100644 index 000000000..a770a5ed8 --- /dev/null +++ b/server/routers/aiProvider/createAiModel.ts @@ -0,0 +1,157 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiModels, aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types"; +import { + aiBudgetUnitSchema, + refineBudgetFields +} from "@server/routers/aiProvider/validation"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive() +}); + +const bodySchema = z + .strictObject({ + modelKey: z.string().nonempty(), + name: z.string().nonempty(), + budgetAmount: z.number().positive().optional().nullable(), + budgetUnit: aiBudgetUnitSchema.optional().nullable(), + enabled: z.boolean().optional() + }) + .superRefine((data, ctx) => { + refineBudgetFields(data, ctx); + }); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/ai-provider/{providerId}/model", + description: "Create an AI model under a provider.", + tags: [OpenAPITags.AiModel], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 201: { + description: "Successful response" + } + } +}); + +export async function createAiModel( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, providerId } = parsedParams.data; + const { modelKey, name, budgetAmount, budgetUnit, enabled } = + parsedBody.data; + + const [provider] = + req.aiProvider && req.aiProvider.providerId === providerId + ? [req.aiProvider] + : await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + const [existing] = await db + .select({ modelId: aiModels.modelId }) + .from(aiModels) + .where( + and( + eq(aiModels.providerId, providerId), + eq(aiModels.modelKey, modelKey) + ) + ) + .limit(1); + + if (existing) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Model with key ${modelKey} already exists for this provider` + ) + ); + } + + const now = Date.now(); + const [model] = await db + .insert(aiModels) + .values({ + providerId, + modelKey, + name, + budgetAmount: budgetAmount ?? null, + budgetUnit: budgetUnit ?? null, + enabled: enabled ?? true, + createdAt: now, + updatedAt: now + }) + .returning(); + + return response(res, { + data: { model }, + success: true, + error: false, + message: "AI model created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts new file mode 100644 index 000000000..dad11d18d --- /dev/null +++ b/server/routers/aiProvider/createAiProvider.ts @@ -0,0 +1,141 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; +import { toPublicAiProvider } from "@server/routers/aiProvider/types"; +import { + aiAuthTypeSchema, + aiBudgetUnitSchema, + aiProviderTypeSchema, + refineBudgetFields, + refineCustomProviderFields +} from "@server/routers/aiProvider/validation"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z + .strictObject({ + name: z.string().nonempty(), + type: aiProviderTypeSchema, + upstreamUrl: z.url().optional().nullable(), + apiKey: z.string().optional(), + authType: aiAuthTypeSchema.optional().nullable(), + skipTlsVerification: z.boolean().optional(), + budgetAmount: z.number().positive().optional().nullable(), + budgetUnit: aiBudgetUnitSchema.optional().nullable(), + enabled: z.boolean().optional() + }) + .superRefine((data, ctx) => { + refineCustomProviderFields(data, ctx); + refineBudgetFields(data, ctx); + }); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/ai-provider", + description: "Create an AI provider for an organization.", + tags: [OpenAPITags.AiProvider], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 201: { + description: "Successful response" + } + } +}); + +export async function createAiProvider( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { + name, + type, + upstreamUrl, + apiKey, + authType, + skipTlsVerification, + budgetAmount, + budgetUnit, + enabled + } = parsedBody.data; + + const key = config.getRawConfig().server.secret!; + const encryptedApiKey = apiKey ? encrypt(apiKey, key) : null; + const apiKeyLastChars = apiKey ? apiKey.slice(-4) : null; + const now = Date.now(); + + const [provider] = await db + .insert(aiProviders) + .values({ + orgId, + name, + type, + upstreamUrl: upstreamUrl ?? null, + apiKey: encryptedApiKey, + apiKeyLastChars, + authType: authType ?? null, + skipTlsVerification: skipTlsVerification ?? false, + budgetAmount: budgetAmount ?? null, + budgetUnit: budgetUnit ?? null, + enabled: enabled ?? true, + createdAt: now, + updatedAt: now + }) + .returning(); + + return response(res, { + data: { provider: toPublicAiProvider(provider) }, + success: true, + error: false, + message: "AI provider created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/deleteAiModel.ts b/server/routers/aiProvider/deleteAiModel.ts new file mode 100644 index 000000000..be784275e --- /dev/null +++ b/server/routers/aiProvider/deleteAiModel.ts @@ -0,0 +1,98 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiModels, aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive(), + modelId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/ai-provider/{providerId}/model/{modelId}", + description: "Delete an AI model.", + tags: [OpenAPITags.AiModel], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function deleteAiModel( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, providerId, modelId } = parsedParams.data; + + const [existing] = await db + .select({ modelId: aiModels.modelId }) + .from(aiModels) + .innerJoin( + aiProviders, + eq(aiModels.providerId, aiProviders.providerId) + ) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI model with ID ${modelId} not found` + ) + ); + } + + await db + .delete(aiModels) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, providerId) + ) + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "AI model deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/deleteAiProvider.ts b/server/routers/aiProvider/deleteAiProvider.ts new file mode 100644 index 000000000..b5416c23c --- /dev/null +++ b/server/routers/aiProvider/deleteAiProvider.ts @@ -0,0 +1,92 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/ai-provider/{providerId}", + description: "Delete an AI provider.", + tags: [OpenAPITags.AiProvider], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function deleteAiProvider( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, providerId } = parsedParams.data; + + const [existing] = await db + .select({ providerId: aiProviders.providerId }) + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + await db + .delete(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "AI provider deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/getAiModel.ts b/server/routers/aiProvider/getAiModel.ts new file mode 100644 index 000000000..6ea8795ee --- /dev/null +++ b/server/routers/aiProvider/getAiModel.ts @@ -0,0 +1,94 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiModels, aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import type { GetAiModelResponse } from "@server/routers/aiProvider/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive(), + modelId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/ai-provider/{providerId}/model/{modelId}", + description: "Get an AI model by ID.", + tags: [OpenAPITags.AiModel], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function getAiModel( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, providerId, modelId } = parsedParams.data; + + const [model] = + req.aiModel && req.aiModel.modelId === modelId + ? [req.aiModel] + : await db + .select({ model: aiModels }) + .from(aiModels) + .innerJoin( + aiProviders, + eq(aiModels.providerId, aiProviders.providerId) + ) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1) + .then((rows) => rows.map((r) => r.model)); + + if (!model) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI model with ID ${modelId} not found` + ) + ); + } + + return response(res, { + data: { model }, + success: true, + error: false, + message: "AI model retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/getAiProvider.ts b/server/routers/aiProvider/getAiProvider.ts new file mode 100644 index 000000000..e2abd3948 --- /dev/null +++ b/server/routers/aiProvider/getAiProvider.ts @@ -0,0 +1,88 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import type { GetAiProviderResponse } from "@server/routers/aiProvider/types"; +import { toPublicAiProvider } from "@server/routers/aiProvider/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/ai-provider/{providerId}", + description: "Get an AI provider by ID.", + tags: [OpenAPITags.AiProvider], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function getAiProvider( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, providerId } = parsedParams.data; + + const [provider] = + req.aiProvider && req.aiProvider.providerId === providerId + ? [req.aiProvider] + : await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + return response(res, { + data: { provider: toPublicAiProvider(provider) }, + success: true, + error: false, + message: "AI provider retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/index.ts b/server/routers/aiProvider/index.ts new file mode 100644 index 000000000..34c900139 --- /dev/null +++ b/server/routers/aiProvider/index.ts @@ -0,0 +1,11 @@ +export * from "./createAiProvider"; +export * from "./listAiProviders"; +export * from "./getAiProvider"; +export * from "./updateAiProvider"; +export * from "./deleteAiProvider"; +export * from "./createAiModel"; +export * from "./listAiModels"; +export * from "./getAiModel"; +export * from "./updateAiModel"; +export * from "./deleteAiModel"; +export * from "./types"; diff --git a/server/routers/aiProvider/listAiModels.ts b/server/routers/aiProvider/listAiModels.ts new file mode 100644 index 000000000..a7794efca --- /dev/null +++ b/server/routers/aiProvider/listAiModels.ts @@ -0,0 +1,166 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiModels, aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, asc, eq, like, sql } from "drizzle-orm"; +import type { ListAiModelsResponse } from "@server/routers/aiProvider/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive() +}); + +const listSchema = z.object({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() + .int() + .min(0) + .optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/ai-provider/{providerId}/models", + description: "List AI models for a provider.", + tags: [OpenAPITags.AiModel], + request: { + params: paramsSchema, + query: listSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listAiModels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, providerId } = parsedParams.data; + + const [provider] = + req.aiProvider && req.aiProvider.providerId === providerId + ? [req.aiProvider] + : await db + .select({ providerId: aiProviders.providerId }) + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + const { pageSize, page, query } = parsedQuery.data; + const conditions = [eq(aiModels.providerId, providerId)]; + + if (query) { + conditions.push( + like( + sql`LOWER(${aiModels.name})`, + "%" + query.toLowerCase() + "%" + ) + ); + } + + const baseQuery = db + .select() + .from(aiModels) + .where(and(...conditions)); + + const countQuery = db.$count( + db + .select() + .from(aiModels) + .where(and(...conditions)) + .as("filtered_ai_models") + ); + + const [totalCount, rows] = await Promise.all([ + countQuery, + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(aiModels.name)) + ]); + + return response(res, { + data: { + models: rows, + pagination: { + total: totalCount, + pageSize, + page + } + }, + success: true, + error: false, + message: "AI models retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/listAiProviders.ts b/server/routers/aiProvider/listAiProviders.ts new file mode 100644 index 000000000..7ec89df51 --- /dev/null +++ b/server/routers/aiProvider/listAiProviders.ts @@ -0,0 +1,152 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, asc, eq, like, sql } from "drizzle-orm"; +import type { ListAiProvidersResponse } from "@server/routers/aiProvider/types"; +import { toPublicAiProvider } from "@server/routers/aiProvider/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const listSchema = z.object({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() + .int() + .min(0) + .optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/ai-providers", + description: "List AI providers for an organization.", + tags: [OpenAPITags.AiProvider], + request: { + params: paramsSchema, + query: listSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listAiProviders( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const { pageSize, page, query } = parsedQuery.data; + const conditions = [eq(aiProviders.orgId, orgId)]; + + if (query) { + conditions.push( + like( + sql`LOWER(${aiProviders.name})`, + "%" + query.toLowerCase() + "%" + ) + ); + } + + const baseQuery = db + .select() + .from(aiProviders) + .where(and(...conditions)); + + const countQuery = db.$count( + db + .select() + .from(aiProviders) + .where(and(...conditions)) + .as("filtered_ai_providers") + ); + + const [totalCount, rows] = await Promise.all([ + countQuery, + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(aiProviders.name)) + ]); + + return response(res, { + data: { + providers: rows.map(toPublicAiProvider), + pagination: { + total: totalCount, + pageSize, + page + } + }, + success: true, + error: false, + message: "AI providers retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/types.ts b/server/routers/aiProvider/types.ts new file mode 100644 index 000000000..4b8461b5e --- /dev/null +++ b/server/routers/aiProvider/types.ts @@ -0,0 +1,51 @@ +import type { AiModel, AiProvider } from "@server/db"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { + resolveAiProviderConfig, + type AiProviderAuthType, + type AiProviderType +} from "@server/lib/aiProviderDefaults"; + +export type AiProviderPublic = Omit & { + effectiveUpstreamUrl: string | null; + effectiveAuthType: AiProviderAuthType | null; +}; + +export type ListAiProvidersResponse = PaginatedResponse<{ + providers: AiProviderPublic[]; +}>; + +export type GetAiProviderResponse = { + provider: AiProviderPublic; +}; + +export type CreateOrEditAiProviderResponse = { + provider: AiProviderPublic; +}; + +export type ListAiModelsResponse = PaginatedResponse<{ + models: AiModel[]; +}>; + +export type GetAiModelResponse = { + model: AiModel; +}; + +export type CreateOrEditAiModelResponse = { + model: AiModel; +}; + +export function toPublicAiProvider(provider: AiProvider): AiProviderPublic { + const { apiKey: _apiKey, ...rest } = provider; + const resolved = resolveAiProviderConfig({ + type: provider.type as AiProviderType, + upstreamUrl: provider.upstreamUrl, + authType: provider.authType as AiProviderAuthType | null + }); + + return { + ...rest, + effectiveUpstreamUrl: resolved.upstreamUrl, + effectiveAuthType: resolved.authType + }; +} diff --git a/server/routers/aiProvider/updateAiModel.ts b/server/routers/aiProvider/updateAiModel.ts new file mode 100644 index 000000000..ad09ee823 --- /dev/null +++ b/server/routers/aiProvider/updateAiModel.ts @@ -0,0 +1,185 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiModels, aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq, ne } from "drizzle-orm"; +import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types"; +import { + aiBudgetUnitSchema, + refineBudgetFields +} from "@server/routers/aiProvider/validation"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive(), + modelId: z.coerce.number().int().positive() +}); + +const bodySchema = z + .strictObject({ + modelKey: z.string().nonempty().optional(), + name: z.string().nonempty().optional(), + budgetAmount: z.number().positive().optional().nullable(), + budgetUnit: aiBudgetUnitSchema.optional().nullable(), + enabled: z.boolean().optional() + }) + .superRefine((data, ctx) => { + refineBudgetFields(data, ctx); + }); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/ai-provider/{providerId}/model/{modelId}", + description: "Update an AI model.", + tags: [OpenAPITags.AiModel], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function updateAiModel( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, providerId, modelId } = parsedParams.data; + const body = parsedBody.data; + + const [existing] = + req.aiModel && req.aiModel.modelId === modelId + ? [req.aiModel] + : await db + .select({ model: aiModels }) + .from(aiModels) + .innerJoin( + aiProviders, + eq(aiModels.providerId, aiProviders.providerId) + ) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1) + .then((rows) => rows.map((r) => r.model)); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI model with ID ${modelId} not found` + ) + ); + } + + if ( + body.modelKey !== undefined && + body.modelKey !== existing.modelKey + ) { + const [conflict] = await db + .select({ modelId: aiModels.modelId }) + .from(aiModels) + .where( + and( + eq(aiModels.providerId, providerId), + eq(aiModels.modelKey, body.modelKey), + ne(aiModels.modelId, modelId) + ) + ) + .limit(1); + + if (conflict) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Model with key ${body.modelKey} already exists for this provider` + ) + ); + } + } + + const updateData: Partial = { + updatedAt: Date.now() + }; + + if (body.modelKey !== undefined) { + updateData.modelKey = body.modelKey; + } + if (body.name !== undefined) { + updateData.name = body.name; + } + if (body.budgetAmount !== undefined) { + updateData.budgetAmount = body.budgetAmount; + } + if (body.budgetUnit !== undefined) { + updateData.budgetUnit = body.budgetUnit; + } + if (body.enabled !== undefined) { + updateData.enabled = body.enabled; + } + + const [model] = await db + .update(aiModels) + .set(updateData) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, providerId) + ) + ) + .returning(); + + return response(res, { + data: { model }, + success: true, + error: false, + message: "AI model updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/updateAiProvider.ts b/server/routers/aiProvider/updateAiProvider.ts new file mode 100644 index 000000000..8525e1dd3 --- /dev/null +++ b/server/routers/aiProvider/updateAiProvider.ts @@ -0,0 +1,208 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; +import { toPublicAiProvider } from "@server/routers/aiProvider/types"; +import type { AiProviderType } from "@server/lib/aiProviderDefaults"; +import { + aiAuthTypeSchema, + aiBudgetUnitSchema, + refineBudgetFields, + refineCustomProviderFields +} from "@server/routers/aiProvider/validation"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive() +}); + +const bodySchema = z + .strictObject({ + name: z.string().nonempty().optional(), + upstreamUrl: z.url().optional().nullable(), + apiKey: z.string().optional(), + authType: aiAuthTypeSchema.optional().nullable(), + skipTlsVerification: z.boolean().optional(), + budgetAmount: z.number().positive().optional().nullable(), + budgetUnit: aiBudgetUnitSchema.optional().nullable(), + enabled: z.boolean().optional() + }) + .superRefine((data, ctx) => { + refineBudgetFields(data, ctx); + }); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/ai-provider/{providerId}", + description: "Update an AI provider.", + tags: [OpenAPITags.AiProvider], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function updateAiProvider( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, providerId } = parsedParams.data; + const body = parsedBody.data; + + const [existing] = + req.aiProvider && req.aiProvider.providerId === providerId + ? [req.aiProvider] + : await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + const providerType = existing.type as AiProviderType; + + if (providerType === "custom") { + const nextUpstreamUrl = + body.upstreamUrl !== undefined + ? body.upstreamUrl + : existing.upstreamUrl; + const nextAuthType = + body.authType !== undefined ? body.authType : existing.authType; + + const validation = z + .object({ + type: z.literal("custom"), + upstreamUrl: z.string().nullable().optional(), + authType: aiAuthTypeSchema.nullable().optional() + }) + .superRefine((data, ctx) => + refineCustomProviderFields(data, ctx) + ) + .safeParse({ + type: "custom", + upstreamUrl: nextUpstreamUrl, + authType: nextAuthType + }); + + if (!validation.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(validation.error).toString() + ) + ); + } + } + + const updateData: Partial = { + updatedAt: Date.now() + }; + + if (body.name !== undefined) { + updateData.name = body.name; + } + if (body.skipTlsVerification !== undefined) { + updateData.skipTlsVerification = body.skipTlsVerification; + } + if (body.enabled !== undefined) { + updateData.enabled = body.enabled; + } + if (body.budgetAmount !== undefined) { + updateData.budgetAmount = body.budgetAmount; + } + if (body.budgetUnit !== undefined) { + updateData.budgetUnit = body.budgetUnit; + } + if (body.upstreamUrl !== undefined) { + updateData.upstreamUrl = body.upstreamUrl; + } + if (body.authType !== undefined) { + updateData.authType = body.authType; + } + + if (body.apiKey !== undefined) { + const key = config.getRawConfig().server.secret!; + updateData.apiKey = encrypt(body.apiKey, key); + updateData.apiKeyLastChars = body.apiKey.slice(-4); + } + + const [provider] = await db + .update(aiProviders) + .set(updateData) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .returning(); + + return response(res, { + data: { provider: toPublicAiProvider(provider) }, + success: true, + error: false, + message: "AI provider updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts new file mode 100644 index 000000000..62b442c59 --- /dev/null +++ b/server/routers/aiProvider/validation.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; +import type { + AiBudgetUnit, + AiProviderType +} from "@server/lib/aiProviderDefaults"; + +export const aiProviderTypeSchema = z.enum([ + "openai", + "anthropic", + "bedrock", + "custom" +]); + +export const aiBudgetUnitSchema = z.enum(["usd", "tokens"]); + +export const aiAuthTypeSchema = z.enum(["bearer"]); + +export function refineBudgetFields( + data: { + budgetAmount?: number | null; + budgetUnit?: AiBudgetUnit | null; + }, + ctx: z.RefinementCtx +) { + const hasAmount = + data.budgetAmount !== undefined && data.budgetAmount !== null; + const hasUnit = data.budgetUnit !== undefined && data.budgetUnit !== null; + + if (hasAmount !== hasUnit) { + ctx.addIssue({ + code: "custom", + message: + "budgetAmount and budgetUnit must both be set or both omitted", + path: hasAmount ? ["budgetUnit"] : ["budgetAmount"] + }); + } +} + +export function refineCustomProviderFields( + data: { + type: AiProviderType; + upstreamUrl?: string | null; + authType?: "bearer" | null; + }, + ctx: z.RefinementCtx +) { + if (data.type !== "custom") { + return; + } + + if (!data.upstreamUrl) { + ctx.addIssue({ + code: "custom", + message: "upstreamUrl is required for custom providers", + path: ["upstreamUrl"] + }); + } + + if (!data.authType) { + ctx.addIssue({ + code: "custom", + message: "authType is required for custom providers", + path: ["authType"] + }); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index ffe0a809b..1d33d6166 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -45,7 +45,9 @@ import { verifySiteResourceAccess, verifyOlmAccess, verifyLimits, - verifyResourcePolicyAccess + verifyResourcePolicyAccess, + verifyAiProviderAccess, + verifyAiModelAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; @@ -55,6 +57,7 @@ import { createStore } from "#dynamic/lib/rateLimitStore"; import { logActionAudit } from "#dynamic/middlewares"; import { checkRoundTripMessage } from "./ws"; import * as labels from "@server/routers/labels"; +import * as aiProvider from "@server/routers/aiProvider"; // Root routes export const unauthenticated = Router(); @@ -1366,6 +1369,90 @@ authenticated.get( authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage); +authenticated.put( + "/org/:orgId/ai-provider", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createAiProvider), + logActionAudit(ActionsEnum.createAiProvider), + aiProvider.createAiProvider +); + +authenticated.get( + "/org/:orgId/ai-providers", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listAiProviders), + aiProvider.listAiProviders +); + +authenticated.get( + "/org/:orgId/ai-provider/:providerId", + verifyOrgAccess, + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.getAiProvider), + aiProvider.getAiProvider +); + +authenticated.post( + "/org/:orgId/ai-provider/:providerId", + verifyOrgAccess, + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.updateAiProvider), + logActionAudit(ActionsEnum.updateAiProvider), + aiProvider.updateAiProvider +); + +authenticated.delete( + "/org/:orgId/ai-provider/:providerId", + verifyOrgAccess, + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.deleteAiProvider), + logActionAudit(ActionsEnum.deleteAiProvider), + aiProvider.deleteAiProvider +); + +authenticated.put( + "/org/:orgId/ai-provider/:providerId/model", + verifyOrgAccess, + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.createAiModel), + logActionAudit(ActionsEnum.createAiModel), + aiProvider.createAiModel +); + +authenticated.get( + "/org/:orgId/ai-provider/:providerId/models", + verifyOrgAccess, + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.listAiModels), + aiProvider.listAiModels +); + +authenticated.get( + "/org/:orgId/ai-provider/:providerId/model/:modelId", + verifyOrgAccess, + verifyAiModelAccess, + verifyUserHasAction(ActionsEnum.getAiModel), + aiProvider.getAiModel +); + +authenticated.post( + "/org/:orgId/ai-provider/:providerId/model/:modelId", + verifyOrgAccess, + verifyAiModelAccess, + verifyUserHasAction(ActionsEnum.updateAiModel), + logActionAudit(ActionsEnum.updateAiModel), + aiProvider.updateAiModel +); + +authenticated.delete( + "/org/:orgId/ai-provider/:providerId/model/:modelId", + verifyOrgAccess, + verifyAiModelAccess, + verifyUserHasAction(ActionsEnum.deleteAiModel), + logActionAudit(ActionsEnum.deleteAiModel), + aiProvider.deleteAiModel +); + authenticated.get( "/org/:orgId/labels", verifyOrgAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 13498ad25..3a17079c6 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -13,6 +13,7 @@ import * as apiKeys from "./apiKeys"; import * as idp from "./idp"; import * as logs from "./auditLogs"; import * as siteResource from "./siteResource"; +import * as aiProvider from "./aiProvider"; import { verifyApiKey, verifyApiKeyOrgAccess, @@ -31,6 +32,8 @@ import { verifyLimits, verifyApiKeyDomainAccess, verifyApiKeyResourcePolicyAccess, + verifyApiKeyAiProviderAccess, + verifyApiKeyAiModelAccess, verifyUserHasAction } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; @@ -1366,3 +1369,87 @@ authenticated.get( verifyApiKeyHasAction(ActionsEnum.listResources), resource.listAllResourceNames ); + +authenticated.put( + "/org/:orgId/ai-provider", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createAiProvider), + logActionAudit(ActionsEnum.createAiProvider), + aiProvider.createAiProvider +); + +authenticated.get( + "/org/:orgId/ai-providers", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listAiProviders), + aiProvider.listAiProviders +); + +authenticated.get( + "/org/:orgId/ai-provider/:providerId", + verifyApiKeyOrgAccess, + verifyApiKeyAiProviderAccess, + verifyApiKeyHasAction(ActionsEnum.getAiProvider), + aiProvider.getAiProvider +); + +authenticated.post( + "/org/:orgId/ai-provider/:providerId", + verifyApiKeyOrgAccess, + verifyApiKeyAiProviderAccess, + verifyApiKeyHasAction(ActionsEnum.updateAiProvider), + logActionAudit(ActionsEnum.updateAiProvider), + aiProvider.updateAiProvider +); + +authenticated.delete( + "/org/:orgId/ai-provider/:providerId", + verifyApiKeyOrgAccess, + verifyApiKeyAiProviderAccess, + verifyApiKeyHasAction(ActionsEnum.deleteAiProvider), + logActionAudit(ActionsEnum.deleteAiProvider), + aiProvider.deleteAiProvider +); + +authenticated.put( + "/org/:orgId/ai-provider/:providerId/model", + verifyApiKeyOrgAccess, + verifyApiKeyAiProviderAccess, + verifyApiKeyHasAction(ActionsEnum.createAiModel), + logActionAudit(ActionsEnum.createAiModel), + aiProvider.createAiModel +); + +authenticated.get( + "/org/:orgId/ai-provider/:providerId/models", + verifyApiKeyOrgAccess, + verifyApiKeyAiProviderAccess, + verifyApiKeyHasAction(ActionsEnum.listAiModels), + aiProvider.listAiModels +); + +authenticated.get( + "/org/:orgId/ai-provider/:providerId/model/:modelId", + verifyApiKeyOrgAccess, + verifyApiKeyAiModelAccess, + verifyApiKeyHasAction(ActionsEnum.getAiModel), + aiProvider.getAiModel +); + +authenticated.post( + "/org/:orgId/ai-provider/:providerId/model/:modelId", + verifyApiKeyOrgAccess, + verifyApiKeyAiModelAccess, + verifyApiKeyHasAction(ActionsEnum.updateAiModel), + logActionAudit(ActionsEnum.updateAiModel), + aiProvider.updateAiModel +); + +authenticated.delete( + "/org/:orgId/ai-provider/:providerId/model/:modelId", + verifyApiKeyOrgAccess, + verifyApiKeyAiModelAccess, + verifyApiKeyHasAction(ActionsEnum.deleteAiModel), + logActionAudit(ActionsEnum.deleteAiModel), + aiProvider.deleteAiModel +); diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 48868722e..fe33337d4 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -150,6 +150,22 @@ function getActionsCategories(root: boolean) { [t("actionListSiteProvisioningKeys")]: "listSiteProvisioningKeys", [t("actionUpdateSiteProvisioningKey")]: "updateSiteProvisioningKey", [t("actionDeleteSiteProvisioningKey")]: "deleteSiteProvisioningKey" + }, + + "AI Provider": { + [t("actionCreateAiProvider")]: "createAiProvider", + [t("actionDeleteAiProvider")]: "deleteAiProvider", + [t("actionGetAiProvider")]: "getAiProvider", + [t("actionListAiProviders")]: "listAiProviders", + [t("actionUpdateAiProvider")]: "updateAiProvider" + }, + + "AI Model": { + [t("actionCreateAiModel")]: "createAiModel", + [t("actionDeleteAiModel")]: "deleteAiModel", + [t("actionGetAiModel")]: "getAiModel", + [t("actionListAiModels")]: "listAiModels", + [t("actionUpdateAiModel")]: "updateAiModel" } }; From e5ac6ec7cd701bd186db12f813de59e2bc4005fd Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 31 Jul 2026 15:55:10 -0400 Subject: [PATCH 176/494] add default providers and support overrides --- server/db/pg/schema/schema.ts | 12 +++++- server/db/sqlite/schema/schema.ts | 12 +++++- server/lib/aiProviderDefaults.ts | 41 ++++++++++++++++++- server/routers/aiProvider/createAiProvider.ts | 4 +- server/routers/aiProvider/updateAiProvider.ts | 33 +++++++++------ server/routers/aiProvider/validation.ts | 24 ++++++----- 6 files changed, 96 insertions(+), 30 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 83f1e19bd..2f8199aa8 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1554,7 +1554,17 @@ export const aiProviders = pgTable("aiProviders", { .references(() => orgs.orgId, { onDelete: "cascade" }), name: varchar("name").notNull(), type: varchar("type") - .$type<"openai" | "anthropic" | "bedrock" | "custom">() + .$type< + | "openai" + | "anthropic" + | "googleGemini" + | "vertexAi" + | "bedrock" + | "microsoftFoundry" + | "openRouter" + | "vercelAiGateway" + | "custom" + >() .notNull(), upstreamUrl: text("upstreamUrl"), apiKey: text("apiKey"), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 97059d3cb..4cc533475 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1545,7 +1545,17 @@ export const aiProviders = sqliteTable("aiProviders", { .references(() => orgs.orgId, { onDelete: "cascade" }), name: text("name").notNull(), type: text("type") - .$type<"openai" | "anthropic" | "bedrock" | "custom">() + .$type< + | "openai" + | "anthropic" + | "googleGemini" + | "vertexAi" + | "bedrock" + | "microsoftFoundry" + | "openRouter" + | "vercelAiGateway" + | "custom" + >() .notNull(), upstreamUrl: text("upstreamUrl"), apiKey: text("apiKey"), diff --git a/server/lib/aiProviderDefaults.ts b/server/lib/aiProviderDefaults.ts index d89be99a0..63c060b62 100644 --- a/server/lib/aiProviderDefaults.ts +++ b/server/lib/aiProviderDefaults.ts @@ -1,9 +1,19 @@ -export type AiProviderType = "openai" | "anthropic" | "bedrock" | "custom"; +export type AiProviderType = + | "openai" + | "anthropic" + | "googleGemini" + | "vertexAi" + | "bedrock" + | "microsoftFoundry" + | "openRouter" + | "vercelAiGateway" + | "custom"; + export type AiProviderAuthType = "bearer"; export type AiBudgetUnit = "usd" | "tokens"; type AiProviderDefaults = { - upstreamUrl: string; + upstreamUrl: string | null; authType: AiProviderAuthType; }; @@ -19,12 +29,39 @@ export const AI_PROVIDER_DEFAULTS: Record< upstreamUrl: "https://api.anthropic.com", authType: "bearer" }, + googleGemini: { + upstreamUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", + authType: "bearer" + }, + vertexAi: { + upstreamUrl: null, + authType: "bearer" + }, bedrock: { upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", authType: "bearer" + }, + microsoftFoundry: { + upstreamUrl: null, + authType: "bearer" + }, + openRouter: { + upstreamUrl: "https://openrouter.ai/api/v1", + authType: "bearer" + }, + vercelAiGateway: { + upstreamUrl: "https://ai-gateway.vercel.sh/v1", + authType: "bearer" } }; +export function providerRequiresUpstreamUrl(type: AiProviderType): boolean { + if (type === "custom") { + return true; + } + return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null; +} + export function resolveAiProviderConfig(input: { type: AiProviderType; upstreamUrl: string | null; diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts index dad11d18d..27420bd34 100644 --- a/server/routers/aiProvider/createAiProvider.ts +++ b/server/routers/aiProvider/createAiProvider.ts @@ -16,7 +16,7 @@ import { aiBudgetUnitSchema, aiProviderTypeSchema, refineBudgetFields, - refineCustomProviderFields + refineProviderUpstreamFields } from "@server/routers/aiProvider/validation"; const paramsSchema = z.strictObject({ @@ -36,7 +36,7 @@ const bodySchema = z enabled: z.boolean().optional() }) .superRefine((data, ctx) => { - refineCustomProviderFields(data, ctx); + refineProviderUpstreamFields(data, ctx); refineBudgetFields(data, ctx); }); diff --git a/server/routers/aiProvider/updateAiProvider.ts b/server/routers/aiProvider/updateAiProvider.ts index 8525e1dd3..186b5bcd8 100644 --- a/server/routers/aiProvider/updateAiProvider.ts +++ b/server/routers/aiProvider/updateAiProvider.ts @@ -12,13 +12,17 @@ import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import { toPublicAiProvider } from "@server/routers/aiProvider/types"; -import type { AiProviderType } from "@server/lib/aiProviderDefaults"; import { aiAuthTypeSchema, aiBudgetUnitSchema, + aiProviderTypeSchema, refineBudgetFields, - refineCustomProviderFields + refineProviderUpstreamFields } from "@server/routers/aiProvider/validation"; +import { + providerRequiresUpstreamUrl, + type AiProviderType +} from "@server/lib/aiProviderDefaults"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty(), @@ -115,26 +119,29 @@ export async function updateAiProvider( } const providerType = existing.type as AiProviderType; + const nextUpstreamUrl = + body.upstreamUrl !== undefined + ? body.upstreamUrl + : existing.upstreamUrl; + const nextAuthType = + body.authType !== undefined ? body.authType : existing.authType; - if (providerType === "custom") { - const nextUpstreamUrl = - body.upstreamUrl !== undefined - ? body.upstreamUrl - : existing.upstreamUrl; - const nextAuthType = - body.authType !== undefined ? body.authType : existing.authType; - + if ( + providerRequiresUpstreamUrl(providerType) || + body.upstreamUrl !== undefined || + body.authType !== undefined + ) { const validation = z .object({ - type: z.literal("custom"), + type: aiProviderTypeSchema, upstreamUrl: z.string().nullable().optional(), authType: aiAuthTypeSchema.nullable().optional() }) .superRefine((data, ctx) => - refineCustomProviderFields(data, ctx) + refineProviderUpstreamFields(data, ctx) ) .safeParse({ - type: "custom", + type: providerType, upstreamUrl: nextUpstreamUrl, authType: nextAuthType }); diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts index 62b442c59..ab70800b5 100644 --- a/server/routers/aiProvider/validation.ts +++ b/server/routers/aiProvider/validation.ts @@ -1,13 +1,19 @@ import { z } from "zod"; -import type { - AiBudgetUnit, - AiProviderType +import { + providerRequiresUpstreamUrl, + type AiBudgetUnit, + type AiProviderType } from "@server/lib/aiProviderDefaults"; export const aiProviderTypeSchema = z.enum([ "openai", "anthropic", + "googleGemini", + "vertexAi", "bedrock", + "microsoftFoundry", + "openRouter", + "vercelAiGateway", "custom" ]); @@ -36,7 +42,7 @@ export function refineBudgetFields( } } -export function refineCustomProviderFields( +export function refineProviderUpstreamFields( data: { type: AiProviderType; upstreamUrl?: string | null; @@ -44,19 +50,15 @@ export function refineCustomProviderFields( }, ctx: z.RefinementCtx ) { - if (data.type !== "custom") { - return; - } - - if (!data.upstreamUrl) { + if (providerRequiresUpstreamUrl(data.type) && !data.upstreamUrl) { ctx.addIssue({ code: "custom", - message: "upstreamUrl is required for custom providers", + message: `upstreamUrl is required for ${data.type} providers`, path: ["upstreamUrl"] }); } - if (!data.authType) { + if (data.type === "custom" && !data.authType) { ctx.addIssue({ code: "custom", message: "authType is required for custom providers", From 9d581f3897c3491790f81f3f9846f5af59102b6b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 31 Jul 2026 16:50:35 -0400 Subject: [PATCH 177/494] Fix types --- messages/en-US.json | 1 + server/db/pg/schema/schema.ts | 4 ++- server/db/sqlite/schema/schema.ts | 2 +- .../private/lib/traefik/getTraefikConfig.ts | 3 +- .../routers/launcher/formatLauncherAccess.ts | 3 +- server/routers/resource/createResource.ts | 4 +-- server/routers/resource/listResources.ts | 3 +- .../settings/resources/private/page.tsx | 4 +-- .../settings/resources/public/create/page.tsx | 2 +- src/components/PrivateResourcesTable.tsx | 36 +++++++++---------- src/components/SiteResourceInfoBox.tsx | 3 +- src/components/SiteResourcesOverview.tsx | 3 +- src/lib/formatSiteResourceAccess.ts | 4 ++- src/lib/privateResourceForm.ts | 7 ++-- 14 files changed, 44 insertions(+), 35 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 24d10ab95..fd28a5469 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2411,6 +2411,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeInference": "Inference", "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "Scheme", "editInternalResourceDialogEnableSsl": "Enable TLS", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 2f8199aa8..43a84ab5d 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -197,7 +197,9 @@ export const resources = pgTable( wildcard: boolean("wildcard").notNull().default(false), mode: text("mode") .default("http") - .$type<"rdp" | "ssh" | "http" | "vnc" | "inference">() + .$type< + "rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp" + >() .notNull(), pamMode: varchar("pamMode", { length: 32 }) .$type<"passthrough" | "push">() diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 4cc533475..8265a2855 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -206,7 +206,7 @@ export const resources = sqliteTable("resources", { wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false), mode: text("mode") .default("http") - .$type<"rdp" | "ssh" | "http" | "vnc" | "inference">() + .$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">() .notNull(), // rdp, ssh, http, vnc, inference pamMode: text("pamMode") .$type<"passthrough" | "push">() diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 4be4c6d2b..0544cafe7 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -18,6 +18,7 @@ import { domains, exitNodes, loginPage, + SiteResource, targetHealthCheck } from "@server/db"; import { @@ -361,7 +362,7 @@ export async function getTraefikConfig( let siteResourcesWithFullDomain: { siteResourceId: number; fullDomain: string | null; - mode: "http" | "host" | "cidr" | "ssh"; + mode: SiteResource["mode"]; }[] = []; if ( build == "enterprise" && diff --git a/server/routers/launcher/formatLauncherAccess.ts b/server/routers/launcher/formatLauncherAccess.ts index c12767ce1..ff5a5bf37 100644 --- a/server/routers/launcher/formatLauncherAccess.ts +++ b/server/routers/launcher/formatLauncherAccess.ts @@ -1,7 +1,8 @@ +import { SiteResource } from "@server/db"; import { formatEndpoint, parseEndpoint } from "@server/lib/ip"; export type SiteResourceDestinationInput = { - mode: "host" | "cidr" | "http" | "ssh"; + mode: SiteResource["mode"]; destination: string | null; destinationPort: number | null; scheme: "http" | "https" | null; diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index ab758cc1c..2ff7b9ae3 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -44,11 +44,11 @@ const createResourceParamsSchema = z.strictObject({ }); function resolveModeFromLegacyFields(data: { - mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp"; + mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference"; http?: boolean; protocol?: "tcp" | "udp"; }): { - mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp"; + mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference"; error?: string; } { if (data.mode) { diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index cc075f23b..e5408f630 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -637,11 +637,12 @@ export async function listResources( ${resourcePassword.passwordId} ) `; - const browserGatewayModes = ["http", "ssh", "rdp", "vnc"]; + const browserGatewayModes = ["http", "ssh", "rdp", "vnc"] as const; switch (authState) { case "none": conditions.push( + // TODO: Does inference belong here? or(eq(resources.mode, "tcp"), eq(resources.mode, "udp")) ); break; diff --git a/src/app/[orgId]/settings/resources/private/page.tsx b/src/app/[orgId]/settings/resources/private/page.tsx index cbd9ec7ef..86bba120d 100644 --- a/src/app/[orgId]/settings/resources/private/page.tsx +++ b/src/app/[orgId]/settings/resources/private/page.tsx @@ -1,5 +1,5 @@ import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner"; -import type { InternalResourceRow } from "@app/components/PrivateResourcesTable"; +import type { PrivateResourceRow } from "@app/components/PrivateResourcesTable"; import PrivateResourcesTable from "@app/components/PrivateResourcesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; @@ -61,7 +61,7 @@ export default async function ClientResourcesPage( redirect(`/${params.orgId}/settings/resources`); } - const internalResourceRows: InternalResourceRow[] = siteResources.map( + const internalResourceRows: PrivateResourceRow[] = siteResources.map( (siteResource) => { return { id: siteResource.siteResourceId, diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index 43d9528b9..eaf4e5527 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -206,7 +206,7 @@ function createAddTargetSchema(t: TranslateFn) { ); } -type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp"; +type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference"; type CreateBgTargetFormValues = SshSettingsFormValues; diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 5ee23f424..87eddb921 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -69,20 +69,21 @@ import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; import { ControlledDataTable } from "./ui/controlled-data-table"; import { SitesColumnFilterButton } from "./SitesColumnFilterButton"; +import { SiteResource } from "@server/db"; -export type InternalResourceSiteRow = ResourceSiteRow; +export type PrivateResourceSiteRow = ResourceSiteRow; -export type InternalResourceRow = { +export type PrivateResourceRow = { id: number; name: string; orgId: string; - sites: InternalResourceSiteRow[]; + sites: PrivateResourceSiteRow[]; siteNames: string[]; siteAddresses: (string | null)[]; siteIds: number[]; siteNiceIds: string[]; // mode: "host" | "cidr" | "port"; - mode: "host" | "cidr" | "http" | "ssh"; + mode: SiteResource["mode"]; scheme: "http" | "https" | null; ssl: boolean; // protocol: string | null; @@ -109,7 +110,7 @@ export type InternalResourceRow = { }>; }; -function formatDestinationDisplay(row: InternalResourceRow): string { +function formatDestinationDisplay(row: PrivateResourceRow): string { return formatSiteResourceDestinationDisplay({ mode: row.mode, destination: row.destination, @@ -133,7 +134,7 @@ const booleanSearchFilterSchema = z .catch(undefined); type ClientResourcesTableProps = { - internalResources: InternalResourceRow[]; + internalResources: PrivateResourceRow[]; orgId: string; pagination: PaginationState; rowCount: number; @@ -164,10 +165,10 @@ export default function PrivateResourcesTable({ const [isNavigatingToAddPage, startNavigation] = useTransition(); const [selectedInternalResource, setSelectedInternalResource] = - useState(null); + useState(null); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [editingResource, setEditingResource] = - useState(); + useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isRefreshing, startRefreshTransition] = useTransition(); @@ -241,9 +242,9 @@ export default function PrivateResourcesTable({ }; const internalColumns = useMemo< - ExtendedColumnDef[] + ExtendedColumnDef[] >(() => { - const cols: ExtendedColumnDef[] = [ + const cols: ExtendedColumnDef[] = [ { accessorKey: "name", enableHiding: false, @@ -364,15 +365,12 @@ export default function PrivateResourcesTable({ ), cell: ({ row }) => { const resourceRow = row.original; - const modeLabels: Record< - "host" | "cidr" | "port" | "http" | "ssh", - string - > = { + const modeLabels: Record = { host: t("editInternalResourceDialogModeHost"), cidr: t("editInternalResourceDialogModeCidr"), - port: t("editInternalResourceDialogModePort"), http: t("editInternalResourceDialogModeHttp"), - ssh: t("editInternalResourceDialogModeSsh") + ssh: t("editInternalResourceDialogModeSsh"), + inference: t("editInternalResourceDialogModeInference") }; return {modeLabels[resourceRow.mode]}; } @@ -521,7 +519,7 @@ export default function PrivateResourcesTable({ className="p-3" /> ), - cell: ({ row }: { row: { original: InternalResourceRow } }) => ( + cell: ({ row }: { row: { original: PrivateResourceRow } }) => ( Date: Fri, 31 Jul 2026 17:22:08 -0400 Subject: [PATCH 178/494] Handle the alias create and update special case exit nodes --- server/lib/rebuildClientAssociations.ts | 79 ++++++++++++++++++- .../siteResource/createSiteResource.ts | 9 ++- .../siteResource/updateSiteResource.ts | 9 ++- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index ce0897aab..194bfe446 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -1137,7 +1137,9 @@ async function syncClientExitNodeConnections( ) ); - const needsConnectSet = new Set(requiresExitNodeRows.map((r) => r.clientId)); + const needsConnectSet = new Set( + requiresExitNodeRows.map((r) => r.clientId) + ); const exitNodeIds = Array.from( new Set( @@ -1249,6 +1251,61 @@ async function syncClientExitNodeConnections( } } +// Notifies the olms of every given client that the alias of the site resource +// they're using an exit node connection for has changed, via the dedicated +// exit node data-update message. Unlike syncClientExitNodeConnections, this +// doesn't touch connect/disconnect state - it's purely a rename for clients +// that are (and remain) connected to the exit node for this resource. +async function syncClientExitNodeAliasUpdate( + clientIds: number[], + oldAlias: string | null, + newAlias: string | null, + trx: Transaction | typeof db = db +): Promise { + const uniqueClientIds = Array.from(new Set(clientIds)); + if (uniqueClientIds.length === 0) { + return; + } + + const oldAliases = oldAlias ? [oldAlias] : []; + const newAliases = newAlias ? [newAlias] : []; + if (oldAliases.length === 0 && newAliases.length === 0) { + return; + } + + const olmRows = await trx + .select({ + clientId: olms.clientId, + olmId: olms.olmId, + version: olms.version + }) + .from(olms) + .where(inArray(olms.clientId, uniqueClientIds)); + + const updatePayloads = olmRows + .filter((r) => r.clientId !== null) + .map((olm) => ({ + clientId: olm.olmId, + message: { + type: "olm/wg/exitnode/data/update", + data: { + oldAliases, + newAliases + } + }, + options: { compress: canCompress(olm.version, "olm") } + })); + + if (updatePayloads.length > 0) { + await sendToClientsBatch(updatePayloads).catch((error) => { + logger.error( + `rebuildClientAssociations: Error sending exit node alias update messages:`, + error + ); + }); + } +} + async function handleSubnetProxyTargetUpdates( siteResource: SiteResource, sitesList: Site[], @@ -1479,7 +1536,7 @@ export async function handleMessagingForUpdatedSiteResource( `handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)` ); - // WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED + // !!!!!!!!!!!!!!!!!! WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED !!!!!!!!!!!!!!!!!! const mergedAllClients = await trx .select({ clientId: clientSiteResourcesAssociationsCache.clientId, @@ -1906,6 +1963,24 @@ export async function handleMessagingForUpdatedSiteResource( ); } + // For a resource that stays on an exit node connection across the update, + // the alias is the only field that affects already-connected clients (the + // exit node itself, its endpoint, etc. are not per-resource). Tell those + // clients' olms about the rename directly via the exit node data-update + // message rather than a full connect/disconnect cycle. + if ( + existingSiteResource?.requiresExitNodeConnection && + updatedSiteResource.requiresExitNodeConnection && + aliasChanged + ) { + await syncClientExitNodeAliasUpdate( + mergedAllClients.map((c) => c.clientId), + existingSiteResource.alias, + updatedSiteResource.alias, + trx + ); + } + // If this resource requires (or required) clients to be connected to the // exit node (e.g. an inference resource), re-sync connect/disconnect // state for every client currently associated with it - covers toggling diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index d220a4b08..1a4cd9a4d 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -29,7 +29,7 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, ne } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -490,7 +490,11 @@ export async function createSiteResource( .where( and( eq(siteResources.orgId, orgId), - eq(siteResources.alias, alias.trim()) + eq(siteResources.alias, alias.trim()), + ne( + siteResources.requiresExitNodeConnection, + mode == "inference" + ) // exclude looking at the ones on exit nodes if this is an inference resource ) ) .limit(1); @@ -527,6 +531,7 @@ export async function createSiteResource( let aliasAddress: string | null = null; let releaseAliasLock: (() => Promise) | null = null; if (mode === "host" || mode === "http" || mode === "ssh") { + // no alias address but we do have an alias for inference const { value, release } = await getNextAvailableAliasAddress(orgId); aliasAddress = value; diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 1b79e7b16..0ab928d04 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -507,7 +507,11 @@ export async function updateSiteResource( and( eq(siteResources.orgId, existingSiteResource.orgId), eq(siteResources.alias, alias.trim()), - ne(siteResources.siteResourceId, siteResourceId) // exclude self + ne(siteResources.siteResourceId, siteResourceId), // exclude self + ne( + siteResources.requiresExitNodeConnection, + mode == "inference" + ) // exclude looking at the ones on exit nodes if this is an inference resource ) ) .limit(1); @@ -587,8 +591,7 @@ export async function updateSiteResource( domainId, subdomain: finalSubdomain, fullDomain, - networkId: - mode === "inference" ? null : undefined, + networkId: mode === "inference" ? null : undefined, requiresExitNodeConnection: mode !== undefined ? mode === "inference" : undefined, ...sshPamSet From 42c0abedb7195bffad45cdfce909e433c8240143 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 31 Jul 2026 17:47:45 -0400 Subject: [PATCH 179/494] Add basic page to configure the alias --- messages/en-US.json | 1 + .../private/[niceId]/inference/page.tsx | 107 ++++++++++++++++++ .../resources/private/[niceId]/layout.tsx | 3 +- .../PrivateResourceDestinationFields.tsx | 31 +++++ src/lib/privateResourceForm.ts | 10 ++ 5 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index fd28a5469..94dd49f96 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2256,6 +2256,7 @@ "requireDeviceApproval": "Require Device Approvals", "requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.", "sshSettings": "SSH Settings", + "inferenceSettings": "Inference Settings", "sshAccess": "SSH Access", "rdpSettings": "RDP Settings", "vncSettings": "VNC Settings", diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx new file mode 100644 index 000000000..3f27f1172 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx @@ -0,0 +1,107 @@ +"use client"; + +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 { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; +import { PrivateResourceInferenceDestinationFields } from "@app/components/PrivateResourceDestinationFields"; +import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges"; +import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; +import { + asAnyControl, + asAnySetValue, + asAnyWatch +} from "@app/lib/formControlUtils"; +import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; + +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; + + const form = useForm({ + 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 ( + + + + + {t("hostSettings")} + + + {t("editInternalResourceDialogDestinationDescription")} + + + + + +
+ + + + + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx index a1e97e188..f07b92673 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx @@ -52,7 +52,8 @@ export default async function PrivateResourceLayout( | "hostSettings" | "cidrSettings" | "httpSettings" - | "sshSettings"; + | "sshSettings" + | "inferenceSettings"; const navItems = [ { diff --git a/src/components/PrivateResourceDestinationFields.tsx b/src/components/PrivateResourceDestinationFields.tsx index 642ee0a5b..a10ce3161 100644 --- a/src/components/PrivateResourceDestinationFields.tsx +++ b/src/components/PrivateResourceDestinationFields.tsx @@ -76,6 +76,37 @@ type PrivateResourceHostDestinationFieldsProps = { hideAlias?: boolean; }; +type PrivateResourceInferenceDestinationFieldsProps = { + control: Control; + watch: UseFormWatch; + labelPrefix?: "create" | "edit"; + hideAlias?: boolean; +}; + +export function PrivateResourceInferenceDestinationFields({ + control, + watch, + labelPrefix = "edit" +}: PrivateResourceInferenceDestinationFieldsProps) { + const t = useTranslations(); + const destinationLabelKey = + labelPrefix === "create" + ? "createInternalResourceDialogDestination" + : "editInternalResourceDialogDestination"; + + return ( + + + + + + ); +} + export function PrivateResourceHostDestinationFields({ control, watch, diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index 844ff96cb..f2ff103f6 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -485,6 +485,7 @@ function destinationRefine( const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native"; const trimmedDestination = data.destination?.trim(); if ( + data.mode !== "inference" && !isNativeSsh && (!trimmedDestination || trimmedDestination.length < 1) ) { @@ -555,6 +556,15 @@ export function createHostFormSchema(t: TranslateFn) { .superRefine((data, ctx) => destinationRefine(data, ctx, t)); } +export function createInferenceFormSchema(t: TranslateFn) { + return z + .object({ + mode: z.literal("inference"), + alias: z.string().nullish() + }) + .superRefine((data, ctx) => destinationRefine(data, ctx, t)); +} + export function createCidrFormSchema(t: TranslateFn) { return z .object({ From 6fa0009ebf3d3699a9ab15366f95025765fbe296 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 31 Jul 2026 17:36:04 -0400 Subject: [PATCH 180/494] add targets and refactor endpoints --- server/db/pg/schema/schema.ts | 20 +- server/db/sqlite/schema/schema.ts | 15 +- server/lib/aiProviderDefaults.ts | 27 ++- server/lib/alerts/events/healthCheckEvents.ts | 8 +- server/lib/deleteResource.ts | 9 +- server/lib/deleteSiteAssociatedResources.ts | 8 +- .../integration/verifyApiKeyAiModelAccess.ts | 27 +-- .../verifyApiKeyAiProviderAccess.ts | 48 +---- .../integration/verifyApiKeyTargetAccess.ts | 80 +++++--- server/middlewares/verifyAiModelAccess.ts | 28 +-- server/middlewares/verifyAiProviderAccess.ts | 19 +- server/middlewares/verifyTargetAccess.ts | 127 +++++++----- server/routers/aiProvider/createAiModel.ts | 12 +- server/routers/aiProvider/createAiProvider.ts | 11 +- server/routers/aiProvider/deleteAiModel.ts | 31 +-- server/routers/aiProvider/deleteAiProvider.ts | 21 +- server/routers/aiProvider/getAiModel.ts | 27 +-- server/routers/aiProvider/getAiProvider.ts | 14 +- server/routers/aiProvider/listAiModels.ts | 12 +- server/routers/aiProvider/types.ts | 4 +- server/routers/aiProvider/updateAiModel.ts | 34 +-- server/routers/aiProvider/updateAiProvider.ts | 89 ++++---- server/routers/aiProvider/validation.ts | 21 +- server/routers/external.ts | 41 ++-- server/routers/integration.ts | 40 ++-- server/routers/newt/buildConfiguration.ts | 16 +- server/routers/target/createTarget.ts | 194 +++++++++++++++--- server/routers/target/deleteTarget.ts | 80 +++++--- server/routers/target/listTargets.ts | 61 +++++- server/routers/target/updateTarget.ts | 106 ++++++++-- .../public/ProxyResourceTargetsForm.tsx | 1 + 31 files changed, 741 insertions(+), 490 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 43a84ab5d..0f59b1c26 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -323,11 +323,18 @@ export const targets = pgTable( "targets", { targetId: serial("targetId").primaryKey(), - resourceId: integer("resourceId") - .references(() => resources.resourceId, { + resourceId: integer("resourceId").references( + () => resources.resourceId, + { onDelete: "cascade" - }) - .notNull(), + } + ), + providerId: integer("providerId").references( + () => aiProviders.providerId, + { + onDelete: "cascade" + } + ), siteId: integer("siteId") .references(() => sites.siteId, { onDelete: "cascade" @@ -351,6 +358,7 @@ export const targets = pgTable( }, (t) => [ index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId), + index("idx_targets_providerid_siteid").on(t.providerId, t.siteId), index("idx_targets_site_enabled_priority_target_resource") .on(t.siteId, t.priority.desc(), t.targetId, t.resourceId) .where(sql`${t.enabled} = true`) @@ -1572,6 +1580,10 @@ export const aiProviders = pgTable("aiProviders", { apiKey: text("apiKey"), apiKeyLastChars: varchar("apiKeyLastChars"), authType: varchar("authType").$type<"bearer">(), + routingMode: varchar("routingMode") + .$type<"url" | "target">() + .notNull() + .default("url"), skipTlsVerification: boolean("skipTlsVerification") .notNull() .default(false), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 8265a2855..73e73f04a 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -326,11 +326,12 @@ export const clientLabels = sqliteTable( export const targets = sqliteTable("targets", { targetId: integer("targetId").primaryKey({ autoIncrement: true }), - resourceId: integer("resourceId") - .references(() => resources.resourceId, { - onDelete: "cascade" - }) - .notNull(), + resourceId: integer("resourceId").references(() => resources.resourceId, { + onDelete: "cascade" + }), + providerId: integer("providerId").references(() => aiProviders.providerId, { + onDelete: "cascade" + }), siteId: integer("siteId") .references(() => sites.siteId, { onDelete: "cascade" @@ -1561,6 +1562,10 @@ export const aiProviders = sqliteTable("aiProviders", { apiKey: text("apiKey"), apiKeyLastChars: text("apiKeyLastChars"), authType: text("authType").$type<"bearer">(), + routingMode: text("routingMode") + .$type<"url" | "target">() + .notNull() + .default("url"), skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" }) .notNull() .default(false), diff --git a/server/lib/aiProviderDefaults.ts b/server/lib/aiProviderDefaults.ts index 63c060b62..6cfb816b7 100644 --- a/server/lib/aiProviderDefaults.ts +++ b/server/lib/aiProviderDefaults.ts @@ -11,6 +11,7 @@ export type AiProviderType = export type AiProviderAuthType = "bearer"; export type AiBudgetUnit = "usd" | "tokens"; +export type AiProviderRoutingMode = "url" | "target"; type AiProviderDefaults = { upstreamUrl: string | null; @@ -55,7 +56,13 @@ export const AI_PROVIDER_DEFAULTS: Record< } }; -export function providerRequiresUpstreamUrl(type: AiProviderType): boolean { +export function providerRequiresUpstreamUrl( + type: AiProviderType, + routingMode: AiProviderRoutingMode = "url" +): boolean { + if (routingMode === "target") { + return false; + } if (type === "custom") { return true; } @@ -66,20 +73,34 @@ export function resolveAiProviderConfig(input: { type: AiProviderType; upstreamUrl: string | null; authType: AiProviderAuthType | null; + routingMode?: AiProviderRoutingMode | null; }): { upstreamUrl: string | null; authType: AiProviderAuthType | null; + routingMode: AiProviderRoutingMode; } { + const routingMode = input.routingMode ?? "url"; + + if (routingMode === "target") { + return { + upstreamUrl: null, + authType: input.authType ?? "bearer", + routingMode + }; + } + if (input.type === "custom") { return { upstreamUrl: input.upstreamUrl, - authType: input.authType + authType: input.authType, + routingMode }; } const defaults = AI_PROVIDER_DEFAULTS[input.type]; return { upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl, - authType: input.authType ?? defaults.authType + authType: input.authType ?? defaults.authType, + routingMode }; } diff --git a/server/lib/alerts/events/healthCheckEvents.ts b/server/lib/alerts/events/healthCheckEvents.ts index 429cf73b0..b7b5a8ff2 100644 --- a/server/lib/alerts/events/healthCheckEvents.ts +++ b/server/lib/alerts/events/healthCheckEvents.ts @@ -202,6 +202,10 @@ async function handleResource( return; } + if (!target.resourceId) { + return; + } + const [resource] = await trx .select() .from(resources) @@ -227,9 +231,7 @@ async function handleResource( let health = "healthy"; const allUnknown = monitoredTargets.length === 0; - const allHealthy = monitoredTargets.every( - (t) => t.hcHealth === "healthy" - ); + const allHealthy = monitoredTargets.every((t) => t.hcHealth === "healthy"); const allUnhealthy = monitoredTargets.every( (t) => t.hcHealth === "unhealthy" ); diff --git a/server/lib/deleteResource.ts b/server/lib/deleteResource.ts index 3f33c7400..b71818b17 100644 --- a/server/lib/deleteResource.ts +++ b/server/lib/deleteResource.ts @@ -64,13 +64,20 @@ export async function performDeleteResources( const targetsByResourceId = new Map(); for (const target of targetsToBeRemoved) { + if (target.resourceId == null) { + continue; + } const existing = targetsByResourceId.get(target.resourceId) ?? []; existing.push(target); targetsByResourceId.set(target.resourceId, existing); } const targetIdToResourceId = new Map( - targetsToBeRemoved.map((target) => [target.targetId, target.resourceId]) + targetsToBeRemoved.flatMap((target) => + target.resourceId == null + ? [] + : [[target.targetId, target.resourceId] as const] + ) ); const healthChecksByResourceId = new Map(); diff --git a/server/lib/deleteSiteAssociatedResources.ts b/server/lib/deleteSiteAssociatedResources.ts index e69585d1a..c0e520846 100644 --- a/server/lib/deleteSiteAssociatedResources.ts +++ b/server/lib/deleteSiteAssociatedResources.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, sql } from "drizzle-orm"; import { db, resources, @@ -33,9 +33,11 @@ export async function getResourceIdsForSite( const rows = await trx .selectDistinct({ resourceId: targets.resourceId }) .from(targets) - .where(eq(targets.siteId, siteId)); + .where(and(eq(targets.siteId, siteId), isNotNull(targets.resourceId))); - return rows.map((row) => row.resourceId); + return rows + .map((row) => row.resourceId) + .filter((resourceId): resourceId is number => resourceId != null); } export async function getSiteResourceIdsForSite( diff --git a/server/middlewares/integration/verifyApiKeyAiModelAccess.ts b/server/middlewares/integration/verifyApiKeyAiModelAccess.ts index 75a56f37e..eec772ac3 100644 --- a/server/middlewares/integration/verifyApiKeyAiModelAccess.ts +++ b/server/middlewares/integration/verifyApiKeyAiModelAccess.ts @@ -14,9 +14,6 @@ export async function verifyApiKeyAiModelAccess( const apiKey = req.apiKey; const modelIdRaw = getFirstString(req.params.modelId); const modelId = Number.parseInt(modelIdRaw ?? "", 10); - const providerIdRaw = getFirstString(req.params.providerId); - const providerId = Number.parseInt(providerIdRaw ?? "", 10); - const orgId = getFirstString(req.params.orgId); if (!apiKey) { return next( @@ -24,18 +21,6 @@ export async function verifyApiKeyAiModelAccess( ); } - if (!orgId) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") - ); - } - - if (Number.isNaN(providerId)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") - ); - } - if (Number.isNaN(modelId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID") @@ -52,13 +37,7 @@ export async function verifyApiKeyAiModelAccess( aiProviders, eq(aiModels.providerId, aiProviders.providerId) ) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiModels.modelId, modelId)) .limit(1); if (!row) { @@ -76,7 +55,9 @@ export async function verifyApiKeyAiModelAccess( return next(); } - if (!req.apiKeyOrg) { + const orgId = row.provider.orgId; + + if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) { const apiKeyOrgRes = await db .select() .from(apiKeyOrg) diff --git a/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts b/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts index 6b9f9b0ec..1668b5727 100644 --- a/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts +++ b/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts @@ -14,7 +14,6 @@ export async function verifyApiKeyAiProviderAccess( const apiKey = req.apiKey; const providerIdRaw = getFirstString(req.params.providerId); const providerId = Number.parseInt(providerIdRaw ?? "", 10); - const orgId = getFirstString(req.params.orgId); if (!apiKey) { return next( @@ -22,52 +21,16 @@ export async function verifyApiKeyAiProviderAccess( ); } - if (!orgId) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") - ); - } - if (Number.isNaN(providerId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") ); } - if (apiKey.isRoot) { - const [provider] = await db - .select() - .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) - .limit(1); - - if (!provider) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `AI provider with ID ${providerId} not found` - ) - ); - } - - req.aiProvider = provider; - return next(); - } - const [provider] = await db .select() .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .limit(1); if (!provider) { @@ -79,7 +42,14 @@ export async function verifyApiKeyAiProviderAccess( ); } - if (!req.apiKeyOrg) { + if (apiKey.isRoot) { + req.aiProvider = provider; + return next(); + } + + const orgId = provider.orgId; + + if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) { const apiKeyOrgRes = await db .select() .from(apiKeyOrg) diff --git a/server/middlewares/integration/verifyApiKeyTargetAccess.ts b/server/middlewares/integration/verifyApiKeyTargetAccess.ts index dac36b1e2..98d5349df 100644 --- a/server/middlewares/integration/verifyApiKeyTargetAccess.ts +++ b/server/middlewares/integration/verifyApiKeyTargetAccess.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; -import { resources, targets, apiKeyOrg } from "@server/db"; +import { aiProviders, resources, targets, apiKeyOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -43,43 +43,65 @@ export async function verifyApiKeyTargetAccess( ); } - const resourceId = target.resourceId; - if (!resourceId) { + const { resourceId, providerId } = target; + if ((!resourceId && !providerId) || (resourceId && providerId)) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - `Target with ID ${targetId} does not have a resource ID` - ) - ); - } - - const [resource] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, resourceId)) - .limit(1); - - if (!resource) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found` + `Target with ID ${targetId} has invalid ownership` ) ); } if (apiKey.isRoot) { - // Root keys can access any key in any org + // Root keys can access any target return next(); } - if (!resource.orgId) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - `Resource with ID ${resourceId} does not have an organization ID` - ) - ); + let orgId: string; + if (resourceId) { + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (!resource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource with ID ${resourceId} does not have an organization ID` + ) + ); + } + + orgId = resource.orgId; + } else { + const [provider] = await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, providerId!)) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + orgId = provider.orgId; } if (!req.apiKeyOrg) { @@ -89,7 +111,7 @@ export async function verifyApiKeyTargetAccess( .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), - eq(apiKeyOrg.orgId, resource.orgId) + eq(apiKeyOrg.orgId, orgId) ) ) .limit(1); @@ -98,7 +120,7 @@ export async function verifyApiKeyTargetAccess( } } - if (!req.apiKeyOrg) { + if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/middlewares/verifyAiModelAccess.ts b/server/middlewares/verifyAiModelAccess.ts index 99693b982..0d5761fc4 100644 --- a/server/middlewares/verifyAiModelAccess.ts +++ b/server/middlewares/verifyAiModelAccess.ts @@ -16,9 +16,6 @@ export async function verifyAiModelAccess( const userId = req.user!.userId; const modelIdRaw = getFirstString(req.params.modelId); const modelId = Number.parseInt(modelIdRaw ?? "", 10); - const providerIdRaw = getFirstString(req.params.providerId); - const providerId = Number.parseInt(providerIdRaw ?? "", 10); - const orgId = getFirstString(req.params.orgId); if (!userId) { return next( @@ -26,18 +23,6 @@ export async function verifyAiModelAccess( ); } - if (!orgId) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") - ); - } - - if (Number.isNaN(providerId)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") - ); - } - if (Number.isNaN(modelId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID") @@ -54,13 +39,7 @@ export async function verifyAiModelAccess( aiProviders, eq(aiModels.providerId, aiProviders.providerId) ) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiModels.modelId, modelId)) .limit(1); if (!row) { @@ -72,7 +51,9 @@ export async function verifyAiModelAccess( ); } - if (!req.userOrg) { + const orgId = row.provider.orgId; + + if (!req.userOrg || req.userOrg.orgId !== orgId) { const userOrgRole = await db .select() .from(userOrgs) @@ -109,6 +90,7 @@ export async function verifyAiModelAccess( } } + req.userOrgId = orgId; req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); req.aiProvider = row.provider; req.aiModel = row.model; diff --git a/server/middlewares/verifyAiProviderAccess.ts b/server/middlewares/verifyAiProviderAccess.ts index 604e3e893..22edd6f86 100644 --- a/server/middlewares/verifyAiProviderAccess.ts +++ b/server/middlewares/verifyAiProviderAccess.ts @@ -16,7 +16,6 @@ export async function verifyAiProviderAccess( const userId = req.user!.userId; const providerIdRaw = getFirstString(req.params.providerId); const providerId = Number.parseInt(providerIdRaw ?? "", 10); - const orgId = getFirstString(req.params.orgId); if (!userId) { return next( @@ -24,12 +23,6 @@ export async function verifyAiProviderAccess( ); } - if (!orgId) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") - ); - } - if (Number.isNaN(providerId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") @@ -39,12 +32,7 @@ export async function verifyAiProviderAccess( const [provider] = await db .select() .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .limit(1); if (!provider) { @@ -56,7 +44,9 @@ export async function verifyAiProviderAccess( ); } - if (!req.userOrg) { + const orgId = provider.orgId; + + if (!req.userOrg || req.userOrg.orgId !== orgId) { const userOrgRole = await db .select() .from(userOrgs) @@ -93,6 +83,7 @@ export async function verifyAiProviderAccess( } } + req.userOrgId = orgId; req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); req.aiProvider = provider; diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts index 24b8abd22..b495cf0e9 100644 --- a/server/middlewares/verifyTargetAccess.ts +++ b/server/middlewares/verifyTargetAccess.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; -import { resources, targets, userOrgs } from "@server/db"; +import { aiProviders, resources, targets, userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -25,9 +25,7 @@ export async function verifyTargetAccess( } if (isNaN(targetId)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") - ); + return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")); } const target = await db @@ -45,73 +43,88 @@ export async function verifyTargetAccess( ); } - const resourceId = target[0].resourceId; + const { resourceId, providerId } = target[0]; - if (!resourceId) { + if ((!resourceId && !providerId) || (resourceId && providerId)) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - `Target with ID ${targetId} does not have a resource ID` + `Target with ID ${targetId} has invalid ownership` ) ); } try { - const resource = await db - .select() - .from(resources) - .where(eq(resources.resourceId, resourceId!)) - .limit(1); + let orgId: string; - if (resource.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found` - ) - ); - } + if (resourceId) { + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); - if (!resource[0].orgId) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - `resource with ID ${resourceId} does not have an organization ID` - ) - ); + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (!resource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource with ID ${resourceId} does not have an organization ID` + ) + ); + } + + orgId = resource.orgId; + } else { + const [provider] = await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, providerId!)) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + orgId = provider.orgId; } if (!req.userOrg) { - const res = await db + const userOrgResult = await db .select() .from(userOrgs) .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, resource[0].orgId) - ) + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) ); - req.userOrg = res[0]; + req.userOrg = userOrgResult[0]; } - if (!req.userOrg) { - next( + if (!req.userOrg || req.userOrg.orgId !== orgId) { + return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); - } else { - req.userOrgRoleIds = await getUserOrgRoleIds( - req.userOrg.userId, - resource[0].orgId! - ); - req.userOrgId = resource[0].orgId!; } - const orgId = req.userOrg.orgId; + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); + req.userOrgId = orgId; - if (req.orgPolicyAllowed === undefined && orgId) { + if (req.orgPolicyAllowed === undefined) { const policyCheck = await checkOrgAccessPolicy({ orgId, userId, @@ -128,22 +141,24 @@ export async function verifyTargetAccess( } } - const resourceAllowed = await canUserAccessResource({ - userId, - resourceId, - roleIds: req.userOrgRoleIds ?? [] - }); + if (resourceId) { + const resourceAllowed = await canUserAccessResource({ + userId, + resourceId, + roleIds: req.userOrgRoleIds ?? [] + }); - if (!resourceAllowed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "User does not have access to this resource" - ) - ); + if (!resourceAllowed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this resource" + ) + ); + } } - next(); + return next(); } catch (e) { return next( createHttpError( diff --git a/server/routers/aiProvider/createAiModel.ts b/server/routers/aiProvider/createAiModel.ts index a770a5ed8..7fda989d2 100644 --- a/server/routers/aiProvider/createAiModel.ts +++ b/server/routers/aiProvider/createAiModel.ts @@ -15,7 +15,6 @@ import { } from "@server/routers/aiProvider/validation"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), providerId: z.coerce.number().int().positive() }); @@ -33,7 +32,7 @@ const bodySchema = z registry.registerPath({ method: "put", - path: "/org/{orgId}/ai-provider/{providerId}/model", + path: "/ai-provider/{providerId}/model", description: "Create an AI model under a provider.", tags: [OpenAPITags.AiModel], request: { @@ -79,7 +78,7 @@ export async function createAiModel( ); } - const { orgId, providerId } = parsedParams.data; + const { providerId } = parsedParams.data; const { modelKey, name, budgetAmount, budgetUnit, enabled } = parsedBody.data; @@ -89,12 +88,7 @@ export async function createAiModel( : await db .select() .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .limit(1); if (!provider) { diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts index 27420bd34..947810ee1 100644 --- a/server/routers/aiProvider/createAiProvider.ts +++ b/server/routers/aiProvider/createAiProvider.ts @@ -15,6 +15,7 @@ import { aiAuthTypeSchema, aiBudgetUnitSchema, aiProviderTypeSchema, + aiRoutingModeSchema, refineBudgetFields, refineProviderUpstreamFields } from "@server/routers/aiProvider/validation"; @@ -30,6 +31,7 @@ const bodySchema = z upstreamUrl: z.url().optional().nullable(), apiKey: z.string().optional(), authType: aiAuthTypeSchema.optional().nullable(), + routingMode: aiRoutingModeSchema.optional(), skipTlsVerification: z.boolean().optional(), budgetAmount: z.number().positive().optional().nullable(), budgetUnit: aiBudgetUnitSchema.optional().nullable(), @@ -95,6 +97,7 @@ export async function createAiProvider( upstreamUrl, apiKey, authType, + routingMode, skipTlsVerification, budgetAmount, budgetUnit, @@ -105,6 +108,8 @@ export async function createAiProvider( const encryptedApiKey = apiKey ? encrypt(apiKey, key) : null; const apiKeyLastChars = apiKey ? apiKey.slice(-4) : null; const now = Date.now(); + const resolvedRoutingMode = + type === "custom" ? (routingMode ?? "url") : "url"; const [provider] = await db .insert(aiProviders) @@ -112,10 +117,14 @@ export async function createAiProvider( orgId, name, type, - upstreamUrl: upstreamUrl ?? null, + upstreamUrl: + resolvedRoutingMode === "target" + ? null + : (upstreamUrl ?? null), apiKey: encryptedApiKey, apiKeyLastChars, authType: authType ?? null, + routingMode: resolvedRoutingMode, skipTlsVerification: skipTlsVerification ?? false, budgetAmount: budgetAmount ?? null, budgetUnit: budgetUnit ?? null, diff --git a/server/routers/aiProvider/deleteAiModel.ts b/server/routers/aiProvider/deleteAiModel.ts index be784275e..e9c58da79 100644 --- a/server/routers/aiProvider/deleteAiModel.ts +++ b/server/routers/aiProvider/deleteAiModel.ts @@ -1,23 +1,21 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { aiModels, aiProviders, db } from "@server/db"; +import { aiModels, db } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - providerId: z.coerce.number().int().positive(), modelId: z.coerce.number().int().positive() }); registry.registerPath({ method: "delete", - path: "/org/{orgId}/ai-provider/{providerId}/model/{modelId}", + path: "/ai-model/{modelId}", description: "Delete an AI model.", tags: [OpenAPITags.AiModel], request: { @@ -46,22 +44,12 @@ export async function deleteAiModel( ); } - const { orgId, providerId, modelId } = parsedParams.data; + const { modelId } = parsedParams.data; const [existing] = await db .select({ modelId: aiModels.modelId }) .from(aiModels) - .innerJoin( - aiProviders, - eq(aiModels.providerId, aiProviders.providerId) - ) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiModels.modelId, modelId)) .limit(1); if (!existing) { @@ -73,14 +61,7 @@ export async function deleteAiModel( ); } - await db - .delete(aiModels) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, providerId) - ) - ); + await db.delete(aiModels).where(eq(aiModels.modelId, modelId)); return response(res, { data: null, diff --git a/server/routers/aiProvider/deleteAiProvider.ts b/server/routers/aiProvider/deleteAiProvider.ts index b5416c23c..86a606b7a 100644 --- a/server/routers/aiProvider/deleteAiProvider.ts +++ b/server/routers/aiProvider/deleteAiProvider.ts @@ -7,16 +7,15 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), providerId: z.coerce.number().int().positive() }); registry.registerPath({ method: "delete", - path: "/org/{orgId}/ai-provider/{providerId}", + path: "/ai-provider/{providerId}", description: "Delete an AI provider.", tags: [OpenAPITags.AiProvider], request: { @@ -45,17 +44,12 @@ export async function deleteAiProvider( ); } - const { orgId, providerId } = parsedParams.data; + const { providerId } = parsedParams.data; const [existing] = await db .select({ providerId: aiProviders.providerId }) .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .limit(1); if (!existing) { @@ -69,12 +63,7 @@ export async function deleteAiProvider( await db .delete(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ); + .where(eq(aiProviders.providerId, providerId)); return response(res, { data: null, diff --git a/server/routers/aiProvider/getAiModel.ts b/server/routers/aiProvider/getAiModel.ts index 6ea8795ee..45ffbe0ee 100644 --- a/server/routers/aiProvider/getAiModel.ts +++ b/server/routers/aiProvider/getAiModel.ts @@ -1,24 +1,22 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { aiModels, aiProviders, db } from "@server/db"; +import { aiModels, db } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import type { GetAiModelResponse } from "@server/routers/aiProvider/types"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - providerId: z.coerce.number().int().positive(), modelId: z.coerce.number().int().positive() }); registry.registerPath({ method: "get", - path: "/org/{orgId}/ai-provider/{providerId}/model/{modelId}", + path: "/ai-model/{modelId}", description: "Get an AI model by ID.", tags: [OpenAPITags.AiModel], request: { @@ -47,27 +45,16 @@ export async function getAiModel( ); } - const { orgId, providerId, modelId } = parsedParams.data; + const { modelId } = parsedParams.data; const [model] = req.aiModel && req.aiModel.modelId === modelId ? [req.aiModel] : await db - .select({ model: aiModels }) + .select() .from(aiModels) - .innerJoin( - aiProviders, - eq(aiModels.providerId, aiProviders.providerId) - ) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) - .limit(1) - .then((rows) => rows.map((r) => r.model)); + .where(eq(aiModels.modelId, modelId)) + .limit(1); if (!model) { return next( diff --git a/server/routers/aiProvider/getAiProvider.ts b/server/routers/aiProvider/getAiProvider.ts index e2abd3948..32a337c85 100644 --- a/server/routers/aiProvider/getAiProvider.ts +++ b/server/routers/aiProvider/getAiProvider.ts @@ -7,18 +7,17 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import type { GetAiProviderResponse } from "@server/routers/aiProvider/types"; import { toPublicAiProvider } from "@server/routers/aiProvider/types"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), providerId: z.coerce.number().int().positive() }); registry.registerPath({ method: "get", - path: "/org/{orgId}/ai-provider/{providerId}", + path: "/ai-provider/{providerId}", description: "Get an AI provider by ID.", tags: [OpenAPITags.AiProvider], request: { @@ -47,7 +46,7 @@ export async function getAiProvider( ); } - const { orgId, providerId } = parsedParams.data; + const { providerId } = parsedParams.data; const [provider] = req.aiProvider && req.aiProvider.providerId === providerId @@ -55,12 +54,7 @@ export async function getAiProvider( : await db .select() .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .limit(1); if (!provider) { diff --git a/server/routers/aiProvider/listAiModels.ts b/server/routers/aiProvider/listAiModels.ts index a7794efca..753d51499 100644 --- a/server/routers/aiProvider/listAiModels.ts +++ b/server/routers/aiProvider/listAiModels.ts @@ -11,7 +11,6 @@ import { and, asc, eq, like, sql } from "drizzle-orm"; import type { ListAiModelsResponse } from "@server/routers/aiProvider/types"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), providerId: z.coerce.number().int().positive() }); @@ -45,7 +44,7 @@ const listSchema = z.object({ registry.registerPath({ method: "get", - path: "/org/{orgId}/ai-provider/{providerId}/models", + path: "/ai-provider/{providerId}/models", description: "List AI models for a provider.", tags: [OpenAPITags.AiModel], request: { @@ -85,7 +84,7 @@ export async function listAiModels( ); } - const { orgId, providerId } = parsedParams.data; + const { providerId } = parsedParams.data; const [provider] = req.aiProvider && req.aiProvider.providerId === providerId @@ -93,12 +92,7 @@ export async function listAiModels( : await db .select({ providerId: aiProviders.providerId }) .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .limit(1); if (!provider) { diff --git a/server/routers/aiProvider/types.ts b/server/routers/aiProvider/types.ts index 4b8461b5e..2a6047e23 100644 --- a/server/routers/aiProvider/types.ts +++ b/server/routers/aiProvider/types.ts @@ -3,6 +3,7 @@ import type { PaginatedResponse } from "@server/types/Pagination"; import { resolveAiProviderConfig, type AiProviderAuthType, + type AiProviderRoutingMode, type AiProviderType } from "@server/lib/aiProviderDefaults"; @@ -40,7 +41,8 @@ export function toPublicAiProvider(provider: AiProvider): AiProviderPublic { const resolved = resolveAiProviderConfig({ type: provider.type as AiProviderType, upstreamUrl: provider.upstreamUrl, - authType: provider.authType as AiProviderAuthType | null + authType: provider.authType as AiProviderAuthType | null, + routingMode: provider.routingMode as AiProviderRoutingMode | null }); return { diff --git a/server/routers/aiProvider/updateAiModel.ts b/server/routers/aiProvider/updateAiModel.ts index ad09ee823..fa94e8da4 100644 --- a/server/routers/aiProvider/updateAiModel.ts +++ b/server/routers/aiProvider/updateAiModel.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { aiModels, aiProviders, db } from "@server/db"; +import { aiModels, db } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -15,8 +15,6 @@ import { } from "@server/routers/aiProvider/validation"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - providerId: z.coerce.number().int().positive(), modelId: z.coerce.number().int().positive() }); @@ -34,7 +32,7 @@ const bodySchema = z registry.registerPath({ method: "post", - path: "/org/{orgId}/ai-provider/{providerId}/model/{modelId}", + path: "/ai-model/{modelId}", description: "Update an AI model.", tags: [OpenAPITags.AiModel], request: { @@ -80,28 +78,17 @@ export async function updateAiModel( ); } - const { orgId, providerId, modelId } = parsedParams.data; + const { modelId } = parsedParams.data; const body = parsedBody.data; const [existing] = req.aiModel && req.aiModel.modelId === modelId ? [req.aiModel] : await db - .select({ model: aiModels }) + .select() .from(aiModels) - .innerJoin( - aiProviders, - eq(aiModels.providerId, aiProviders.providerId) - ) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) - .limit(1) - .then((rows) => rows.map((r) => r.model)); + .where(eq(aiModels.modelId, modelId)) + .limit(1); if (!existing) { return next( @@ -121,7 +108,7 @@ export async function updateAiModel( .from(aiModels) .where( and( - eq(aiModels.providerId, providerId), + eq(aiModels.providerId, existing.providerId), eq(aiModels.modelKey, body.modelKey), ne(aiModels.modelId, modelId) ) @@ -161,12 +148,7 @@ export async function updateAiModel( const [model] = await db .update(aiModels) .set(updateData) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, providerId) - ) - ) + .where(eq(aiModels.modelId, modelId)) .returning(); return response(res, { diff --git a/server/routers/aiProvider/updateAiProvider.ts b/server/routers/aiProvider/updateAiProvider.ts index 186b5bcd8..494e459f6 100644 --- a/server/routers/aiProvider/updateAiProvider.ts +++ b/server/routers/aiProvider/updateAiProvider.ts @@ -7,7 +7,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; @@ -16,16 +16,16 @@ import { aiAuthTypeSchema, aiBudgetUnitSchema, aiProviderTypeSchema, + aiRoutingModeSchema, refineBudgetFields, refineProviderUpstreamFields } from "@server/routers/aiProvider/validation"; -import { - providerRequiresUpstreamUrl, - type AiProviderType +import type { + AiProviderRoutingMode, + AiProviderType } from "@server/lib/aiProviderDefaults"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), providerId: z.coerce.number().int().positive() }); @@ -35,6 +35,7 @@ const bodySchema = z upstreamUrl: z.url().optional().nullable(), apiKey: z.string().optional(), authType: aiAuthTypeSchema.optional().nullable(), + routingMode: aiRoutingModeSchema.optional(), skipTlsVerification: z.boolean().optional(), budgetAmount: z.number().positive().optional().nullable(), budgetUnit: aiBudgetUnitSchema.optional().nullable(), @@ -46,7 +47,7 @@ const bodySchema = z registry.registerPath({ method: "post", - path: "/org/{orgId}/ai-provider/{providerId}", + path: "/ai-provider/{providerId}", description: "Update an AI provider.", tags: [OpenAPITags.AiProvider], request: { @@ -92,7 +93,7 @@ export async function updateAiProvider( ); } - const { orgId, providerId } = parsedParams.data; + const { providerId } = parsedParams.data; const body = parsedBody.data; const [existing] = @@ -101,12 +102,7 @@ export async function updateAiProvider( : await db .select() .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .limit(1); if (!existing) { @@ -119,6 +115,11 @@ export async function updateAiProvider( } const providerType = existing.type as AiProviderType; + const nextRoutingMode: AiProviderRoutingMode = + providerType === "custom" + ? ((body.routingMode ?? + existing.routingMode) as AiProviderRoutingMode) + : "url"; const nextUpstreamUrl = body.upstreamUrl !== undefined ? body.upstreamUrl @@ -126,38 +127,33 @@ export async function updateAiProvider( const nextAuthType = body.authType !== undefined ? body.authType : existing.authType; - if ( - providerRequiresUpstreamUrl(providerType) || - body.upstreamUrl !== undefined || - body.authType !== undefined - ) { - const validation = z - .object({ - type: aiProviderTypeSchema, - upstreamUrl: z.string().nullable().optional(), - authType: aiAuthTypeSchema.nullable().optional() - }) - .superRefine((data, ctx) => - refineProviderUpstreamFields(data, ctx) - ) - .safeParse({ - type: providerType, - upstreamUrl: nextUpstreamUrl, - authType: nextAuthType - }); + const validation = z + .object({ + type: aiProviderTypeSchema, + upstreamUrl: z.string().nullable().optional(), + authType: aiAuthTypeSchema.nullable().optional(), + routingMode: aiRoutingModeSchema.optional() + }) + .superRefine((data, ctx) => refineProviderUpstreamFields(data, ctx)) + .safeParse({ + type: providerType, + upstreamUrl: nextUpstreamUrl, + authType: nextAuthType, + routingMode: nextRoutingMode + }); - if (!validation.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(validation.error).toString() - ) - ); - } + if (!validation.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(validation.error).toString() + ) + ); } const updateData: Partial = { - updatedAt: Date.now() + updatedAt: Date.now(), + routingMode: nextRoutingMode }; if (body.name !== undefined) { @@ -175,7 +171,9 @@ export async function updateAiProvider( if (body.budgetUnit !== undefined) { updateData.budgetUnit = body.budgetUnit; } - if (body.upstreamUrl !== undefined) { + if (nextRoutingMode === "target") { + updateData.upstreamUrl = null; + } else if (body.upstreamUrl !== undefined) { updateData.upstreamUrl = body.upstreamUrl; } if (body.authType !== undefined) { @@ -191,12 +189,7 @@ export async function updateAiProvider( const [provider] = await db .update(aiProviders) .set(updateData) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .returning(); return response(res, { diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts index ab70800b5..a0b0aa684 100644 --- a/server/routers/aiProvider/validation.ts +++ b/server/routers/aiProvider/validation.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { providerRequiresUpstreamUrl, type AiBudgetUnit, + type AiProviderRoutingMode, type AiProviderType } from "@server/lib/aiProviderDefaults"; @@ -21,6 +22,8 @@ export const aiBudgetUnitSchema = z.enum(["usd", "tokens"]); export const aiAuthTypeSchema = z.enum(["bearer"]); +export const aiRoutingModeSchema = z.enum(["url", "target"]); + export function refineBudgetFields( data: { budgetAmount?: number | null; @@ -47,10 +50,24 @@ export function refineProviderUpstreamFields( type: AiProviderType; upstreamUrl?: string | null; authType?: "bearer" | null; + routingMode?: AiProviderRoutingMode | null; }, ctx: z.RefinementCtx ) { - if (providerRequiresUpstreamUrl(data.type) && !data.upstreamUrl) { + const routingMode = data.routingMode ?? "url"; + + if (data.type !== "custom" && routingMode === "target") { + ctx.addIssue({ + code: "custom", + message: "routingMode target is only allowed for custom providers", + path: ["routingMode"] + }); + } + + if ( + providerRequiresUpstreamUrl(data.type, routingMode) && + !data.upstreamUrl + ) { ctx.addIssue({ code: "custom", message: `upstreamUrl is required for ${data.type} providers`, @@ -58,7 +75,7 @@ export function refineProviderUpstreamFields( }); } - if (data.type === "custom" && !data.authType) { + if (data.type === "custom" && routingMode === "url" && !data.authType) { ctx.addIssue({ code: "custom", message: "authType is required for custom providers", diff --git a/server/routers/external.ts b/server/routers/external.ts index 1d33d6166..99bca3a8e 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1385,16 +1385,31 @@ authenticated.get( ); authenticated.get( - "/org/:orgId/ai-provider/:providerId", - verifyOrgAccess, + "/ai-provider/:providerId", verifyAiProviderAccess, verifyUserHasAction(ActionsEnum.getAiProvider), aiProvider.getAiProvider ); +authenticated.put( + "/ai-provider/:providerId/target", + verifyAiProviderAccess, + verifySiteAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createTarget), + logActionAudit(ActionsEnum.createTarget), + target.createTarget +); + +authenticated.get( + "/ai-provider/:providerId/targets", + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.listTargets), + target.listTargets +); + authenticated.post( - "/org/:orgId/ai-provider/:providerId", - verifyOrgAccess, + "/ai-provider/:providerId", verifyAiProviderAccess, verifyUserHasAction(ActionsEnum.updateAiProvider), logActionAudit(ActionsEnum.updateAiProvider), @@ -1402,8 +1417,7 @@ authenticated.post( ); authenticated.delete( - "/org/:orgId/ai-provider/:providerId", - verifyOrgAccess, + "/ai-provider/:providerId", verifyAiProviderAccess, verifyUserHasAction(ActionsEnum.deleteAiProvider), logActionAudit(ActionsEnum.deleteAiProvider), @@ -1411,8 +1425,7 @@ authenticated.delete( ); authenticated.put( - "/org/:orgId/ai-provider/:providerId/model", - verifyOrgAccess, + "/ai-provider/:providerId/model", verifyAiProviderAccess, verifyUserHasAction(ActionsEnum.createAiModel), logActionAudit(ActionsEnum.createAiModel), @@ -1420,24 +1433,21 @@ authenticated.put( ); authenticated.get( - "/org/:orgId/ai-provider/:providerId/models", - verifyOrgAccess, + "/ai-provider/:providerId/models", verifyAiProviderAccess, verifyUserHasAction(ActionsEnum.listAiModels), aiProvider.listAiModels ); authenticated.get( - "/org/:orgId/ai-provider/:providerId/model/:modelId", - verifyOrgAccess, + "/ai-model/:modelId", verifyAiModelAccess, verifyUserHasAction(ActionsEnum.getAiModel), aiProvider.getAiModel ); authenticated.post( - "/org/:orgId/ai-provider/:providerId/model/:modelId", - verifyOrgAccess, + "/ai-model/:modelId", verifyAiModelAccess, verifyUserHasAction(ActionsEnum.updateAiModel), logActionAudit(ActionsEnum.updateAiModel), @@ -1445,8 +1455,7 @@ authenticated.post( ); authenticated.delete( - "/org/:orgId/ai-provider/:providerId/model/:modelId", - verifyOrgAccess, + "/ai-model/:modelId", verifyAiModelAccess, verifyUserHasAction(ActionsEnum.deleteAiModel), logActionAudit(ActionsEnum.deleteAiModel), diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 3a17079c6..384214e4c 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -1386,16 +1386,30 @@ authenticated.get( ); authenticated.get( - "/org/:orgId/ai-provider/:providerId", - verifyApiKeyOrgAccess, + "/ai-provider/:providerId", verifyApiKeyAiProviderAccess, verifyApiKeyHasAction(ActionsEnum.getAiProvider), aiProvider.getAiProvider ); +authenticated.put( + "/ai-provider/:providerId/target", + verifyApiKeyAiProviderAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createTarget), + logActionAudit(ActionsEnum.createTarget), + target.createTarget +); + +authenticated.get( + "/ai-provider/:providerId/targets", + verifyApiKeyAiProviderAccess, + verifyApiKeyHasAction(ActionsEnum.listTargets), + target.listTargets +); + authenticated.post( - "/org/:orgId/ai-provider/:providerId", - verifyApiKeyOrgAccess, + "/ai-provider/:providerId", verifyApiKeyAiProviderAccess, verifyApiKeyHasAction(ActionsEnum.updateAiProvider), logActionAudit(ActionsEnum.updateAiProvider), @@ -1403,8 +1417,7 @@ authenticated.post( ); authenticated.delete( - "/org/:orgId/ai-provider/:providerId", - verifyApiKeyOrgAccess, + "/ai-provider/:providerId", verifyApiKeyAiProviderAccess, verifyApiKeyHasAction(ActionsEnum.deleteAiProvider), logActionAudit(ActionsEnum.deleteAiProvider), @@ -1412,8 +1425,7 @@ authenticated.delete( ); authenticated.put( - "/org/:orgId/ai-provider/:providerId/model", - verifyApiKeyOrgAccess, + "/ai-provider/:providerId/model", verifyApiKeyAiProviderAccess, verifyApiKeyHasAction(ActionsEnum.createAiModel), logActionAudit(ActionsEnum.createAiModel), @@ -1421,24 +1433,21 @@ authenticated.put( ); authenticated.get( - "/org/:orgId/ai-provider/:providerId/models", - verifyApiKeyOrgAccess, + "/ai-provider/:providerId/models", verifyApiKeyAiProviderAccess, verifyApiKeyHasAction(ActionsEnum.listAiModels), aiProvider.listAiModels ); authenticated.get( - "/org/:orgId/ai-provider/:providerId/model/:modelId", - verifyApiKeyOrgAccess, + "/ai-model/:modelId", verifyApiKeyAiModelAccess, verifyApiKeyHasAction(ActionsEnum.getAiModel), aiProvider.getAiModel ); authenticated.post( - "/org/:orgId/ai-provider/:providerId/model/:modelId", - verifyApiKeyOrgAccess, + "/ai-model/:modelId", verifyApiKeyAiModelAccess, verifyApiKeyHasAction(ActionsEnum.updateAiModel), logActionAudit(ActionsEnum.updateAiModel), @@ -1446,8 +1455,7 @@ authenticated.post( ); authenticated.delete( - "/org/:orgId/ai-provider/:providerId/model/:modelId", - verifyApiKeyOrgAccess, + "/ai-model/:modelId", verifyApiKeyAiModelAccess, verifyApiKeyHasAction(ActionsEnum.deleteAiModel), logActionAudit(ActionsEnum.deleteAiModel), diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index ee33a2c94..e1782fd40 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -15,7 +15,7 @@ import { } from "@server/db"; import logger from "@server/logger"; import { initPeerAddHandshake, updatePeer } from "../olm/peers"; -import { eq, and, inArray } from "drizzle-orm"; +import { eq, and, inArray, or, isNotNull, sql } from "drizzle-orm"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { @@ -211,7 +211,8 @@ export async function buildClientConfigurationForNewtClient( // call rather than letting each resource fetch its own — with thousands // of resources this avoids a concurrent DB/cache stampede for what is // often the very same (e.g. wildcard) certificate. - const certByDomain = await batchFetchCertsForSiteResources(allSiteResources); + const certByDomain = + await batchFetchCertsForSiteResources(allSiteResources); const resourceTargetsArr = await Promise.all( allSiteResources.map((resource) => @@ -240,7 +241,7 @@ export async function buildTargetConfigurationForNewtClient( version?: string | null, remoteExitNodeId?: string ) { - // Get all enabled targets with their resource mode information + // Get enabled HTTP/TCP/UDP targets for resources and AI providers const allTargets = await db .select({ resourceId: targets.resourceId, @@ -250,15 +251,18 @@ export async function buildTargetConfigurationForNewtClient( port: targets.port, internalPort: targets.internalPort, enabled: targets.enabled, - mode: resources.mode + mode: sql`COALESCE(${resources.mode}, ${targets.mode})`.mapWith( + String + ) }) .from(targets) - .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .leftJoin(resources, eq(targets.resourceId, resources.resourceId)) .where( and( eq(targets.siteId, siteId), eq(targets.enabled, true), - inArray(targets.mode, ["http", "udp", "tcp"]) + inArray(targets.mode, ["http", "udp", "tcp"]), + or(isNotNull(targets.resourceId), isNotNull(targets.providerId)) ) ); diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 84fa00cec..fbcaa8439 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -6,7 +6,14 @@ import { TargetHealthCheck, targetHealthCheck } from "@server/db"; -import { newts, resources, sites, Target, targets } from "@server/db"; +import { + aiProviders, + newts, + resources, + sites, + Target, + targets +} from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -29,10 +36,19 @@ import { generateId } from "@server/auth/sessions/app"; import config from "@server/lib/config"; import { sendBrowserGatewayTargets } from "@server/routers/newt/targets"; -const createTargetParamsSchema = z.strictObject({ +const resourceTargetParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); +const providerTargetParamsSchema = z.strictObject({ + providerId: z.coerce.number().int().positive() +}); + +const createTargetParamsSchema = z.union([ + resourceTargetParamsSchema, + providerTargetParamsSchema +]); + const createTargetSchema = z .strictObject({ siteId: z.int().positive(), @@ -95,7 +111,7 @@ registry.registerPath({ description: "Create a target for a resource.", tags: [OpenAPITags.PublicResourceLegacy], request: { - params: createTargetParamsSchema, + params: resourceTargetParamsSchema, body: { content: { "application/json": { @@ -128,7 +144,40 @@ registry.registerPath({ description: "Create a target for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Target], request: { - params: createTargetParamsSchema, + params: resourceTargetParamsSchema, + body: { + content: { + "application/json": { + schema: createTargetSchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "put", + path: "/ai-provider/{providerId}/target", + description: "Create a target for an AI provider.", + tags: [OpenAPITags.AiProvider], + request: { + params: providerTargetParamsSchema, body: { content: { "application/json": { @@ -183,21 +232,74 @@ export async function createTarget( ); } - const { resourceId } = parsedParams.data; + let resource: typeof resources.$inferSelect | undefined; + let provider: typeof aiProviders.$inferSelect | undefined; - // get the resource - const [resource] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, resourceId)); + if ("providerId" in parsedParams.data) { + const { providerId } = parsedParams.data; + [provider] = + req.aiProvider && req.aiProvider.providerId === providerId + ? [req.aiProvider] + : await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, providerId)) + .limit(1); - if (!resource) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found` - ) - ); + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + if (provider.routingMode !== "target") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI provider must use target routing mode" + ) + ); + } + + if (provider.type !== "custom") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Only custom AI providers support targets" + ) + ); + } + + if ( + targetData.method && + !["http", "https"].includes(targetData.method.toLowerCase()) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI provider target method must be http or https" + ) + ); + } + } else { + const { resourceId } = parsedParams.data; + [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } } const siteId = targetData.siteId; @@ -217,6 +319,24 @@ export async function createTarget( ); } + if (provider && site.orgId && site.orgId !== provider.orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Site must belong to the AI provider organization" + ) + ); + } + + const resourceId = resource?.resourceId ?? null; + const providerId = provider?.providerId ?? null; + const targetMode = provider + ? "http" + : (targetData.mode ?? resource?.mode ?? "http"); + const targetMethod = provider + ? (targetData.method?.toLowerCase() ?? "https") + : targetData.method; + const plainToken = generateId(48); const encryptedToken = encrypt( plainToken, @@ -230,20 +350,24 @@ export async function createTarget( const existingTargets = await trx .select() .from(targets) - .where(eq(targets.resourceId, resourceId)); + .where( + providerId + ? eq(targets.providerId, providerId) + : eq(targets.resourceId, resourceId!) + ); const existingTarget = existingTargets.find( (target) => target.ip === targetData.ip && target.port === targetData.port && - target.method === targetData.method && + target.method === targetMethod && target.siteId === targetData.siteId ); if (existingTarget) { // log a warning logger.warn( - `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}` + `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetMethod} already exists for ${providerId ? `AI provider ID ${providerId}` : `resource ID ${resourceId}`}` ); } @@ -252,10 +376,10 @@ export async function createTarget( .insert(targets) .values({ resourceId, + providerId, ...targetData, - mode: (targetData.mode ?? - resource.mode ?? - "http") as Target["mode"], + mode: targetMode as Target["mode"], + method: targetMethod, priority: targetData.priority || 100 }) .returning(); @@ -289,13 +413,12 @@ export async function createTarget( .insert(targets) .values({ resourceId, + providerId, siteId: site.siteId, ip: targetData.ip, - mode: (targetData.mode ?? - resource.mode ?? - "http") as Target["mode"], + mode: targetMode as Target["mode"], authToken: encryptedToken, - method: targetData.method, + method: targetMethod, port: targetData.port, internalPort, enabled: targetData.enabled, @@ -321,10 +444,12 @@ export async function createTarget( healthCheck = await trx .insert(targetHealthCheck) .values({ - orgId: resource.orgId, + orgId: provider?.orgId ?? resource!.orgId, targetId: newTarget[0].targetId, siteId: targetData.siteId, - name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, + name: provider + ? `AI Provider ${provider.name} - ${targetData.ip}:${targetData.port}` + : `Resource ${resource!.name} - ${targetData.ip}:${targetData.port}`, hcEnabled: targetData.hcEnabled ?? false, hcPath: targetData.hcPath ?? null, hcScheme: targetData.hcScheme ?? null, @@ -399,10 +524,17 @@ export async function createTarget( newt.newtId, newTarget, healthCheck, - resource.mode === "udp" ? "udp" : "tcp", + provider + ? "tcp" + : (resource!.mode as string) === "udp" + ? "udp" + : "tcp", newt.version ); - } else if (["ssh", "rdp", "vnc"].includes(newTarget[0].mode)) { + } else if ( + !provider && + ["ssh", "rdp", "vnc"].includes(newTarget[0].mode) + ) { await sendBrowserGatewayTargets( newt.newtId, newTarget, diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index a959e10eb..8baf17d66 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -79,38 +79,54 @@ export async function deleteTarget( ) ); } - // get the resource - const [resource] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, deletedTarget.resourceId!)); - if (!resource) { + if ( + (!deletedTarget.resourceId && !deletedTarget.providerId) || + (deletedTarget.resourceId && deletedTarget.providerId) + ) { return next( createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${deletedTarget.resourceId} not found` + HttpCode.INTERNAL_SERVER_ERROR, + `Target with ID ${targetId} has invalid ownership` ) ); } - // check if there are other targets on the resource - const otherTargets = await db - .select() - .from(targets) - .where( - and( - eq(targets.resourceId, resource.resourceId), - ne(targets.targetId, targetId) - ) - ); + let resource: typeof resources.$inferSelect | undefined; + if (deletedTarget.resourceId) { + [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, deletedTarget.resourceId)) + .limit(1); - if (otherTargets.length == 0) { - // set the resource status - await db - .update(resources) - .set({ health: "unknown" }) - .where(eq(resources.resourceId, resource.resourceId)); + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${deletedTarget.resourceId} not found` + ) + ); + } + + // check if there are other targets on the resource + const otherTargets = await db + .select() + .from(targets) + .where( + and( + eq(targets.resourceId, resource.resourceId), + ne(targets.targetId, targetId) + ) + ); + + if (otherTargets.length == 0) { + // set the resource status + await db + .update(resources) + .set({ health: "unknown" }) + .where(eq(resources.resourceId, resource.resourceId)); + } } const [site] = await db @@ -137,16 +153,26 @@ export async function deleteTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - if (["http", "tcp", "udp"].includes(deletedTarget.mode)) { + if ( + deletedTarget.providerId || + ["http", "tcp", "udp"].includes(deletedTarget.mode) + ) { await removeTargets( newt.newtId, // [deletedTarget], [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this [deletedHealthCheck], - resource.mode === "udp" ? "udp" : "tcp", + deletedTarget.providerId + ? "tcp" + : (resource!.mode as string) === "udp" + ? "udp" + : "tcp", newt.version ); - } else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) { + } else if ( + !deletedTarget.providerId && + ["ssh", "rdp", "vnc"].includes(deletedTarget.mode) + ) { await removeBrowserGatewayTarget( newt.newtId, deletedTarget.targetId, diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index f2d16dd21..fc6058429 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -10,10 +10,19 @@ import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -const listTargetsParamsSchema = z.strictObject({ +const resourceTargetsParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); +const providerTargetsParamsSchema = z.strictObject({ + providerId: z.coerce.number().int().positive() +}); + +const listTargetsParamsSchema = z.union([ + resourceTargetsParamsSchema, + providerTargetsParamsSchema +]); + const listTargetsSchema = z.strictObject({ limit: z .string() @@ -29,7 +38,7 @@ const listTargetsSchema = z.strictObject({ .pipe(z.int().nonnegative()) }); -function queryTargets(resourceId: number) { +function queryTargets(owner: { resourceId: number } | { providerId: number }) { const baseQuery = db .select({ targetId: targets.targetId, @@ -39,6 +48,7 @@ function queryTargets(resourceId: number) { port: targets.port, enabled: targets.enabled, resourceId: targets.resourceId, + providerId: targets.providerId, siteId: targets.siteId, siteType: sites.type, siteName: sites.name, @@ -71,7 +81,11 @@ function queryTargets(resourceId: number) { targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) ) - .where(eq(targets.resourceId, resourceId)); + .where( + "providerId" in owner + ? eq(targets.providerId, owner.providerId) + : eq(targets.resourceId, owner.resourceId) + ); return baseQuery; } @@ -94,7 +108,7 @@ registry.registerPath({ description: "List targets for a resource.", tags: [OpenAPITags.PublicResourceLegacy], request: { - params: listTargetsParamsSchema, + params: resourceTargetsParamsSchema, query: listTargetsSchema }, responses: { @@ -121,7 +135,34 @@ registry.registerPath({ description: "List targets for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Target], request: { - params: listTargetsParamsSchema, + params: resourceTargetsParamsSchema, + query: listTargetsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/ai-provider/{providerId}/targets", + description: "List targets for an AI provider.", + tags: [OpenAPITags.AiProvider], + request: { + params: providerTargetsParamsSchema, query: listTargetsSchema }, responses: { @@ -168,14 +209,18 @@ export async function listTargets( ) ); } - const { resourceId } = parsedParams.data; + const owner = parsedParams.data; + const ownerCondition = + "providerId" in owner + ? eq(targets.providerId, owner.providerId) + : eq(targets.resourceId, owner.resourceId); - const baseQuery = queryTargets(resourceId); + const baseQuery = queryTargets(owner); const countQuery = db .select({ count: sql`cast(count(*) as integer)` }) .from(targets) - .where(eq(targets.resourceId, resourceId)); + .where(ownerCondition); const targetsList = await baseQuery.limit(limit).offset(offset); const totalCountResult = await countQuery; diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 52bf3e578..e1ca95f5f 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, targetHealthCheck } from "@server/db"; -import { newts, resources, sites, targets } from "@server/db"; +import { aiProviders, newts, resources, sites, targets } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -147,21 +147,68 @@ export async function updateTarget( ); } - // get the resource - const [resource] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, target.resourceId!)); - - if (!resource) { + if ( + (!target.resourceId && !target.providerId) || + (target.resourceId && target.providerId) + ) { return next( createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${target.resourceId} not found` + HttpCode.INTERNAL_SERVER_ERROR, + `Target with ID ${targetId} has invalid ownership` ) ); } + let resource: typeof resources.$inferSelect | undefined; + let provider: typeof aiProviders.$inferSelect | undefined; + + if (target.resourceId) { + [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, target.resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${target.resourceId} not found` + ) + ); + } + } else { + [provider] = await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, target.providerId!)) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${target.providerId} not found` + ) + ); + } + + if ( + parsedBody.data.method !== undefined && + (!parsedBody.data.method || + !["http", "https"].includes( + parsedBody.data.method.toLowerCase() + )) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI provider target method must be http or https" + ) + ); + } + } + const [site] = await db .select() .from(sites) @@ -177,6 +224,15 @@ export async function updateTarget( ); } + if (provider && site.orgId && site.orgId !== provider.orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Site must belong to the AI provider organization" + ) + ); + } + const { internalPort, targetIps } = await pickPort(site.siteId!, db); if (!internalPort) { @@ -221,8 +277,13 @@ export async function updateTarget( } const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; - const nextMode = - parsedBody.data.mode === null ? undefined : parsedBody.data.mode; + const nextMode = provider + ? parsedBody.data.mode !== undefined + ? "http" + : undefined + : parsedBody.data.mode === null + ? undefined + : parsedBody.data.mode; let updatedTarget: any; let updatedHc: any; @@ -233,7 +294,10 @@ export async function updateTarget( siteId: parsedBody.data.siteId, ip: parsedBody.data.ip, mode: nextMode, - method: parsedBody.data.method, + method: + provider && parsedBody.data.method + ? parsedBody.data.method.toLowerCase() + : parsedBody.data.method, port: parsedBody.data.port, internalPort, enabled: parsedBody.data.enabled, @@ -368,15 +432,25 @@ export async function updateTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - if (["http", "tcp", "udp"].includes(updatedTarget.mode)) { + if ( + provider || + ["http", "tcp", "udp"].includes(updatedTarget.mode) + ) { await addTargets( newt.newtId, [updatedTarget], [updatedHc], - resource.mode === "udp" ? "udp" : "tcp", + provider + ? "tcp" + : (resource!.mode as string) === "udp" + ? "udp" + : "tcp", newt.version ); - } else if (["ssh", "rdp", "vnc"].includes(updatedTarget.mode)) { + } else if ( + !provider && + ["ssh", "rdp", "vnc"].includes(updatedTarget.mode) + ) { await sendBrowserGatewayTargets( newt.newtId, [updatedTarget], diff --git a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx index b8042b2f3..0db4cb156 100644 --- a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx +++ b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx @@ -596,6 +596,7 @@ export function ProxyResourceTargetsForm({ priority: 100, enabled: true, resourceId: resource?.resourceId ?? 0, + providerId: null, hcEnabled: false, hcPath: null, hcMethod: null, From 1073011a2a8bc98102ffc8ef8e8e5f1cbaec5f5f Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 2 Aug 2026 10:16:36 -0400 Subject: [PATCH 181/494] first pass of traefik -> basic gateway --- server/db/pg/schema/schema.ts | 42 ++- server/db/sqlite/schema/schema.ts | 42 ++- server/lib/traefik/TraefikConfigManager.ts | 6 +- server/lib/traefik/getTraefikConfig.ts | 121 +++++++- .../private/lib/traefik/getTraefikConfig.ts | 269 +++++++++++++++++- server/private/routers/hybrid.ts | 4 +- server/routers/aiGateway/chatCompletions.ts | 256 +++++++++++++++++ server/routers/aiGateway/index.ts | 1 + server/routers/internal.ts | 8 + server/routers/resource/createResource.ts | 21 +- server/routers/resource/updateResource.ts | 9 + .../siteResource/createSiteResource.ts | 16 +- .../siteResource/updateSiteResource.ts | 15 +- .../routers/traefik/traefikConfigProvider.ts | 6 +- 14 files changed, 795 insertions(+), 21 deletions(-) create mode 100644 server/routers/aiGateway/chatCompletions.ts create mode 100644 server/routers/aiGateway/index.ts diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 0f59b1c26..e253cd6d5 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -210,7 +210,11 @@ export const resources = pgTable( authDaemonPort: integer("authDaemonPort").default(22123), status: varchar("status") .$type<"pending" | "approved">() - .default("approved") + .default("approved"), + aiProviderId: integer("aiProviderId").references( + () => aiProviders.providerId, + { onDelete: "set null" } + ) }, (t) => [ index("idx_resources_fulldomain") @@ -221,6 +225,19 @@ export const resources = pgTable( ] ); +export const resourceAiModels = pgTable( + "resourceAiModels", + { + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + modelId: integer("modelId") + .notNull() + .references(() => aiModels.modelId, { onDelete: "cascade" }) + }, + (t) => [primaryKey({ columns: [t.resourceId, t.modelId] })] +); + export const labels = pgTable("labels", { labelId: serial("labelId").primaryKey(), name: varchar("name").notNull(), @@ -475,11 +492,30 @@ export const siteResources = pgTable( fullDomain: varchar("fullDomain"), status: varchar("status") .$type<"pending" | "approved">() - .default("approved") + .default("approved"), + aiProviderId: integer("aiProviderId").references( + () => aiProviders.providerId, + { onDelete: "set null" } + ) }, (t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)] ); +export const siteResourceAiModels = pgTable( + "siteResourceAiModels", + { + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { + onDelete: "cascade" + }), + modelId: integer("modelId") + .notNull() + .references(() => aiModels.modelId, { onDelete: "cascade" }) + }, + (t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })] +); + export const networks = pgTable( "networks", { @@ -1698,3 +1734,5 @@ export type UserPolicy = InferSelectModel; export type ResourcePolicyRule = InferSelectModel; export type AiProvider = InferSelectModel; export type AiModel = InferSelectModel; +export type ResourceAiModel = InferSelectModel; +export type SiteResourceAiModel = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 73e73f04a..3d3ff2ded 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -215,9 +215,26 @@ export const resources = sqliteTable("resources", { .$type<"site" | "remote" | "native">() .default("site"), authDaemonPort: integer("authDaemonPort").default(22123), - status: text("status").$type<"pending" | "approved">().default("approved") + status: text("status").$type<"pending" | "approved">().default("approved"), + aiProviderId: integer("aiProviderId").references( + () => aiProviders.providerId, + { onDelete: "set null" } + ) }); +export const resourceAiModels = sqliteTable( + "resourceAiModels", + { + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + modelId: integer("modelId") + .notNull() + .references(() => aiModels.modelId, { onDelete: "cascade" }) + }, + (t) => [primaryKey({ columns: [t.resourceId, t.modelId] })] +); + export const labels = sqliteTable("labels", { labelId: integer("labelId").primaryKey({ autoIncrement: true }), name: text("name").notNull(), @@ -462,9 +479,28 @@ export const siteResources = sqliteTable("siteResources", { }), subdomain: text("subdomain"), fullDomain: text("fullDomain"), - status: text("status").$type<"pending" | "approved">().default("approved") + status: text("status").$type<"pending" | "approved">().default("approved"), + aiProviderId: integer("aiProviderId").references( + () => aiProviders.providerId, + { onDelete: "set null" } + ) }); +export const siteResourceAiModels = sqliteTable( + "siteResourceAiModels", + { + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { + onDelete: "cascade" + }), + modelId: integer("modelId") + .notNull() + .references(() => aiModels.modelId, { onDelete: "cascade" }) + }, + (t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })] +); + export const networks = sqliteTable("networks", { networkId: integer("networkId").primaryKey({ autoIncrement: true }), niceId: text("niceId"), @@ -1680,3 +1716,5 @@ export type RolePolicy = InferSelectModel; export type UserPolicy = InferSelectModel; export type AiProvider = InferSelectModel; export type AiModel = InferSelectModel; +export type ResourceAiModel = InferSelectModel; +export type SiteResourceAiModel = InferSelectModel; diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index cc7299ff7..73d46ee64 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -516,6 +516,9 @@ export class TraefikConfigManager { const maintenanceHost = config.getRawConfig().server.internal_hostname; const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`; + const aiGatewayUrl = `http://${maintenanceHost}:${ + config.getRawConfig().server.internal_port + }/api/v1/ai-gateway`; // logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`); traefikConfig = await getTraefikConfig( @@ -528,7 +531,8 @@ export class TraefikConfigManager { ? false : config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config pangolinUIUrl, // generate maintenance pages on cloud and hybrid - pangolinUIUrl // generate browser gateway targets on cloud and hybrid + pangolinUIUrl, // generate browser gateway targets on cloud and hybrid + aiGatewayUrl ); const domains = new Set(); diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 3e84cd570..4f5dd3860 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -1,4 +1,4 @@ -import { db, targetHealthCheck, domains } from "@server/db"; +import { db, targetHealthCheck, domains, aiProviders } from "@server/db"; import { and, eq, @@ -45,7 +45,8 @@ export async function getTraefikConfig( generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE allowRawResources = true, maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE - browserGatewayUiUrl: string | null = null // UNUSED BUT USED IN PRIVATE + browserGatewayUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE + aiGatewayUrl: string | null = null ): Promise { // Get resources with their targets and sites in a single optimized query // Start from sites on this exit node, then join to targets and resources @@ -209,8 +210,37 @@ export async function getTraefikConfig( }); }); + // Inference-mode resources have no targets/sites (their "backend" is the + // central AI gateway), so they can't be reached via the targets->sites + // join above - query them separately and include them on every exit node. + const inferenceResources = await db + .select({ + resourceId: resources.resourceId, + resourceName: resources.name, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + subdomain: resources.subdomain, + domainId: resources.domainId, + enabled: resources.enabled, + domainCertResolver: domains.certResolver, + preferWildcardCert: domains.preferWildcardCert + }) + .from(resources) + .innerJoin( + aiProviders, + eq(resources.aiProviderId, aiProviders.providerId) + ) + .leftJoin(domains, eq(domains.domainId, resources.domainId)) + .where( + and( + eq(resources.mode, "inference"), + eq(resources.enabled, true), + eq(aiProviders.enabled, true) + ) + ); + // make sure we have at least one resource - if (resourcesMap.size === 0) { + if (resourcesMap.size === 0 && inferenceResources.length === 0) { return {}; } @@ -673,5 +703,90 @@ export async function getTraefikConfig( }; } } + + if (aiGatewayUrl) { + for (const ir of inferenceResources) { + if (!ir.enabled) continue; + if (!ir.domainId || !ir.fullDomain) continue; + + if (!config_output.http.routers) config_output.http.routers = {}; + if (!config_output.http.services) config_output.http.services = {}; + + const fullDomain = ir.fullDomain; + const irKey = `inference-r${ir.resourceId}`; + const routerName = `${irKey}-router`; + const serviceName = `${irKey}-service`; + const rule = `Host(\`${fullDomain}\`)`; + + const domainParts = fullDomain.split("."); + let wildCard; + if (domainParts.length <= 2) { + wildCard = `*.${domainParts.join(".")}`; + } else { + wildCard = `*.${domainParts.slice(1).join(".")}`; + } + if (!ir.subdomain) { + wildCard = fullDomain; + } + + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; + const resolverName = ir.domainCertResolver + ? ir.domainCertResolver.trim() + : globalDefaultResolver; + const preferWildcard = + ir.preferWildcardCert !== undefined && + ir.preferWildcardCert !== null + ? ir.preferWildcardCert + : globalDefaultPreferWildcard; + + const tls = { + certResolver: resolverName, + ...(preferWildcard ? { domains: [{ main: wildCard }] } : {}) + }; + + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; + const routerMiddlewares = [ + badgerMiddlewareName, + ...additionalMiddlewares + ]; + + config_output.http.routers[routerName] = { + entryPoints: [ + ir.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint + ], + middlewares: routerMiddlewares, + service: serviceName, + rule, + priority: 100, + ...(ir.ssl ? { tls } : {}) + }; + + if (ir.ssl) { + config_output.http.routers[routerName + "-redirect"] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [redirectHttpsMiddlewareName], + service: serviceName, + rule, + priority: 100 + }; + } + + config_output.http.services[serviceName] = { + loadBalancer: { + servers: [{ url: `${aiGatewayUrl}/chat/completions` }], + passHostHeader: true + } + }; + } + } + return config_output; } diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 0544cafe7..8e1b1eca2 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -41,7 +41,8 @@ import { siteNetworks, siteResources, Target, - targets + targets, + aiProviders } from "@server/db"; import { sanitize, @@ -88,7 +89,8 @@ export async function getTraefikConfig( generateLoginPageRouters = false, allowRawResources = true, maintenancePageUiUrl: string | null = null, - browserGatewayUiUrl: string | null = null + browserGatewayUiUrl: string | null = null, + aiGatewayUrl: string | null = null ): Promise { // Get resources with their targets and sites in a single optimized query // Start from sites on this exit node, then join to targets and resources @@ -395,6 +397,65 @@ export async function getTraefikConfig( ); } + // Inference-mode resources/siteResources have no targets/sites/network + // (their "backend" is the central AI gateway, not something on a site), + // so they can't be reached via the joins above - query them separately + // and include them on every exit node. + const inferenceResources = await db + .select({ + resourceId: resources.resourceId, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + subdomain: resources.subdomain, + domainId: resources.domainId, + enabled: resources.enabled, + wildcard: resources.wildcard, + domainCertResolver: domains.certResolver, + preferWildcardCert: domains.preferWildcardCert + }) + .from(resources) + .innerJoin( + aiProviders, + eq(resources.aiProviderId, aiProviders.providerId) + ) + .leftJoin(domains, eq(domains.domainId, resources.domainId)) + .where( + and( + eq(resources.mode, "inference"), + eq(resources.enabled, true), + eq(aiProviders.enabled, true) + ) + ); + + let siteResourcesInference: { + siteResourceId: number; + alias: string | null; + ssl: boolean | null; + enabled: boolean | null; + }[] = []; + if (build == "enterprise") { + siteResourcesInference = await db + .select({ + siteResourceId: siteResources.siteResourceId, + alias: siteResources.alias, + ssl: siteResources.ssl, + enabled: siteResources.enabled + }) + .from(siteResources) + .innerJoin( + aiProviders, + eq(siteResources.aiProviderId, aiProviders.providerId) + ) + .where( + and( + eq(siteResources.mode, "inference"), + eq(siteResources.enabled, true), + eq(aiProviders.enabled, true), + isNotNull(siteResources.alias) + ) + ); + } + let validCerts: CertificateResult[] = []; if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { // create a list of all domains to get certs for @@ -416,6 +477,17 @@ export async function getTraefikConfig( domains.add(bgResource.fullDomain); } } + // Include inference resource/siteResource domains + for (const ir of inferenceResources) { + if (ir.enabled && ir.ssl && ir.fullDomain) { + domains.add(ir.fullDomain); + } + } + for (const sr of siteResourcesInference) { + if (sr.enabled && sr.ssl && sr.alias) { + domains.add(sr.alias); + } + } // get the valid certs for these domains validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); @@ -1462,6 +1534,199 @@ export async function getTraefikConfig( } } + if (aiGatewayUrl) { + // Public inference resources: same TLS/cert-resolver handling as + // plain http-mode resources, but the service points at the AI + // gateway instead of any real backend targets. + for (const ir of inferenceResources) { + if (!ir.enabled) continue; + if (!ir.domainId || !ir.fullDomain) continue; + + if (!config_output.http.routers) config_output.http.routers = {}; + if (!config_output.http.services) config_output.http.services = {}; + + const fullDomain = ir.fullDomain; + const irKey = `inference-r${ir.resourceId}`; + const routerName = `${irKey}-router`; + const serviceName = `${irKey}-service`; + + let rule: string; + if (ir.wildcard && fullDomain.startsWith("*.")) { + const escaped = fullDomain.slice(2).replace(/\./g, "\\."); + rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`; + } else { + rule = `Host(\`${fullDomain}\`)`; + } + + let tls: any = {}; + if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { + const domainParts = fullDomain.split("."); + let wildCard; + if (domainParts.length <= 2) { + wildCard = `*.${domainParts.join(".")}`; + } else { + wildCard = `*.${domainParts.slice(1).join(".")}`; + } + if (!ir.subdomain) { + wildCard = fullDomain; + } + + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; + const resolverName = ir.domainCertResolver + ? ir.domainCertResolver.trim() + : globalDefaultResolver; + const preferWildcard = + ir.preferWildcardCert !== undefined && + ir.preferWildcardCert !== null + ? ir.preferWildcardCert + : globalDefaultPreferWildcard; + + tls = { + certResolver: resolverName, + ...(preferWildcard + ? { domains: [{ main: wildCard }] } + : {}) + }; + } else { + const matchingCert = validCerts.find( + (cert) => cert.queriedDomain === fullDomain + ); + if (!matchingCert) { + logger.debug( + `No matching certificate found for inference resource domain: ${fullDomain}` + ); + continue; + } + } + + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; + const routerMiddlewares = [ + badgerMiddlewareName, + ...additionalMiddlewares + ]; + + if (ir.ssl) { + config_output.http.routers[routerName + "-redirect"] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [redirectHttpsMiddlewareName], + service: serviceName, + rule, + priority: 100 + }; + } + + config_output.http.routers[routerName] = { + entryPoints: [ + ir.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint + ], + middlewares: routerMiddlewares, + service: serviceName, + rule, + priority: 100, + ...(ir.ssl ? { tls } : {}) + }; + + config_output.http.services[serviceName] = { + loadBalancer: { + servers: [{ url: `${aiGatewayUrl}/chat/completions` }], + passHostHeader: true + } + }; + } + + // Private (siteResource) inference resources: routed by their alias + // instead of a public fullDomain, and deliberately WITHOUT the + // badger middleware - no per-user auth/policy stack exists for + // siteResources today (see plan doc), so gating here is + // reachability-only for now. + for (const sr of siteResourcesInference) { + if (!sr.enabled || !sr.alias) continue; + + if (!config_output.http.routers) config_output.http.routers = {}; + if (!config_output.http.services) config_output.http.services = {}; + + const alias = sr.alias; + const srKey = `inference-sr${sr.siteResourceId}`; + const routerName = `${srKey}-router`; + const serviceName = `${srKey}-service`; + const rule = `Host(\`${alias}\`)`; + + let tls: any = {}; + if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { + const domainParts = alias.split("."); + const wildCard = + domainParts.length <= 2 + ? `*.${domainParts.join(".")}` + : `*.${domainParts.slice(1).join(".")}`; + + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; + + tls = { + certResolver: globalDefaultResolver, + ...(globalDefaultPreferWildcard + ? { domains: [{ main: wildCard }] } + : {}) + }; + } else { + const matchingCert = validCerts.find( + (cert) => cert.queriedDomain === alias + ); + if (!matchingCert) { + logger.debug( + `No matching certificate found for inference siteResource alias: ${alias}` + ); + continue; + } + } + + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; + + if (sr.ssl) { + config_output.http.routers[routerName + "-redirect"] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [redirectHttpsMiddlewareName], + service: serviceName, + rule, + priority: 100 + }; + } + + config_output.http.routers[routerName] = { + entryPoints: [ + sr.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint + ], + middlewares: additionalMiddlewares, + service: serviceName, + rule, + priority: 100, + ...(sr.ssl ? { tls } : {}) + }; + + config_output.http.services[serviceName] = { + loadBalancer: { + servers: [{ url: `${aiGatewayUrl}/chat/completions` }], + passHostHeader: true + } + }; + } + } + if (generateLoginPageRouters) { const exitNodeLoginPages = await db .select({ diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 06799bcd6..e8650e234 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -351,6 +351,7 @@ hybridRouter.get( } const pangolinUIUrl = config.getRawConfig().app.dashboard_url; // points to the dashboard to serve from there + const aiGatewayUrl = `${config.getRawConfig().app.dashboard_url}/api/v1/ai-gateway`; try { const traefikConfig = await getTraefikConfig( @@ -360,7 +361,8 @@ hybridRouter.get( false, // Dont include login pages, true, // allow raw resources pangolinUIUrl, // dont generate maintenance page - pangolinUIUrl // generate browser gateway targets + pangolinUIUrl, // generate browser gateway targets + aiGatewayUrl ); return response(res, { diff --git a/server/routers/aiGateway/chatCompletions.ts b/server/routers/aiGateway/chatCompletions.ts new file mode 100644 index 000000000..cddcf8404 --- /dev/null +++ b/server/routers/aiGateway/chatCompletions.ts @@ -0,0 +1,256 @@ +import { Request, Response } from "express"; +import { and, eq } from "drizzle-orm"; +import { + AiProvider, + aiModels, + aiProviders, + db, + resourceAiModels, + resources, + siteResourceAiModels, + siteResources +} from "@server/db"; +import config from "@server/lib/config"; +import { decrypt } from "@server/lib/crypto"; +import { + AiProviderAuthType, + AiProviderRoutingMode, + AiProviderType, + resolveAiProviderConfig +} from "@server/lib/aiProviderDefaults"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; + +type ResolvedTarget = { + provider: AiProvider; + // null = no restriction; every enabled model on the provider is allowed + allowedModelIds: number[] | null; +}; + +async function resolveTarget(host: string): Promise { + const [resourceRow] = await db + .select({ resourceId: resources.resourceId, provider: aiProviders }) + .from(resources) + .innerJoin( + aiProviders, + eq(resources.aiProviderId, aiProviders.providerId) + ) + .where( + and( + eq(resources.fullDomain, host), + eq(resources.mode, "inference"), + eq(resources.enabled, true), + eq(aiProviders.enabled, true) + ) + ) + .limit(1); + + if (resourceRow) { + const restrictions = await db + .select({ modelId: resourceAiModels.modelId }) + .from(resourceAiModels) + .where(eq(resourceAiModels.resourceId, resourceRow.resourceId)); + + return { + provider: resourceRow.provider, + allowedModelIds: restrictions.length + ? restrictions.map((r) => r.modelId) + : null + }; + } + + const [siteResourceRow] = await db + .select({ + siteResourceId: siteResources.siteResourceId, + provider: aiProviders + }) + .from(siteResources) + .innerJoin( + aiProviders, + eq(siteResources.aiProviderId, aiProviders.providerId) + ) + .where( + and( + eq(siteResources.alias, host), + eq(siteResources.mode, "inference"), + eq(siteResources.enabled, true), + eq(aiProviders.enabled, true) + ) + ) + .limit(1); + + if (siteResourceRow) { + const restrictions = await db + .select({ modelId: siteResourceAiModels.modelId }) + .from(siteResourceAiModels) + .where( + eq( + siteResourceAiModels.siteResourceId, + siteResourceRow.siteResourceId + ) + ); + + return { + provider: siteResourceRow.provider, + allowedModelIds: restrictions.length + ? restrictions.map((r) => r.modelId) + : null + }; + } + + return null; +} + +// Generic OpenAI-wire-compatible passthrough. Anthropic's native API uses a +// different path/schema; everything else here is OpenAI-compatible today. +function getCompletionsPath(type: AiProviderType): string { + if (type === "anthropic") { + return "/v1/messages"; + } + return "/chat/completions"; +} + +export async function chatCompletions(req: Request, res: Response): Promise { + try { + const host = (req.headers.host || "").split(":")[0]; + if (!host) { + return res + .status(HttpCode.BAD_REQUEST) + .json({ error: { message: "Missing Host header" } }); + } + + const target = await resolveTarget(host); + if (!target) { + return res.status(HttpCode.NOT_FOUND).json({ + error: { + message: "No inference resource found for this host" + } + }); + } + + const { provider, allowedModelIds } = target; + const requestedModel = + typeof req.body?.model === "string" ? req.body.model : undefined; + + if (allowedModelIds) { + if (!requestedModel) { + return res.status(HttpCode.FORBIDDEN).json({ + error: { + message: + "This resource restricts access to specific models; a model must be specified" + } + }); + } + + const [matchedModel] = await db + .select({ modelId: aiModels.modelId }) + .from(aiModels) + .where( + and( + eq(aiModels.providerId, provider.providerId), + eq(aiModels.modelKey, requestedModel) + ) + ) + .limit(1); + + if ( + !matchedModel || + !allowedModelIds.includes(matchedModel.modelId) + ) { + return res.status(HttpCode.FORBIDDEN).json({ + error: { + message: `Model "${requestedModel}" is not permitted on this resource` + } + }); + } + } + + if (!provider.apiKey) { + return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ + error: { message: "AI provider has no API key configured" } + }); + } + + const secret = config.getRawConfig().server.secret!; + const apiKey = decrypt(provider.apiKey, secret); + + const { upstreamUrl, authType } = resolveAiProviderConfig({ + type: provider.type as AiProviderType, + upstreamUrl: provider.upstreamUrl, + authType: provider.authType as AiProviderAuthType | null, + routingMode: provider.routingMode as AiProviderRoutingMode | null + }); + + if (!upstreamUrl) { + return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ + error: { + message: "AI provider has no upstream URL configured" + } + }); + } + + const targetUrl = `${upstreamUrl.replace(/\/$/, "")}${getCompletionsPath( + provider.type as AiProviderType + )}`; + + const headers: Record = { + "Content-Type": "application/json" + }; + if (authType === "bearer") { + headers["Authorization"] = `Bearer ${apiKey}`; + } + + // No dedicated per-request TLS agent is wired up (no extra deps for + // this v1 gateway) - toggle the process-wide Node TLS check instead. + // Known limitation: this is not safe under concurrent requests mixing + // skipTlsVerification providers with strict ones. + const restoreTlsReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + if (provider.skipTlsVerification) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + } + + let upstreamRes: globalThis.Response; + try { + upstreamRes = await fetch(targetUrl, { + method: "POST", + headers, + body: JSON.stringify(req.body) + }); + } finally { + if (provider.skipTlsVerification) { + if (restoreTlsReject === undefined) { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + } else { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = restoreTlsReject; + } + } + } + + const contentType = upstreamRes.headers.get("content-type") || ""; + const isStream = + req.body?.stream === true || + contentType.includes("text/event-stream"); + + res.status(upstreamRes.status); + res.setHeader("Content-Type", contentType || "application/json"); + + if (isStream && upstreamRes.body) { + res.flushHeaders(); + const reader = upstreamRes.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(value); + } + return res.end(); + } + + const text = await upstreamRes.text(); + return res.send(text); + } catch (error) { + logger.error(error); + return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ + error: { message: "Failed to proxy inference request" } + }); + } +} diff --git a/server/routers/aiGateway/index.ts b/server/routers/aiGateway/index.ts new file mode 100644 index 000000000..691ebebe8 --- /dev/null +++ b/server/routers/aiGateway/index.ts @@ -0,0 +1 @@ +export * from "./chatCompletions"; diff --git a/server/routers/internal.ts b/server/routers/internal.ts index 2fa5239cc..af12d4429 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -3,6 +3,7 @@ import * as gerbil from "@server/routers/gerbil"; import * as traefik from "@server/routers/traefik"; import * as resource from "./resource"; import * as badger from "./badger"; +import * as aiGateway from "@server/routers/aiGateway"; import * as auth from "@server/routers/auth"; import * as supporterKey from "@server/routers/supporterKey"; import * as idp from "@server/routers/idp"; @@ -63,3 +64,10 @@ internalRouter.use("/badger", badgerRouter); badgerRouter.post("/verify-session", badger.verifyResourceSession); badgerRouter.post("/exchange-session", badger.exchangeSession); + +// AI inference gateway - minimal chat-completions proxy for inference-mode +// resources/siteResources +internalRouter.post( + "/ai-gateway/chat/completions", + aiGateway.chatCompletions +); diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 2ff7b9ae3..e56896e55 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -90,11 +90,22 @@ const createHttpResourceSchema = z domainId: z.string(), stickySession: z.boolean().optional(), postAuthPath: z.string().nullable().optional(), - mode: z.enum(["http", "ssh", "rdp", "vnc", "tcp", "udp"]).optional(), + mode: z + .enum(["http", "ssh", "rdp", "vnc", "tcp", "udp", "inference"]) + .optional(), // SSH Settings pamMode: z.enum(["passthrough", "push"]).optional(), authDaemonPort: z.int().positive().optional(), - authDaemonMode: z.enum(["site", "remote", "native"]).optional() + authDaemonMode: z.enum(["site", "remote", "native"]).optional(), + // Inference settings + aiProviderId: z + .number() + .int() + .positive() + .optional() + .describe( + "For inference-mode resources: the AI provider this resource proxies chat completions to." + ) }) .refine( (data) => { @@ -365,7 +376,8 @@ async function createHttpResource( mode, authDaemonPort, authDaemonMode, - pamMode + pamMode, + aiProviderId } = parsedBody.data; const subdomain = parsedBody.data.subdomain; const stickySession = parsedBody.data.stickySession; @@ -552,7 +564,8 @@ async function createHttpResource( postAuthPath: postAuthPath, wildcard, health: "unknown", - defaultResourcePolicyId: defaultPolicy.resourcePolicyId + defaultResourcePolicyId: defaultPolicy.resourcePolicyId, + aiProviderId: aiProviderId ?? null }) .returning(); diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index f3e0c3909..c67cb2795 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -120,6 +120,15 @@ const updateHttpResourceBodySchema = z .optional() .describe( "ID of the resource policy to apply to this resource. Set to null to remove the resource policy and fall back to the inline policy settings." + ), + aiProviderId: z + .number() + .int() + .positive() + .nullable() + .optional() + .describe( + "For inference-mode resources: the AI provider this resource proxies chat completions to. Set to null to unlink." ) }) .refine((data) => Object.keys(data).length > 0, { diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 1a4cd9a4d..9a6f91ad7 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -78,7 +78,15 @@ const createSiteResourceSchema = z authDaemonMode: z.enum(["site", "remote", "native"]).optional(), pamMode: z.enum(["passthrough", "push"]).optional(), domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org - subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org + subdomain: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org + aiProviderId: z + .number() + .int() + .positive() + .optional() + .describe( + "For inference-mode site resources: the AI provider this resource proxies chat completions to." + ) }) .strict() .refine( @@ -322,7 +330,8 @@ export async function createSiteResource( authDaemonMode, pamMode, domainId, - subdomain + subdomain, + aiProviderId } = parsedBody.data; // Backward compatibility: merge deprecated siteId into siteIds array @@ -594,7 +603,8 @@ export async function createSiteResource( domainId, subdomain: finalSubdomain, fullDomain, - requiresExitNodeConnection: mode === "inference" // in the future we might want to have different modes that do this + requiresExitNodeConnection: mode === "inference", // in the future we might want to have different modes that do this + aiProviderId: aiProviderId ?? null }; if (isLicensedSshPam) { if (authDaemonPort !== undefined) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 0ab928d04..0ae57c087 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -78,7 +78,16 @@ const updateSiteResourceSchema = z authDaemonMode: z.enum(["site", "remote", "native"]).optional(), pamMode: z.enum(["passthrough", "push"]).optional(), domainId: z.string().optional(), - subdomain: z.string().optional() + subdomain: z.string().optional(), + aiProviderId: z + .number() + .int() + .positive() + .nullable() + .optional() + .describe( + "For inference-mode site resources: the AI provider this resource proxies chat completions to. Set to null to unlink." + ) }) .strict() .refine( @@ -329,7 +338,8 @@ export async function updateSiteResource( authDaemonMode, pamMode, domainId, - subdomain + subdomain, + aiProviderId } = parsedBody.data; // Backward compatibility: merge deprecated siteId into siteIds array @@ -594,6 +604,7 @@ export async function updateSiteResource( networkId: mode === "inference" ? null : undefined, requiresExitNodeConnection: mode !== undefined ? mode === "inference" : undefined, + aiProviderId: aiProviderId, ...sshPamSet }) .where(and(eq(siteResources.siteResourceId, siteResourceId))) diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index 04cb30530..898ac42ee 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -20,6 +20,9 @@ export async function traefikConfigProvider( const maintenancePort = config.getRawConfig().server.next_port; const maintenanceHost = config.getRawConfig().server.internal_hostname; const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`; + const aiGatewayUrl = `http://${maintenanceHost}:${ + config.getRawConfig().server.internal_port + }/api/v1/ai-gateway`; const traefikConfig = await getTraefikConfig( currentExitNodeId, @@ -28,7 +31,8 @@ export async function traefikConfigProvider( build != "oss", // generate the login pages on the cloud and and enterprise, config.getRawConfig().traefik.allow_raw_resources, pangolinUIUrl, - pangolinUIUrl + pangolinUIUrl, + aiGatewayUrl ); if (traefikConfig?.http?.middlewares) { From f7689b7a5a812361c38bb7fc08525aa5082bf867 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 2 Aug 2026 10:50:50 -0400 Subject: [PATCH 182/494] Endpoints to update models on the resource --- server/auth/actions.ts | 2 + server/routers/external.ts | 30 ++++ server/routers/integration.ts | 86 +++++++++ .../routers/resource/addAiModelToResource.ts | 161 +++++++++++++++++ server/routers/resource/index.ts | 4 + .../routers/resource/listResourceAiModels.ts | 107 +++++++++++ .../resource/removeAiModelFromResource.ts | 141 +++++++++++++++ .../routers/resource/setResourceAiModels.ts | 155 ++++++++++++++++ .../siteResource/addAiModelToSiteResource.ts | 170 ++++++++++++++++++ server/routers/siteResource/index.ts | 4 + .../siteResource/listSiteResourceAiModels.ts | 115 ++++++++++++ .../removeAiModelFromSiteResource.ts | 140 +++++++++++++++ .../siteResource/setSiteResourceAiModels.ts | 167 +++++++++++++++++ 13 files changed, 1282 insertions(+) create mode 100644 server/routers/resource/addAiModelToResource.ts create mode 100644 server/routers/resource/listResourceAiModels.ts create mode 100644 server/routers/resource/removeAiModelFromResource.ts create mode 100644 server/routers/resource/setResourceAiModels.ts create mode 100644 server/routers/siteResource/addAiModelToSiteResource.ts create mode 100644 server/routers/siteResource/listSiteResourceAiModels.ts create mode 100644 server/routers/siteResource/removeAiModelFromSiteResource.ts create mode 100644 server/routers/siteResource/setSiteResourceAiModels.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 944adbff4..1b4a33e89 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -50,6 +50,8 @@ export enum ActionsEnum { setResourceUsers = "setResourceUsers", setResourceRoles = "setResourceRoles", listResourceUsers = "listResourceUsers", + listResourceAiModels = "listResourceAiModels", + setResourceAiModels = "setResourceAiModels", // removeRoleSite = "removeRoleSite", // addRoleAction = "addRoleAction", // removeRoleAction = "removeRoleAction", diff --git a/server/routers/external.ts b/server/routers/external.ts index 99bca3a8e..4ebf58cae 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -407,6 +407,13 @@ authenticated.get( siteResource.listSiteResourceClients ); +authenticated.get( + "/site-resource/:siteResourceId/ai-models", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceAiModels), + siteResource.listSiteResourceAiModels +); + authenticated.post( "/site-resource/:siteResourceId/roles", verifySiteResourceAccess, @@ -417,6 +424,14 @@ authenticated.post( siteResource.setSiteResourceRoles ); +authenticated.post( + "/site-resource/:siteResourceId/ai-models", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + siteResource.setSiteResourceAiModels +); + authenticated.post( "/site-resource/:siteResourceId/users", verifySiteResourceAccess, @@ -651,6 +666,13 @@ authenticated.get( resource.listResourceUsers ); +authenticated.get( + "/resource/:resourceId/ai-models", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceAiModels), + resource.listResourceAiModels +); + authenticated.get( "/resource/:resourceId", verifyResourceAccess, @@ -854,6 +876,14 @@ authenticated.post( resource.setResourceUsers ); +authenticated.post( + "/resource/:resourceId/ai-models", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + resource.setResourceAiModels +); + authenticated.put( "/resource-policy/:resourcePolicyId/access-control", verifyResourcePolicyAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 384214e4c..41757e7a0 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -246,6 +246,16 @@ authenticated.get( siteResource.listSiteResourceClients ); +authenticated.get( + [ + "/site-resource/:siteResourceId/ai-models", + "/private-resource/:siteResourceId/ai-models" + ], + verifyApiKeySiteResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceAiModels), + siteResource.listSiteResourceAiModels +); + authenticated.post( [ "/site-resource/:siteResourceId/roles", @@ -298,6 +308,39 @@ authenticated.post( siteResource.removeRoleFromSiteResource ); +authenticated.post( + [ + "/site-resource/:siteResourceId/ai-models", + "/private-resource/:siteResourceId/ai-models" + ], + verifyApiKeySiteResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + siteResource.setSiteResourceAiModels +); + +authenticated.post( + [ + "/site-resource/:siteResourceId/ai-models/add", + "/private-resource/:siteResourceId/ai-models/add" + ], + verifyApiKeySiteResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + siteResource.addAiModelToSiteResource +); + +authenticated.post( + [ + "/site-resource/:siteResourceId/ai-models/remove", + "/private-resource/:siteResourceId/ai-models/remove" + ], + verifyApiKeySiteResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + siteResource.removeAiModelFromSiteResource +); + authenticated.post( [ "/site-resource/:siteResourceId/users/add", @@ -510,6 +553,16 @@ authenticated.get( resource.listResourceUsers ); +authenticated.get( + [ + "/resource/:resourceId/ai-models", + "/public-resource/:resourceId/ai-models" + ], + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceAiModels), + resource.listResourceAiModels +); + authenticated.get( ["/resource/:resourceId", "/public-resource/:resourceId"], verifyApiKeyResourceAccess, @@ -711,6 +764,17 @@ authenticated.post( resource.setResourceRoles ); +authenticated.post( + [ + "/resource/:resourceId/ai-models", + "/public-resource/:resourceId/ai-models" + ], + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + resource.setResourceAiModels +); + authenticated.post( ["/resource/:resourceId/users", "/public-resource/:resourceId/users"], verifyApiKeyResourceAccess, @@ -903,6 +967,28 @@ authenticated.post( resource.removeRoleFromResource ); +authenticated.post( + [ + "/resource/:resourceId/ai-models/add", + "/public-resource/:resourceId/ai-models/add" + ], + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + resource.addAiModelToResource +); + +authenticated.post( + [ + "/resource/:resourceId/ai-models/remove", + "/public-resource/:resourceId/ai-models/remove" + ], + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + resource.removeAiModelFromResource +); + authenticated.post( [ "/resource/:resourceId/users/add", diff --git a/server/routers/resource/addAiModelToResource.ts b/server/routers/resource/addAiModelToResource.ts new file mode 100644 index 000000000..fd33a3e91 --- /dev/null +++ b/server/routers/resource/addAiModelToResource.ts @@ -0,0 +1,161 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources, resourceAiModels, aiModels } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const addAiModelToResourceBodySchema = z.strictObject({ + modelId: z.int().positive() +}); + +const addAiModelToResourceParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/ai-models/add", + description: + "Add a single AI model to a resource's model restriction allow-list.", + tags: [OpenAPITags.PublicResource], + request: { + params: addAiModelToResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addAiModelToResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function addAiModelToResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = addAiModelToResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { modelId } = parsedBody.data; + + const parsedParams = addAiModelToResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + if (!resource.aiProviderId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Resource has no AI provider linked" + ) + ); + } + + const [model] = await db + .select() + .from(aiModels) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, resource.aiProviderId) + ) + ) + .limit(1); + + if (!model) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Model not found or does not belong to this resource's AI provider" + ) + ); + } + + const existingEntry = await db + .select() + .from(resourceAiModels) + .where( + and( + eq(resourceAiModels.resourceId, resourceId), + eq(resourceAiModels.modelId, modelId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Model already assigned to resource" + ) + ); + } + + await db.insert(resourceAiModels).values({ resourceId, modelId }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Model added to resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 709bf7340..32992d364 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -35,3 +35,7 @@ export * from "./removeEmailFromResourceWhitelist"; export * from "./getStatusHistory"; export * from "./getBatchedStatusHistory"; export * from "./getResourcePolicies"; +export * from "./listResourceAiModels"; +export * from "./setResourceAiModels"; +export * from "./addAiModelToResource"; +export * from "./removeAiModelFromResource"; diff --git a/server/routers/resource/listResourceAiModels.ts b/server/routers/resource/listResourceAiModels.ts new file mode 100644 index 000000000..f6875faff --- /dev/null +++ b/server/routers/resource/listResourceAiModels.ts @@ -0,0 +1,107 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources, resourceAiModels, aiModels } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listResourceAiModelsParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +async function query(resourceId: number) { + return await db + .select({ + modelId: aiModels.modelId, + modelKey: aiModels.modelKey, + name: aiModels.name, + enabled: aiModels.enabled + }) + .from(resourceAiModels) + .innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId)) + .where(eq(resourceAiModels.resourceId, resourceId)); +} + +export type ListResourceAiModelsResponse = { + models: NonNullable>>; +}; + +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/ai-models", + description: + "List the AI models a resource is restricted to. An empty list means the resource is not restricted and every enabled model on its linked AI provider is allowed.", + tags: [OpenAPITags.PublicResource], + request: { + params: listResourceAiModelsParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function listResourceAiModels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listResourceAiModelsParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const models = await query(resourceId); + + return response(res, { + data: { models }, + success: true, + error: false, + message: "Resource AI models retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/removeAiModelFromResource.ts b/server/routers/resource/removeAiModelFromResource.ts new file mode 100644 index 000000000..47445bbf5 --- /dev/null +++ b/server/routers/resource/removeAiModelFromResource.ts @@ -0,0 +1,141 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources, resourceAiModels } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const removeAiModelFromResourceBodySchema = z.strictObject({ + modelId: z.int().positive() +}); + +const removeAiModelFromResourceParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/ai-models/remove", + description: + "Remove a single AI model from a resource's model restriction allow-list.", + tags: [OpenAPITags.PublicResource], + request: { + params: removeAiModelFromResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeAiModelFromResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function removeAiModelFromResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = removeAiModelFromResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { modelId } = parsedBody.data; + + const parsedParams = removeAiModelFromResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const existingEntry = await db + .select() + .from(resourceAiModels) + .where( + and( + eq(resourceAiModels.resourceId, resourceId), + eq(resourceAiModels.modelId, modelId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Model not found in resource's restriction list" + ) + ); + } + + await db + .delete(resourceAiModels) + .where( + and( + eq(resourceAiModels.resourceId, resourceId), + eq(resourceAiModels.modelId, modelId) + ) + ); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Model removed from resource successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/setResourceAiModels.ts b/server/routers/resource/setResourceAiModels.ts new file mode 100644 index 000000000..2ef56f4fc --- /dev/null +++ b/server/routers/resource/setResourceAiModels.ts @@ -0,0 +1,155 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources, resourceAiModels, aiModels } from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setResourceAiModelsBodySchema = z.strictObject({ + modelIds: z.array(z.int().positive()) +}); + +const setResourceAiModelsParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/ai-models", + description: + "Set the AI models a resource is restricted to. This replaces all existing restrictions. Pass an empty array to remove the restriction (allow every enabled model on the linked provider).", + tags: [OpenAPITags.PublicResource], + request: { + params: setResourceAiModelsParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceAiModelsBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function setResourceAiModels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setResourceAiModelsBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { modelIds } = parsedBody.data; + + const parsedParams = setResourceAiModelsParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + if (modelIds.length > 0) { + if (!resource.aiProviderId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Resource has no AI provider linked" + ) + ); + } + + const validModels = await db + .select({ modelId: aiModels.modelId }) + .from(aiModels) + .where( + and( + inArray(aiModels.modelId, modelIds), + eq(aiModels.providerId, resource.aiProviderId) + ) + ); + + if (validModels.length !== new Set(modelIds).size) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more model IDs do not exist or do not belong to this resource's AI provider" + ) + ); + } + } + + await db.transaction(async (trx) => { + await trx + .delete(resourceAiModels) + .where(eq(resourceAiModels.resourceId, resourceId)); + + if (modelIds.length > 0) { + await trx + .insert(resourceAiModels) + .values( + modelIds.map((modelId) => ({ resourceId, modelId })) + ); + } + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "AI models set for resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/siteResource/addAiModelToSiteResource.ts b/server/routers/siteResource/addAiModelToSiteResource.ts new file mode 100644 index 000000000..2e55f100b --- /dev/null +++ b/server/routers/siteResource/addAiModelToSiteResource.ts @@ -0,0 +1,170 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + siteResources, + siteResourceAiModels, + aiModels +} from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const addAiModelToSiteResourceBodySchema = z.strictObject({ + modelId: z.int().positive() +}); + +const addAiModelToSiteResourceParamsSchema = z.strictObject({ + siteResourceId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/ai-models/add", + description: + "Add a single AI model to a site resource's model restriction allow-list.", + tags: [OpenAPITags.PrivateResource], + request: { + params: addAiModelToSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addAiModelToSiteResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function addAiModelToSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = addAiModelToSiteResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { modelId } = parsedBody.data; + + const parsedParams = addAiModelToSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + if (!siteResource.aiProviderId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Site resource has no AI provider linked" + ) + ); + } + + const [model] = await db + .select() + .from(aiModels) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, siteResource.aiProviderId) + ) + ) + .limit(1); + + if (!model) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Model not found or does not belong to this site resource's AI provider" + ) + ); + } + + const existingEntry = await db + .select() + .from(siteResourceAiModels) + .where( + and( + eq(siteResourceAiModels.siteResourceId, siteResourceId), + eq(siteResourceAiModels.modelId, modelId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Model already assigned to site resource" + ) + ); + } + + await db + .insert(siteResourceAiModels) + .values({ siteResourceId, modelId }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Model added to site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/siteResource/index.ts b/server/routers/siteResource/index.ts index 5c09d3883..2acaf1e33 100644 --- a/server/routers/siteResource/index.ts +++ b/server/routers/siteResource/index.ts @@ -17,3 +17,7 @@ export * from "./setSiteResourceClients"; export * from "./addClientToSiteResource"; export * from "./batchAddClientToSiteResources"; export * from "./removeClientFromSiteResource"; +export * from "./listSiteResourceAiModels"; +export * from "./setSiteResourceAiModels"; +export * from "./addAiModelToSiteResource"; +export * from "./removeAiModelFromSiteResource"; diff --git a/server/routers/siteResource/listSiteResourceAiModels.ts b/server/routers/siteResource/listSiteResourceAiModels.ts new file mode 100644 index 000000000..26dea7cd0 --- /dev/null +++ b/server/routers/siteResource/listSiteResourceAiModels.ts @@ -0,0 +1,115 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + siteResources, + siteResourceAiModels, + aiModels +} from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listSiteResourceAiModelsParamsSchema = z.strictObject({ + siteResourceId: z.coerce.number().int().positive() +}); + +async function query(siteResourceId: number) { + return await db + .select({ + modelId: aiModels.modelId, + modelKey: aiModels.modelKey, + name: aiModels.name, + enabled: aiModels.enabled + }) + .from(siteResourceAiModels) + .innerJoin( + aiModels, + eq(siteResourceAiModels.modelId, aiModels.modelId) + ) + .where(eq(siteResourceAiModels.siteResourceId, siteResourceId)); +} + +export type ListSiteResourceAiModelsResponse = { + models: NonNullable>>; +}; + +registry.registerPath({ + method: "get", + path: "/site-resource/{siteResourceId}/ai-models", + description: + "List the AI models a site resource is restricted to. An empty list means the site resource is not restricted and every enabled model on its linked AI provider is allowed.", + tags: [OpenAPITags.PrivateResource], + request: { + params: listSiteResourceAiModelsParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function listSiteResourceAiModels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listSiteResourceAiModelsParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + const models = await query(siteResourceId); + + return response(res, { + data: { models }, + success: true, + error: false, + message: "Site resource AI models retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/siteResource/removeAiModelFromSiteResource.ts b/server/routers/siteResource/removeAiModelFromSiteResource.ts new file mode 100644 index 000000000..5af2f4d87 --- /dev/null +++ b/server/routers/siteResource/removeAiModelFromSiteResource.ts @@ -0,0 +1,140 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources, siteResourceAiModels } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const removeAiModelFromSiteResourceBodySchema = z.strictObject({ + modelId: z.int().positive() +}); + +const removeAiModelFromSiteResourceParamsSchema = z.strictObject({ + siteResourceId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/ai-models/remove", + description: + "Remove a single AI model from a site resource's model restriction allow-list.", + tags: [OpenAPITags.PrivateResource], + request: { + params: removeAiModelFromSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeAiModelFromSiteResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function removeAiModelFromSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = removeAiModelFromSiteResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { modelId } = parsedBody.data; + + const parsedParams = + removeAiModelFromSiteResourceParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + const existingEntry = await db + .select() + .from(siteResourceAiModels) + .where( + and( + eq(siteResourceAiModels.siteResourceId, siteResourceId), + eq(siteResourceAiModels.modelId, modelId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Model not found in site resource's restriction list" + ) + ); + } + + await db + .delete(siteResourceAiModels) + .where( + and( + eq(siteResourceAiModels.siteResourceId, siteResourceId), + eq(siteResourceAiModels.modelId, modelId) + ) + ); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Model removed from site resource successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/siteResource/setSiteResourceAiModels.ts b/server/routers/siteResource/setSiteResourceAiModels.ts new file mode 100644 index 000000000..1366187af --- /dev/null +++ b/server/routers/siteResource/setSiteResourceAiModels.ts @@ -0,0 +1,167 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + siteResources, + siteResourceAiModels, + aiModels +} from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setSiteResourceAiModelsBodySchema = z.strictObject({ + modelIds: z.array(z.int().positive()) +}); + +const setSiteResourceAiModelsParamsSchema = z.strictObject({ + siteResourceId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/ai-models", + description: + "Set the AI models a site resource is restricted to. This replaces all existing restrictions. Pass an empty array to remove the restriction (allow every enabled model on the linked provider).", + tags: [OpenAPITags.PrivateResource], + request: { + params: setSiteResourceAiModelsParamsSchema, + body: { + content: { + "application/json": { + schema: setSiteResourceAiModelsBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function setSiteResourceAiModels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setSiteResourceAiModelsBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { modelIds } = parsedBody.data; + + const parsedParams = setSiteResourceAiModelsParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + if (modelIds.length > 0) { + if (!siteResource.aiProviderId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Site resource has no AI provider linked" + ) + ); + } + + const validModels = await db + .select({ modelId: aiModels.modelId }) + .from(aiModels) + .where( + and( + inArray(aiModels.modelId, modelIds), + eq(aiModels.providerId, siteResource.aiProviderId) + ) + ); + + if (validModels.length !== new Set(modelIds).size) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more model IDs do not exist or do not belong to this site resource's AI provider" + ) + ); + } + } + + await db.transaction(async (trx) => { + await trx + .delete(siteResourceAiModels) + .where( + eq(siteResourceAiModels.siteResourceId, siteResourceId) + ); + + if (modelIds.length > 0) { + await trx + .insert(siteResourceAiModels) + .values( + modelIds.map((modelId) => ({ + siteResourceId, + modelId + })) + ); + } + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "AI models set for site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} From 1a644b131df2dd7ca8335a1dbcc0a521698db007 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 2 Aug 2026 12:12:48 -0400 Subject: [PATCH 183/494] Pull the session cookie properly --- server/routers/aiGateway/chatCompletions.ts | 129 +++++++++++++++++++- server/routers/badger/verifySession.ts | 4 +- 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/server/routers/aiGateway/chatCompletions.ts b/server/routers/aiGateway/chatCompletions.ts index cddcf8404..7a4ba3a84 100644 --- a/server/routers/aiGateway/chatCompletions.ts +++ b/server/routers/aiGateway/chatCompletions.ts @@ -8,7 +8,8 @@ import { resourceAiModels, resources, siteResourceAiModels, - siteResources + siteResources, + users } from "@server/db"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; @@ -18,18 +19,121 @@ import { AiProviderType, resolveAiProviderConfig } from "@server/lib/aiProviderDefaults"; +import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; +import { + SESSION_COOKIE_NAME, + validateSessionToken +} from "@server/auth/sessions/app"; +import { getUserOrgRoles } from "@server/lib/userOrgRoles"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; type ResolvedTarget = { + resourceId: number | null; + orgId: string | null; provider: AiProvider; // null = no restriction; every enabled model on the provider is allowed allowedModelIds: number[] | null; }; +// Fallback for clients that hit the endpoint directly (e.g. an AI tool's +// "API key" field) instead of going through a browser session - badger +// forwards whatever Authorization header the client sent untouched in that +// case. This prefix lets us tell "this bearer value is a Pangolin resource +// access token" apart from an arbitrary/opaque API key a user might paste +// in, without guessing based on format alone. +const USER_TOKEN_PREFIX = "pu_"; + +export type RequestUser = { + userId: string; + username: string; + email: string | null; + name: string | null; + role: string | null; +}; + +async function buildRequestUser( + userId: string, + orgId: string | null +): Promise { + const [user] = await db + .select() + .from(users) + .where(eq(users.userId, userId)) + .limit(1); + + if (!user) { + return null; + } + + const orgRoles = orgId ? await getUserOrgRoles(user.userId, orgId) : []; + + return { + userId: user.userId, + username: user.username, + email: user.email, + name: user.name, + role: orgRoles.map((r) => r.roleName).join(", ") || null + }; +} + +async function resolveRequestUser( + req: Request, + resourceId: number | null, + orgId: string | null +): Promise { + // Public resources behind badger: badger passes the resource session + // cookie through to the backend (same mechanism the browser gateway, + // e.g. the SSH page, relies on), so we can validate it exactly like + // verifySessionUserMiddleware does for the dashboard. + const sessionToken = req.cookies?.[SESSION_COOKIE_NAME]; + if (sessionToken) { + const { session, user } = await validateSessionToken(sessionToken); + if (session && user) { + return buildRequestUser(user.userId, orgId); + } + } + + // User devices hitting the endpoint directly (no browser session to + // forward) fall back to a Pangolin resource access token passed as the + // client's "API key". + const authHeader = req.headers["authorization"]; + if (typeof authHeader !== "string" || !resourceId) { + return null; + } + + const bearer = authHeader.match(/^Bearer\s+(.+)$/i)?.[1]; + if (!bearer || !bearer.startsWith(USER_TOKEN_PREFIX)) { + return null; + } + + const [accessTokenId, accessToken] = bearer + .slice(USER_TOKEN_PREFIX.length) + .split("."); + if (!accessTokenId || !accessToken) { + return null; + } + + const { valid, tokenItem } = await verifyResourceAccessToken({ + accessToken, + accessTokenId, + resourceId + }); + + if (!valid || !tokenItem?.userId) { + return null; + } + + return buildRequestUser(tokenItem.userId, tokenItem.orgId); +} + async function resolveTarget(host: string): Promise { const [resourceRow] = await db - .select({ resourceId: resources.resourceId, provider: aiProviders }) + .select({ + resourceId: resources.resourceId, + orgId: resources.orgId, + provider: aiProviders + }) .from(resources) .innerJoin( aiProviders, @@ -52,6 +156,8 @@ async function resolveTarget(host: string): Promise { .where(eq(resourceAiModels.resourceId, resourceRow.resourceId)); return { + resourceId: resourceRow.resourceId, + orgId: resourceRow.orgId, provider: resourceRow.provider, allowedModelIds: restrictions.length ? restrictions.map((r) => r.modelId) @@ -62,6 +168,7 @@ async function resolveTarget(host: string): Promise { const [siteResourceRow] = await db .select({ siteResourceId: siteResources.siteResourceId, + orgId: siteResources.orgId, provider: aiProviders }) .from(siteResources) @@ -91,6 +198,11 @@ async function resolveTarget(host: string): Promise { ); return { + // siteResources have no per-user auth/policy stack today (see + // the routing comment in getTraefikConfig.ts), so there's no + // resource access token scope to validate a user token against. + resourceId: null, + orgId: siteResourceRow.orgId, provider: siteResourceRow.provider, allowedModelIds: restrictions.length ? restrictions.map((r) => r.modelId) @@ -128,7 +240,18 @@ export async function chatCompletions(req: Request, res: Response): Promise }); } - const { provider, allowedModelIds } = target; + const { provider, allowedModelIds, resourceId, orgId } = target; + + // Best-effort identity resolution - not yet enforced, but lets us + // start making per-user access decisions (e.g. model/role-based + // restrictions) without another round of plumbing later. + const requestUser = await resolveRequestUser(req, resourceId, orgId); + if (requestUser) { + logger.debug( + `AI gateway request from user ${requestUser.userId} (${requestUser.username})` + ); + } + const requestedModel = typeof req.body?.model === "string" ? req.body.model : undefined; diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 99e0d9812..b0aed6e30 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -222,7 +222,9 @@ export async function verifyResourceSession( } const { blockAccess, mode } = resource; - const dontStripSession = ["ssh", "rdp", "vnc"].includes(mode); + const dontStripSession = ["ssh", "rdp", "vnc", "inference"].includes( + mode + ); if (blockAccess) { logger.debug("Resource blocked", host); From b0edc6302e0d0fb0751d9a0e53abc9c13f606a62 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 3 Aug 2026 10:36:42 -0400 Subject: [PATCH 184/494] providers table, create, and edit first pass --- messages/en-US.json | 77 +++ .../[providerId]/configuration/page.tsx | 426 +++++++++++++++ .../[providerId]/general/page.tsx | 180 ++++++ .../ai-providers/[providerId]/layout.tsx | 90 +++ .../ai-providers/[providerId]/page.tsx | 10 + .../settings/ai-providers/create/layout.tsx | 13 + .../settings/ai-providers/create/page.tsx | 514 ++++++++++++++++++ .../[orgId]/settings/ai-providers/page.tsx | 71 +++ src/app/navigation.tsx | 21 + src/components/AiProviderTypeSelect.tsx | 147 +++++ src/components/AiProvidersTable.tsx | 317 +++++++++++ src/components/HorizontalTabs.tsx | 1 + src/contexts/aiProviderContext.ts | 13 + src/hooks/useAiProviderContext.ts | 12 + src/lib/aiProviderFormSchema.ts | 202 +++++++ src/providers/AiProviderProvider.tsx | 47 ++ 16 files changed, 2141 insertions(+) create mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx create mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx create mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx create mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx create mode 100644 src/app/[orgId]/settings/ai-providers/create/layout.tsx create mode 100644 src/app/[orgId]/settings/ai-providers/create/page.tsx create mode 100644 src/app/[orgId]/settings/ai-providers/page.tsx create mode 100644 src/components/AiProviderTypeSelect.tsx create mode 100644 src/components/AiProvidersTable.tsx create mode 100644 src/contexts/aiProviderContext.ts create mode 100644 src/hooks/useAiProviderContext.ts create mode 100644 src/lib/aiProviderFormSchema.ts create mode 100644 src/providers/AiProviderProvider.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 94dd49f96..4febc876a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1632,6 +1632,83 @@ "sidebarInvitations": "Invitations", "sidebarRoles": "Roles", "sidebarShareableLinks": "Shareable Links", + "sidebarAi": "AI", + "sidebarAiProviders": "Providers", + "commandAiProviders": "AI Providers", + "aiProvidersTitle": "AI Providers", + "aiProvidersDescription": "Connect model providers for AI workloads in this organization", + "aiProvidersAdd": "Add Provider", + "aiProvidersSearch": "Search providers...", + "aiProvidersEmpty": "No AI providers yet", + "aiProviderCreate": "Create AI Provider", + "aiProviderCreateDescription": "Add a model provider for this organization", + "aiProviderSeeAll": "See All Providers", + "aiProviderSetting": "Provider Settings for {providerName}", + "aiProviderSettingDescription": "Configure this AI provider", + "aiProviderGeneral": "General", + "aiProviderGeneralDescription": "Basic settings for this provider", + "aiProviderConfiguration": "Configuration", + "aiProviderConfigurationDescription": "Upstream URL, routing, authentication, and TLS settings", + "aiProviderType": "Provider Type", + "aiProviderTypeSearch": "Search providers...", + "aiProviderTypeNotFound": "No provider type found", + "aiProviderTypeOpenai": "OpenAI", + "aiProviderTypeAnthropic": "Anthropic", + "aiProviderTypeGoogleGemini": "Google Gemini", + "aiProviderTypeVertexAi": "Vertex AI", + "aiProviderTypeBedrock": "Amazon Bedrock", + "aiProviderTypeMicrosoftFoundry": "Microsoft Foundry", + "aiProviderTypeOpenRouter": "OpenRouter", + "aiProviderTypeVercelAiGateway": "Vercel AI Gateway", + "aiProviderTypeCustom": "Custom", + "aiProviderTypeOpenaiDescription": "OpenAI API with default upstream URL", + "aiProviderTypeAnthropicDescription": "Anthropic API with default upstream URL", + "aiProviderTypeGoogleGeminiDescription": "Google Gemini OpenAI-compatible endpoint", + "aiProviderTypeVertexAiDescription": "Google Vertex AI; upstream URL required", + "aiProviderTypeBedrockDescription": "Amazon Bedrock Runtime", + "aiProviderTypeMicrosoftFoundryDescription": "Microsoft Foundry; upstream URL required", + "aiProviderTypeOpenRouterDescription": "OpenRouter API", + "aiProviderTypeVercelAiGatewayDescription": "Vercel AI Gateway", + "aiProviderTypeCustomDescription": "Bring your own OpenAI-compatible endpoint or route via Pangolin targets", + "aiProviderUpstreamUrl": "Upstream URL", + "aiProviderUpstreamUrlDescription": "Base URL for the provider API", + "aiProviderUpstreamUrlOptionalDescription": "Leave blank to use the default upstream URL for this provider", + "aiProviderEffectiveUpstreamUrl": "Effective Upstream URL", + "aiProviderApiKey": "API Key", + "aiProviderApiKeyDescription": "Stored encrypted. Leave blank on edit to keep the existing key.", + "aiProviderApiKeyLastChars": "API Key", + "aiProviderAuthType": "Auth Type", + "aiProviderAuthTypeBearer": "Bearer", + "aiProviderAuthTypeDescription": "How the upstream API authenticates requests", + "aiProviderRoutingMode": "Routing Mode", + "aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to Pangolin HTTPS targets", + "aiProviderRoutingModeUrl": "Upstream URL", + "aiProviderRoutingModeUrlDescription": "Call a public or private API base URL", + "aiProviderRoutingModeTarget": "Pangolin Targets", + "aiProviderRoutingModeTargetDescription": "Route through HTTPS targets on your sites", + "aiProviderRoutingModeTargetNote": "Target configuration will be available in a later update. You can still create this provider now.", + "aiProviderSkipTlsVerification": "Skip TLS Verification", + "aiProviderSkipTlsVerificationDescription": "Disable TLS certificate verification for the upstream connection", + "aiProviderBudget": "Budget", + "aiProviderBudgetDescription": "Optional spending or token budget for this provider", + "aiProviderBudgetAmount": "Budget Amount", + "aiProviderBudgetUnit": "Budget Unit", + "aiProviderBudgetUnitUsd": "USD", + "aiProviderBudgetUnitTokens": "Tokens", + "aiProviderEnabled": "Enabled", + "aiProviderEnabledDescription": "Disable to stop using this provider without deleting it", + "aiProviderErrorCreate": "Failed to create AI provider", + "aiProviderErrorUpdate": "Failed to update AI provider", + "aiProviderErrorDelete": "Failed to delete AI provider", + "aiProviderErrorLoad": "Failed to load AI provider", + "aiProviderCreated": "AI provider created", + "aiProviderUpdated": "AI provider updated", + "aiProviderDeleted": "AI provider deleted", + "aiProviderDelete": "Delete Provider", + "aiProviderDeleteConfirm": "Delete 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.", + "aiProviderErrorNoUpdate": "AI provider is not available to update", "sidebarApiKeys": "API Keys", "sidebarProvisioning": "Provisioning", "sidebarSettings": "Settings", diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx new file mode 100644 index 000000000..03a9ab48f --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx @@ -0,0 +1,426 @@ +"use client"; + +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +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 { + aiProviderFormSchema, + showsUpstreamUrlField, + toAiProviderConfigurationPayload, + upstreamUrlRequired, + type AiProviderFormValues +} from "@app/lib/aiProviderFormSchema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import type { AiProviderType } from "@server/lib/aiProviderDefaults"; +import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; +import type { AxiosResponse } from "axios"; +import { InfoIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +export default function AiProviderConfigurationPage() { + const { provider, updateProvider } = useAiProviderContext(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const t = useTranslations(); + const [saveLoading, setSaveLoading] = useState(false); + + const form = useForm({ + resolver: zodResolver(aiProviderFormSchema), + defaultValues: { + name: provider.name, + type: provider.type as AiProviderType, + upstreamUrl: provider.upstreamUrl ?? "", + apiKey: "", + authType: (provider.authType as "bearer" | null) ?? "bearer", + routingMode: (provider.routingMode as "url" | "target") ?? "url", + skipTlsVerification: provider.skipTlsVerification, + budgetAmount: provider.budgetAmount, + budgetUnit: provider.budgetUnit as "usd" | "tokens" | null, + enabled: provider.enabled + } + }); + + const providerType = form.watch("type"); + const routingMode = form.watch("routingMode"); + const showUpstream = showsUpstreamUrlField(providerType, routingMode); + const requireUpstream = upstreamUrlRequired(providerType, routingMode); + const showRoutingMode = providerType === "custom"; + const showAuthType = + providerType === "custom" && (routingMode ?? "url") === "url"; + const showTargetNote = + providerType === "custom" && routingMode === "target"; + + async function onSubmit(values: AiProviderFormValues) { + setSaveLoading(true); + try { + const res = await api.post< + AxiosResponse + >( + `/ai-provider/${provider.providerId}`, + toAiProviderConfigurationPayload({ + ...values, + type: provider.type as AiProviderType + }) + ); + const updated = res.data.data.provider; + updateProvider(updated); + form.reset({ + name: updated.name, + type: updated.type as AiProviderType, + upstreamUrl: updated.upstreamUrl ?? "", + apiKey: "", + authType: (updated.authType as "bearer" | null) ?? "bearer", + routingMode: (updated.routingMode as "url" | "target") ?? "url", + skipTlsVerification: updated.skipTlsVerification, + budgetAmount: updated.budgetAmount, + budgetUnit: updated.budgetUnit as "usd" | "tokens" | null, + enabled: updated.enabled + }); + toast({ + title: t("success"), + description: t("aiProviderUpdated") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiProviderErrorUpdate"), + description: formatAxiosError(e, t("aiProviderErrorUpdate")) + }); + } finally { + setSaveLoading(false); + } + } + + return ( + + + + + {t("aiProviderConfiguration")} + + + {t("aiProviderConfigurationDescription")} + + + + + +
+ + + {showRoutingMode && ( + + ( + + + {t( + "aiProviderRoutingMode" + )} + + + { + field.onChange( + value + ); + if ( + value === + "target" + ) { + form.setValue( + "upstreamUrl", + "" + ); + } + }} + /> + + + {t( + "aiProviderRoutingModeDescription" + )} + + + + )} + /> + + )} + + {showTargetNote && ( + + + + + {t( + "aiProviderRoutingModeTarget" + )} + + + {t( + "aiProviderRoutingModeTargetNote" + )} + + + + )} + + {showUpstream && ( + + ( + + + {t( + "aiProviderUpstreamUrl" + )} + {requireUpstream + ? "" + : " (optional)"} + + + + + + {requireUpstream + ? t( + "aiProviderUpstreamUrlDescription" + ) + : t( + "aiProviderUpstreamUrlOptionalDescription" + )} + + {provider.effectiveUpstreamUrl && ( + + {t( + "aiProviderEffectiveUpstreamUrl" + )} + {": "} + + { + provider.effectiveUpstreamUrl + } + + + )} + + + )} + /> + + )} + + {showAuthType && ( + + ( + + + {t( + "aiProviderAuthType" + )} + + + + {t( + "aiProviderAuthTypeDescription" + )} + + + + )} + /> + + )} + + + ( + + + {t("aiProviderApiKey")} + + + + + + {provider.apiKeyLastChars + ? `••••${provider.apiKeyLastChars}. ${t("aiProviderApiKeyDescription")}` + : t( + "aiProviderApiKeyDescription" + )} + + + + )} + /> + + + + ( + + + + + + + )} + /> + + +
+ +
+
+ + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx new file mode 100644 index 000000000..99db2eda8 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +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 { zodResolver } from "@hookform/resolvers/zod"; +import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; +import type { AxiosResponse } from "axios"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const generalSchema = z.object({ + name: z.string().trim().min(1), + enabled: z.boolean() +}); + +type GeneralFormValues = z.infer; + +export default function AiProviderGeneralPage() { + const { provider, updateProvider } = useAiProviderContext(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const t = useTranslations(); + const [saveLoading, setSaveLoading] = useState(false); + + const form = useForm({ + resolver: zodResolver(generalSchema), + defaultValues: { + name: provider.name, + enabled: provider.enabled + } + }); + + async function onSubmit(values: GeneralFormValues) { + setSaveLoading(true); + try { + const res = await api.post< + AxiosResponse + >(`/ai-provider/${provider.providerId}`, { + name: values.name.trim(), + enabled: values.enabled + }); + const updated = res.data.data.provider; + updateProvider(updated); + form.reset({ + name: updated.name, + enabled: updated.enabled + }); + toast({ + title: t("success"), + description: t("aiProviderUpdated") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiProviderErrorUpdate"), + description: formatAxiosError(e, t("aiProviderErrorUpdate")) + }); + } finally { + setSaveLoading(false); + } + } + + return ( + + + + + {t("aiProviderGeneral")} + + + {t("aiProviderGeneralDescription")} + + + + + +
+ + + + ( + + + + + + + )} + /> + + + + ( + + + {t("name")} + + + + + + + )} + /> + + +
+ +
+
+ + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx new file mode 100644 index 000000000..b734f6084 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx @@ -0,0 +1,90 @@ +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import OrgProvider from "@app/providers/OrgProvider"; +import AiProviderProvider from "@app/providers/AiProviderProvider"; +import type { GetOrgResponse } from "@server/routers/org"; +import type { GetAiProviderResponse } from "@server/routers/aiProvider/types"; +import type { AxiosResponse } from "axios"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +export const metadata: Metadata = { + title: "AI Provider" +}; + +export const dynamic = "force-dynamic"; + +type Props = { + children: React.ReactNode; + params: Promise<{ orgId: string; providerId: string }>; +}; + +export default async function AiProviderLayout({ children, params }: Props) { + const { orgId, providerId } = await params; + const t = await getTranslations(); + + let provider = null; + try { + const res = await internal.get>( + `/ai-provider/${providerId}`, + await authCookieHeader() + ); + provider = res.data.data.provider; + } catch { + redirect(`/${orgId}/settings/ai-providers`); + } + + if (!provider || provider.orgId !== orgId) { + redirect(`/${orgId}/settings/ai-providers`); + } + + let org = null; + try { + const getOrg = cache(async () => + internal.get>( + `/org/${orgId}`, + await authCookieHeader() + ) + ); + const res = await getOrg(); + org = res.data.data; + } catch { + redirect(`/${orgId}/settings/ai-providers`); + } + + if (!org) { + redirect(`/${orgId}/settings/ai-providers`); + } + + const navItems = [ + { + title: t("general"), + href: "/{orgId}/settings/ai-providers/{providerId}/general" + }, + { + title: t("aiProviderConfiguration"), + href: "/{orgId}/settings/ai-providers/{providerId}/configuration" + } + ]; + + return ( + <> + + + + + {children} + + + + ); +} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx new file mode 100644 index 000000000..2daca1227 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +type Props = { + params: Promise<{ orgId: string; providerId: string }>; +}; + +export default async function AiProviderPage({ params }: Props) { + const { orgId, providerId } = await params; + redirect(`/${orgId}/settings/ai-providers/${providerId}/general`); +} diff --git a/src/app/[orgId]/settings/ai-providers/create/layout.tsx b/src/app/[orgId]/settings/ai-providers/create/layout.tsx new file mode 100644 index 000000000..f33dcd80d --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/create/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Create AI Provider" +}; + +export default function CreateAiProviderLayout({ + children +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx new file mode 100644 index 000000000..9a9258d8d --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -0,0 +1,514 @@ +"use client"; + +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + aiProviderFormSchema, + emptyUpstreamForType, + showsUpstreamUrlField, + toAiProviderCreatePayload, + upstreamUrlRequired, + type AiProviderFormValues +} from "@app/lib/aiProviderFormSchema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; +import type { AxiosResponse } from "axios"; +import { InfoIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +export default function CreateAiProviderPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const params = useParams(); + const orgId = params.orgId as string; + const router = useRouter(); + const t = useTranslations(); + const [loading, setLoading] = useState(false); + + const form = useForm({ + resolver: zodResolver(aiProviderFormSchema), + defaultValues: { + name: "", + type: "openai", + upstreamUrl: emptyUpstreamForType("openai"), + apiKey: "", + authType: "bearer", + routingMode: "url", + skipTlsVerification: false, + budgetAmount: null, + budgetUnit: null, + enabled: true + } + }); + + const providerType = form.watch("type"); + const routingMode = form.watch("routingMode"); + + const showUpstream = showsUpstreamUrlField(providerType, routingMode); + const requireUpstream = upstreamUrlRequired(providerType, routingMode); + const showRoutingMode = providerType === "custom"; + const showAuthType = + providerType === "custom" && (routingMode ?? "url") === "url"; + const showTargetNote = + providerType === "custom" && routingMode === "target"; + + async function onSubmit(values: AiProviderFormValues) { + setLoading(true); + try { + const res = await api.put< + AxiosResponse + >(`/org/${orgId}/ai-provider`, toAiProviderCreatePayload(values)); + + toast({ + title: t("success"), + description: t("aiProviderCreated") + }); + + router.push( + `/${orgId}/settings/ai-providers/${res.data.data.provider.providerId}` + ); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiProviderErrorCreate"), + description: formatAxiosError(e, t("aiProviderErrorCreate")) + }); + } finally { + setLoading(false); + } + } + + return ( + <> +
+ + +
+ + + + + + {t("aiProviderGeneral")} + + + {t("aiProviderGeneralDescription")} + + + + + +
+ + + + ( + + + + + + + )} + /> + + + + ( + + + {t("name")} + + + + + + + )} + /> + + + + ( + + + {t( + "aiProviderType" + )} + + + { + field.onChange( + value + ); + form.setValue( + "upstreamUrl", + emptyUpstreamForType( + value + ) + ); + if ( + value !== + "custom" + ) { + form.setValue( + "routingMode", + "url" + ); + } + }} + /> + + + + )} + /> + + + {showRoutingMode && ( + + ( + + + {t( + "aiProviderRoutingMode" + )} + + + { + field.onChange( + value + ); + if ( + value === + "target" + ) { + form.setValue( + "upstreamUrl", + "" + ); + } + }} + /> + + + {t( + "aiProviderRoutingModeDescription" + )} + + + + )} + /> + + )} + + {showTargetNote && ( + + + + + {t( + "aiProviderRoutingModeTarget" + )} + + + {t( + "aiProviderRoutingModeTargetNote" + )} + + + + )} + + {showUpstream && ( + + ( + + + {t( + "aiProviderUpstreamUrl" + )} + {requireUpstream + ? "" + : " (optional)"} + + + + + + {requireUpstream + ? t( + "aiProviderUpstreamUrlDescription" + ) + : t( + "aiProviderUpstreamUrlOptionalDescription" + )} + + + + )} + /> + + )} + + {showAuthType && ( + + ( + + + {t( + "aiProviderAuthType" + )} + + + + {t( + "aiProviderAuthTypeDescription" + )} + + + + )} + /> + + )} + + + ( + + + {t( + "aiProviderApiKey" + )} + + + + + + {t( + "aiProviderApiKeyDescription" + )} + + + + )} + /> + + + + ( + + + + + + + )} + /> + + +
+ +
+
+ + + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/ai-providers/page.tsx b/src/app/[orgId]/settings/ai-providers/page.tsx new file mode 100644 index 000000000..9858641e2 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/page.tsx @@ -0,0 +1,71 @@ +import AiProvidersTable from "@app/components/AiProvidersTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import type { ListAiProvidersResponse } from "@server/routers/aiProvider/types"; +import type { AxiosResponse } from "axios"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export const metadata: Metadata = { + title: "AI Providers" +}; + +export const dynamic = "force-dynamic"; + +type Props = { + params: Promise<{ orgId: string }>; + searchParams: Promise>; +}; + +export default async function AiProvidersPage({ params, searchParams }: Props) { + const { orgId } = await params; + const searchParamsObj = new URLSearchParams(await searchParams); + const t = await getTranslations(); + + let providers: ListAiProvidersResponse["providers"] = []; + let pagination: ListAiProvidersResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + try { + const res = await internal.get>( + `/org/${orgId}/ai-providers?${searchParamsObj.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + providers = responseData.providers; + pagination = responseData.pagination; + } catch { + // empty list on error + } + + return ( + <> + + + ({ + providerId: provider.providerId, + name: provider.name, + type: provider.type, + routingMode: provider.routingMode, + enabled: provider.enabled, + effectiveUpstreamUrl: provider.effectiveUpstreamUrl, + apiKeyLastChars: provider.apiKeyLastChars + }))} + rowCount={pagination.total} + pagination={{ + pageIndex: pagination.page - 1, + pageSize: pagination.pageSize + }} + /> + + ); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index b0650ce05..cb91bd734 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -25,6 +25,7 @@ import { Server, Settings, ShieldIcon, + Sparkles, SquareMousePointer, TagIcon, TicketCheck, @@ -186,6 +187,16 @@ export const orgNavSections = ( } ] }, + { + heading: "sidebarAi", + items: [ + { + title: "sidebarAiProviders", + href: "/{orgId}/settings/ai-providers", + icon: + } + ] + }, { heading: "sidebarOrganization", items: [ @@ -471,6 +482,16 @@ export const commandBarNavSections = ( } ] }, + { + heading: "sidebarAi", + items: [ + { + title: "commandAiProviders", + href: "/{orgId}/settings/ai-providers", + icon: + } + ] + }, { heading: "commandLogsAndAnalytics", items: [ diff --git a/src/components/AiProviderTypeSelect.tsx b/src/components/AiProviderTypeSelect.tsx new file mode 100644 index 000000000..72e076f53 --- /dev/null +++ b/src/components/AiProviderTypeSelect.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { aiProviderTypeValues } from "@app/lib/aiProviderFormSchema"; +import type { AiProviderType } from "@server/lib/aiProviderDefaults"; +import { CheckIcon, ChevronsUpDown } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; + +const typeLabelMap = { + openai: "aiProviderTypeOpenai", + anthropic: "aiProviderTypeAnthropic", + googleGemini: "aiProviderTypeGoogleGemini", + vertexAi: "aiProviderTypeVertexAi", + bedrock: "aiProviderTypeBedrock", + microsoftFoundry: "aiProviderTypeMicrosoftFoundry", + openRouter: "aiProviderTypeOpenRouter", + vercelAiGateway: "aiProviderTypeVercelAiGateway", + custom: "aiProviderTypeCustom" +} as const; + +const typeDescriptionMap = { + openai: "aiProviderTypeOpenaiDescription", + anthropic: "aiProviderTypeAnthropicDescription", + googleGemini: "aiProviderTypeGoogleGeminiDescription", + vertexAi: "aiProviderTypeVertexAiDescription", + bedrock: "aiProviderTypeBedrockDescription", + microsoftFoundry: "aiProviderTypeMicrosoftFoundryDescription", + openRouter: "aiProviderTypeOpenRouterDescription", + vercelAiGateway: "aiProviderTypeVercelAiGatewayDescription", + custom: "aiProviderTypeCustomDescription" +} as const; + +type AiProviderTypeSelectProps = { + value: AiProviderType; + onChange: (value: AiProviderType) => void; + disabled?: boolean; + className?: string; +}; + +export function AiProviderTypeSelect({ + value, + onChange, + disabled, + className +}: AiProviderTypeSelectProps) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + + const options = useMemo( + () => + aiProviderTypeValues.map((type) => ({ + type, + title: t(typeLabelMap[type]), + description: t(typeDescriptionMap[type]) + })), + [t] + ); + + const selected = options.find((option) => option.type === value); + + return ( + + + + + + + + + + {t("aiProviderTypeNotFound")} + + + {options.map((option) => ( + { + onChange(option.type); + setOpen(false); + }} + > + +
+ + {option.title} + + + {option.description} + +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/components/AiProvidersTable.tsx b/src/components/AiProvidersTable.tsx new file mode 100644 index 000000000..ba7fbb00c --- /dev/null +++ b/src/components/AiProvidersTable.tsx @@ -0,0 +1,317 @@ +"use client"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Switch } from "@app/components/ui/switch"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import type { PaginationState } from "@tanstack/react-table"; +import { ArrowRight, MoreHorizontal } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; + +export type AiProviderRow = { + providerId: number; + name: string; + type: string; + routingMode: string; + enabled: boolean; + effectiveUpstreamUrl: string | null; + apiKeyLastChars: string | null; +}; + +type AiProvidersTableProps = { + providers: AiProviderRow[]; + orgId: string; + pagination: PaginationState; + rowCount: number; +}; + +export default function AiProvidersTable({ + providers, + orgId, + pagination, + rowCount +}: AiProvidersTableProps) { + const router = useRouter(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + + const [rows, setRows] = useState(providers); + const [selected, setSelected] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isRefreshing, startTransition] = useTransition(); + + useEffect(() => { + setRows(providers); + }, [providers]); + + function refreshData() { + startTransition(() => { + try { + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ searchParams }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ searchParams }); + }, 300); + + function typeLabel(type: string) { + const key = `aiProviderType${type.charAt(0).toUpperCase()}${type.slice(1)}`; + const map: Record = { + openai: "aiProviderTypeOpenai", + anthropic: "aiProviderTypeAnthropic", + googleGemini: "aiProviderTypeGoogleGemini", + vertexAi: "aiProviderTypeVertexAi", + bedrock: "aiProviderTypeBedrock", + microsoftFoundry: "aiProviderTypeMicrosoftFoundry", + openRouter: "aiProviderTypeOpenRouter", + vercelAiGateway: "aiProviderTypeVercelAiGateway", + custom: "aiProviderTypeCustom" + }; + return t(map[type] ?? key); + } + + function routingLabel(mode: string) { + return mode === "target" + ? t("aiProviderRoutingModeTarget") + : t("aiProviderRoutingModeUrl"); + } + + async function toggleEnabled(row: AiProviderRow, enabled: boolean) { + setRows((prev) => + prev.map((r) => + r.providerId === row.providerId ? { ...r, enabled } : r + ) + ); + + try { + await api.post(`/ai-provider/${row.providerId}`, { enabled }); + toast({ + title: t("success"), + description: t("aiProviderUpdated") + }); + router.refresh(); + } catch (e) { + setRows((prev) => + prev.map((r) => + r.providerId === row.providerId + ? { ...r, enabled: row.enabled } + : r + ) + ); + toast({ + variant: "destructive", + title: t("aiProviderErrorUpdate"), + description: formatAxiosError(e, t("aiProviderErrorUpdate")) + }); + } + } + + function deleteProvider(row: AiProviderRow) { + startTransition(async () => { + try { + await api.delete(`/ai-provider/${row.providerId}`); + setRows((prev) => + prev.filter((r) => r.providerId !== row.providerId) + ); + setIsDeleteModalOpen(false); + setSelected(null); + toast({ + title: t("success"), + description: t("aiProviderDeleted") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiProviderErrorDelete"), + description: formatAxiosError(e, t("aiProviderErrorDelete")) + }); + } + }); + } + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + enableHiding: false, + header: () => {t("name")}, + cell: ({ row }) => ( + + {row.original.name} + + ) + }, + { + accessorKey: "type", + header: () => ( + {t("aiProviderType")} + ), + cell: ({ row }) => typeLabel(row.original.type) + }, + { + accessorKey: "routingMode", + header: () => ( + {t("aiProviderRoutingMode")} + ), + cell: ({ row }) => routingLabel(row.original.routingMode) + }, + { + accessorKey: "effectiveUpstreamUrl", + header: () => ( + {t("aiProviderUpstreamUrl")} + ), + cell: ({ row }) => ( + {row.original.effectiveUpstreamUrl ?? "-"} + ) + }, + { + accessorKey: "enabled", + header: () => ( + {t("aiProviderEnabled")} + ), + cell: ({ row }) => ( + + toggleEnabled(row.original, checked) + } + /> + ) + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => ( +
+ + + + + + + + {t("edit")} + + + { + setSelected(row.original); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + + + +
+ ) + } + ], + [orgId, t] + ); + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + if (!val) { + setSelected(null); + } + }} + dialog={ +
+

{t("aiProviderQuestionRemove")}

+

{t("aiProviderMessageRemove")}

+
+ } + buttonText={t("aiProviderDeleteConfirm")} + onConfirm={async () => deleteProvider(selected)} + string={selected.name} + title={t("aiProviderDelete")} + /> + )} + + + router.push(`/${orgId}/settings/ai-providers/create`) + } + tableId="ai-providers-table" + searchPlaceholder={t("aiProvidersSearch")} + pagination={pagination} + onPaginationChange={handlePaginationChange} + searchQuery={searchParams.get("query")?.toString()} + onSearch={handleSearchChange} + onRefresh={refreshData} + isRefreshing={isRefreshing || isFiltering} + rowCount={rowCount} + stickyRightColumn="actions" + /> + + ); +} diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index bffaadeba..6c91dbfef 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -47,6 +47,7 @@ export function HorizontalTabs({ .replace("{userId}", params.userId as string) .replace("{clientId}", params.clientId as string) .replace("{apiKeyId}", params.apiKeyId as string) + .replace("{providerId}", params.providerId as string) .replace("{remoteExitNodeId}", params.remoteExitNodeId as string); } diff --git a/src/contexts/aiProviderContext.ts b/src/contexts/aiProviderContext.ts new file mode 100644 index 000000000..bb72eef1a --- /dev/null +++ b/src/contexts/aiProviderContext.ts @@ -0,0 +1,13 @@ +import { createContext } from "react"; +import type { AiProviderPublic } from "@server/routers/aiProvider/types"; + +export type AiProviderContextType = { + provider: AiProviderPublic; + updateProvider: (updated: Partial) => void; +}; + +const AiProviderContext = createContext( + undefined +); + +export default AiProviderContext; diff --git a/src/hooks/useAiProviderContext.ts b/src/hooks/useAiProviderContext.ts new file mode 100644 index 000000000..9ab1182ca --- /dev/null +++ b/src/hooks/useAiProviderContext.ts @@ -0,0 +1,12 @@ +import AiProviderContext from "@app/contexts/aiProviderContext"; +import { useContext } from "react"; + +export function useAiProviderContext() { + const context = useContext(AiProviderContext); + if (context === undefined) { + throw new Error( + "useAiProviderContext must be used within an AiProviderProvider" + ); + } + return context; +} diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts new file mode 100644 index 000000000..184be14c9 --- /dev/null +++ b/src/lib/aiProviderFormSchema.ts @@ -0,0 +1,202 @@ +import { z } from "zod"; +import { + AI_PROVIDER_DEFAULTS, + providerRequiresUpstreamUrl, + type AiProviderType +} from "@server/lib/aiProviderDefaults"; + +export const aiProviderTypeValues = [ + "openai", + "anthropic", + "googleGemini", + "vertexAi", + "bedrock", + "microsoftFoundry", + "openRouter", + "vercelAiGateway", + "custom" +] as const satisfies readonly AiProviderType[]; + +export const aiProviderFormSchema = z + .object({ + name: z.string().trim().min(1), + type: z.enum(aiProviderTypeValues), + upstreamUrl: z.string().optional().nullable(), + apiKey: z.string().optional(), + authType: z.enum(["bearer"]).optional().nullable(), + routingMode: z.enum(["url", "target"]).optional(), + skipTlsVerification: z.boolean().optional(), + budgetAmount: z.number().positive().nullable().optional(), + budgetUnit: z.enum(["usd", "tokens"]).optional().nullable(), + enabled: z.boolean().optional() + }) + .superRefine((data, ctx) => { + const routingMode = + data.type === "custom" ? (data.routingMode ?? "url") : "url"; + + if (data.type !== "custom" && data.routingMode === "target") { + ctx.addIssue({ + code: "custom", + message: + "routingMode target is only allowed for custom providers", + path: ["routingMode"] + }); + } + + const upstreamUrl = + data.upstreamUrl && data.upstreamUrl.trim().length > 0 + ? data.upstreamUrl.trim() + : null; + + if (upstreamUrl) { + try { + new URL(upstreamUrl); + } catch { + ctx.addIssue({ + code: "custom", + message: "Invalid URL", + path: ["upstreamUrl"] + }); + } + } + + if ( + providerRequiresUpstreamUrl(data.type, routingMode) && + !upstreamUrl + ) { + ctx.addIssue({ + code: "custom", + message: `upstreamUrl is required for ${data.type} providers`, + path: ["upstreamUrl"] + }); + } + + if (data.type === "custom" && routingMode === "url" && !data.authType) { + ctx.addIssue({ + code: "custom", + message: "authType is required for custom providers", + path: ["authType"] + }); + } + + const hasAmount = + data.budgetAmount !== undefined && data.budgetAmount !== null; + const hasUnit = + data.budgetUnit !== undefined && data.budgetUnit !== null; + + if (hasAmount !== hasUnit) { + ctx.addIssue({ + code: "custom", + message: + "budgetAmount and budgetUnit must both be set or both omitted", + path: hasAmount ? ["budgetUnit"] : ["budgetAmount"] + }); + } + }); + +export type AiProviderFormValues = z.infer; + +export function emptyUpstreamForType(type: AiProviderType): string { + if (type === "custom") { + return ""; + } + return AI_PROVIDER_DEFAULTS[type].upstreamUrl ?? ""; +} + +export function showsUpstreamUrlField( + type: AiProviderType, + routingMode: "url" | "target" | undefined +): boolean { + const mode = type === "custom" ? (routingMode ?? "url") : "url"; + if (mode === "target") { + return false; + } + return true; +} + +export function upstreamUrlRequired( + type: AiProviderType, + routingMode: "url" | "target" | undefined +): boolean { + const mode = type === "custom" ? (routingMode ?? "url") : "url"; + return providerRequiresUpstreamUrl(type, mode); +} + +export function toAiProviderCreatePayload(values: AiProviderFormValues) { + const routingMode = + values.type === "custom" ? (values.routingMode ?? "url") : "url"; + const upstreamRaw = values.upstreamUrl?.trim() ?? ""; + const upstreamUrl = + routingMode === "target" + ? null + : upstreamRaw.length > 0 + ? upstreamRaw + : null; + + const hasBudget = + values.budgetAmount !== undefined && + values.budgetAmount !== null && + values.budgetUnit; + + return { + name: values.name.trim(), + type: values.type, + routingMode: values.type === "custom" ? routingMode : undefined, + upstreamUrl, + apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined, + authType: + values.type === "custom" && routingMode === "url" + ? (values.authType ?? "bearer") + : (values.authType ?? undefined), + skipTlsVerification: values.skipTlsVerification, + budgetAmount: hasBudget ? values.budgetAmount : null, + budgetUnit: hasBudget ? values.budgetUnit : null, + enabled: values.enabled ?? true + }; +} + +export function toAiProviderUpdatePayload(values: AiProviderFormValues) { + const routingMode = + values.type === "custom" ? (values.routingMode ?? "url") : "url"; + const upstreamRaw = values.upstreamUrl?.trim() ?? ""; + const upstreamUrl = + routingMode === "target" + ? null + : upstreamRaw.length > 0 + ? upstreamRaw + : null; + + const hasBudget = + values.budgetAmount !== undefined && + values.budgetAmount !== null && + values.budgetUnit; + + const payload: Record = { + name: values.name.trim(), + routingMode: values.type === "custom" ? routingMode : "url", + upstreamUrl, + authType: + values.type === "custom" && routingMode === "url" + ? (values.authType ?? "bearer") + : (values.authType ?? null), + skipTlsVerification: values.skipTlsVerification ?? false, + budgetAmount: hasBudget ? values.budgetAmount : null, + budgetUnit: hasBudget ? values.budgetUnit : null, + enabled: values.enabled ?? true + }; + + if (values.apiKey?.trim()) { + payload.apiKey = values.apiKey.trim(); + } + + return payload; +} + +export function toAiProviderConfigurationPayload(values: AiProviderFormValues) { + const { + name: _name, + enabled: _enabled, + ...payload + } = toAiProviderUpdatePayload(values); + return payload; +} diff --git a/src/providers/AiProviderProvider.tsx b/src/providers/AiProviderProvider.tsx new file mode 100644 index 000000000..072f75608 --- /dev/null +++ b/src/providers/AiProviderProvider.tsx @@ -0,0 +1,47 @@ +"use client"; + +import AiProviderContext from "@app/contexts/aiProviderContext"; +import type { AiProviderPublic } from "@server/routers/aiProvider/types"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; + +type AiProviderProviderProps = { + children: React.ReactNode; + provider: AiProviderPublic; +}; + +export function AiProviderProvider({ + children, + provider: serverProvider +}: AiProviderProviderProps) { + const [provider, setProvider] = useState(serverProvider); + const t = useTranslations(); + + useEffect(() => { + setProvider(serverProvider); + }, [serverProvider]); + + const updateProvider = (updated: Partial) => { + if (!provider) { + throw new Error(t("aiProviderErrorNoUpdate")); + } + + setProvider((prev) => { + if (!prev) { + return prev; + } + return { + ...prev, + ...updated + }; + }); + }; + + return ( + + {children} + + ); +} + +export default AiProviderProvider; From 33b775e2633c6fbbf08ca9f6466fd9a2ae54a69b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 3 Aug 2026 16:06:28 -0400 Subject: [PATCH 185/494] clean up providers ui --- messages/en-US.json | 17 +- server/routers/aiProvider/createAiProvider.ts | 4 +- server/routers/aiProvider/getAiProvider.ts | 4 +- server/routers/aiProvider/types.ts | 24 +- server/routers/aiProvider/updateAiProvider.ts | 16 +- server/routers/aiProvider/validation.ts | 2 +- .../[providerId]/authentication/page.tsx | 235 ++++++ .../[providerId]/configuration/page.tsx | 432 +--------- .../ai-providers/[providerId]/layout.tsx | 8 +- .../[providerId]/network/page.tsx | 362 +++++++++ .../settings/ai-providers/create/page.tsx | 751 ++++++++++-------- .../public/ProxyResourceTargetsForm.tsx | 431 +++++----- src/app/navigation.tsx | 7 +- src/components/AiProviderTypeSelect.tsx | 16 +- .../resource-target-address-item.tsx | 16 +- src/lib/aiProviderFormSchema.ts | 35 +- src/lib/queries.ts | 14 + 17 files changed, 1412 insertions(+), 962 deletions(-) create mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx create mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 4febc876a..d6759f822 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1632,7 +1632,7 @@ "sidebarInvitations": "Invitations", "sidebarRoles": "Roles", "sidebarShareableLinks": "Shareable Links", - "sidebarAi": "AI", + "sidebarAiGateway": "AI Gateway", "sidebarAiProviders": "Providers", "commandAiProviders": "AI Providers", "aiProvidersTitle": "AI Providers", @@ -1648,7 +1648,11 @@ "aiProviderGeneral": "General", "aiProviderGeneralDescription": "Basic settings for this provider", "aiProviderConfiguration": "Configuration", - "aiProviderConfigurationDescription": "Upstream URL, routing, authentication, and TLS settings", + "aiProviderConfigurationDescription": "Network routing and authentication for this provider", + "aiProviderNetworkSettings": "Network Settings", + "aiProviderNetworkSettingsDescription": "Choose how traffic reaches this provider", + "aiProviderAuthSettings": "Authentication", + "aiProviderAuthSettingsDescription": "Credentials used for both upstream URL and Pangolin target routing", "aiProviderType": "Provider Type", "aiProviderTypeSearch": "Search providers...", "aiProviderTypeNotFound": "No provider type found", @@ -1675,18 +1679,19 @@ "aiProviderUpstreamUrlOptionalDescription": "Leave blank to use the default upstream URL for this provider", "aiProviderEffectiveUpstreamUrl": "Effective Upstream URL", "aiProviderApiKey": "API Key", - "aiProviderApiKeyDescription": "Stored encrypted. Leave blank on edit to keep the existing key.", + "aiProviderApiKeyDescription": "API key used to authenticate requests to this provider", "aiProviderApiKeyLastChars": "API Key", "aiProviderAuthType": "Auth Type", "aiProviderAuthTypeBearer": "Bearer", "aiProviderAuthTypeDescription": "How the upstream API authenticates requests", "aiProviderRoutingMode": "Routing Mode", - "aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to Pangolin HTTPS targets", + "aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to HTTP targets on your sites", "aiProviderRoutingModeUrl": "Upstream URL", "aiProviderRoutingModeUrlDescription": "Call a public or private API base URL", - "aiProviderRoutingModeTarget": "Pangolin Targets", + "aiProviderRoutingModeTarget": "Site Targets", "aiProviderRoutingModeTargetDescription": "Route through HTTPS targets on your sites", - "aiProviderRoutingModeTargetNote": "Target configuration will be available in a later update. You can still create this provider now.", + "aiProviderRoutingModeTargetNote": "After creating this provider, configure site targets on the Network Settings tab.", + "aiProviderTargetNoOne": "This provider doesn't have any targets. Add a target to route requests through your sites.", "aiProviderSkipTlsVerification": "Skip TLS Verification", "aiProviderSkipTlsVerificationDescription": "Disable TLS certificate verification for the upstream connection", "aiProviderBudget": "Budget", diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts index 947810ee1..1d58344cb 100644 --- a/server/routers/aiProvider/createAiProvider.ts +++ b/server/routers/aiProvider/createAiProvider.ts @@ -135,7 +135,9 @@ export async function createAiProvider( .returning(); return response(res, { - data: { provider: toPublicAiProvider(provider) }, + data: { + provider: toPublicAiProvider(provider, { includeApiKey: true }) + }, success: true, error: false, message: "AI provider created successfully", diff --git a/server/routers/aiProvider/getAiProvider.ts b/server/routers/aiProvider/getAiProvider.ts index 32a337c85..28a0152e1 100644 --- a/server/routers/aiProvider/getAiProvider.ts +++ b/server/routers/aiProvider/getAiProvider.ts @@ -67,7 +67,9 @@ export async function getAiProvider( } return response(res, { - data: { provider: toPublicAiProvider(provider) }, + data: { + provider: toPublicAiProvider(provider, { includeApiKey: true }) + }, success: true, error: false, message: "AI provider retrieved successfully", diff --git a/server/routers/aiProvider/types.ts b/server/routers/aiProvider/types.ts index 2a6047e23..3fa966785 100644 --- a/server/routers/aiProvider/types.ts +++ b/server/routers/aiProvider/types.ts @@ -6,8 +6,12 @@ import { type AiProviderRoutingMode, type AiProviderType } from "@server/lib/aiProviderDefaults"; +import { decrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; export type AiProviderPublic = Omit & { + /** Decrypted API key. Only included on get/create/update of a single provider. */ + apiKey?: string | null; effectiveUpstreamUrl: string | null; effectiveAuthType: AiProviderAuthType | null; }; @@ -36,8 +40,11 @@ export type CreateOrEditAiModelResponse = { model: AiModel; }; -export function toPublicAiProvider(provider: AiProvider): AiProviderPublic { - const { apiKey: _apiKey, ...rest } = provider; +export function toPublicAiProvider( + provider: AiProvider, + options?: { includeApiKey?: boolean } +): AiProviderPublic { + const { apiKey: encryptedApiKey, ...rest } = provider; const resolved = resolveAiProviderConfig({ type: provider.type as AiProviderType, upstreamUrl: provider.upstreamUrl, @@ -45,8 +52,21 @@ export function toPublicAiProvider(provider: AiProvider): AiProviderPublic { routingMode: provider.routingMode as AiProviderRoutingMode | null }); + let apiKey: string | null | undefined; + if (options?.includeApiKey) { + if (encryptedApiKey) { + apiKey = decrypt( + encryptedApiKey, + config.getRawConfig().server.secret! + ); + } else { + apiKey = null; + } + } + return { ...rest, + ...(options?.includeApiKey ? { apiKey } : {}), effectiveUpstreamUrl: resolved.upstreamUrl, effectiveAuthType: resolved.authType }; diff --git a/server/routers/aiProvider/updateAiProvider.ts b/server/routers/aiProvider/updateAiProvider.ts index 494e459f6..39787fb93 100644 --- a/server/routers/aiProvider/updateAiProvider.ts +++ b/server/routers/aiProvider/updateAiProvider.ts @@ -125,7 +125,10 @@ export async function updateAiProvider( ? body.upstreamUrl : existing.upstreamUrl; const nextAuthType = - body.authType !== undefined ? body.authType : existing.authType; + body.authType !== undefined + ? body.authType + : (existing.authType ?? + (providerType === "custom" ? "bearer" : null)); const validation = z .object({ @@ -178,6 +181,13 @@ export async function updateAiProvider( } if (body.authType !== undefined) { updateData.authType = body.authType; + } else if ( + providerType === "custom" && + !existing.authType && + nextAuthType + ) { + // Backfill required authType for custom providers created without one + updateData.authType = nextAuthType; } if (body.apiKey !== undefined) { @@ -193,7 +203,9 @@ export async function updateAiProvider( .returning(); return response(res, { - data: { provider: toPublicAiProvider(provider) }, + data: { + provider: toPublicAiProvider(provider, { includeApiKey: true }) + }, success: true, error: false, message: "AI provider updated successfully", diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts index a0b0aa684..c830c08de 100644 --- a/server/routers/aiProvider/validation.ts +++ b/server/routers/aiProvider/validation.ts @@ -75,7 +75,7 @@ export function refineProviderUpstreamFields( }); } - if (data.type === "custom" && routingMode === "url" && !data.authType) { + if (data.type === "custom" && !data.authType) { ctx.addIssue({ code: "custom", message: "authType is required for custom providers", diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx new file mode 100644 index 000000000..f054c7901 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +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 { + aiProviderFormSchema, + toAiProviderAuthPayload, + type AiProviderFormValues +} from "@app/lib/aiProviderFormSchema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import type { AiProviderType } from "@server/lib/aiProviderDefaults"; +import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; +import type { AxiosResponse } from "axios"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +export default function AiProviderAuthenticationPage() { + const { provider, updateProvider } = useAiProviderContext(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const t = useTranslations(); + const [saveLoading, setSaveLoading] = useState(false); + + const form = useForm({ + resolver: zodResolver(aiProviderFormSchema), + defaultValues: { + name: provider.name, + type: provider.type as AiProviderType, + upstreamUrl: provider.upstreamUrl ?? "", + apiKey: provider.apiKey ?? "", + authType: (provider.authType as "bearer" | null) ?? "bearer", + routingMode: (provider.routingMode as "url" | "target") ?? "url", + skipTlsVerification: provider.skipTlsVerification, + budgetAmount: provider.budgetAmount, + budgetUnit: provider.budgetUnit as "usd" | "tokens" | null, + enabled: provider.enabled + } + }); + + const showAuthType = provider.type === "custom"; + + async function onSubmit(values: AiProviderFormValues) { + setSaveLoading(true); + try { + const res = await api.post< + AxiosResponse + >( + `/ai-provider/${provider.providerId}`, + toAiProviderAuthPayload({ + ...values, + type: provider.type as AiProviderType + }) + ); + const updated = res.data.data.provider; + updateProvider(updated); + form.reset({ + name: updated.name, + type: updated.type as AiProviderType, + upstreamUrl: updated.upstreamUrl ?? "", + apiKey: updated.apiKey ?? "", + authType: (updated.authType as "bearer" | null) ?? "bearer", + routingMode: (updated.routingMode as "url" | "target") ?? "url", + skipTlsVerification: updated.skipTlsVerification, + budgetAmount: updated.budgetAmount, + budgetUnit: updated.budgetUnit as "usd" | "tokens" | null, + enabled: updated.enabled + }); + toast({ + title: t("success"), + description: t("aiProviderUpdated") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiProviderErrorUpdate"), + description: formatAxiosError(e, t("aiProviderErrorUpdate")) + }); + } finally { + setSaveLoading(false); + } + } + + return ( + + + + + {t("aiProviderAuthSettings")} + + + {t("aiProviderAuthSettingsDescription")} + + + + + +
+ + + {showAuthType && ( + + ( + + + {t( + "aiProviderAuthType" + )} + + + + {t( + "aiProviderAuthTypeDescription" + )} + + + + )} + /> + + )} + + + ( + + + {t("aiProviderApiKey")} + + + + + + {t( + "aiProviderApiKeyDescription" + )} + + + + )} + /> + + +
+ +
+
+ + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx index 03a9ab48f..bf003b96e 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx @@ -1,426 +1,12 @@ -"use client"; +import { redirect } from "next/navigation"; -import { - SettingsContainer, - SettingsFormCell, - SettingsFormGrid, - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionFooter, - SettingsSectionForm, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; -import { StrategySelect } from "@app/components/StrategySelect"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { Button } from "@app/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -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 { - aiProviderFormSchema, - showsUpstreamUrlField, - toAiProviderConfigurationPayload, - upstreamUrlRequired, - type AiProviderFormValues -} from "@app/lib/aiProviderFormSchema"; -import { zodResolver } from "@hookform/resolvers/zod"; -import type { AiProviderType } from "@server/lib/aiProviderDefaults"; -import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; -import type { AxiosResponse } from "axios"; -import { InfoIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; +type Props = { + params: Promise<{ orgId: string; providerId: string }>; +}; -export default function AiProviderConfigurationPage() { - const { provider, updateProvider } = useAiProviderContext(); - const { env } = useEnvContext(); - const api = createApiClient({ env }); - const router = useRouter(); - const t = useTranslations(); - const [saveLoading, setSaveLoading] = useState(false); - - const form = useForm({ - resolver: zodResolver(aiProviderFormSchema), - defaultValues: { - name: provider.name, - type: provider.type as AiProviderType, - upstreamUrl: provider.upstreamUrl ?? "", - apiKey: "", - authType: (provider.authType as "bearer" | null) ?? "bearer", - routingMode: (provider.routingMode as "url" | "target") ?? "url", - skipTlsVerification: provider.skipTlsVerification, - budgetAmount: provider.budgetAmount, - budgetUnit: provider.budgetUnit as "usd" | "tokens" | null, - enabled: provider.enabled - } - }); - - const providerType = form.watch("type"); - const routingMode = form.watch("routingMode"); - const showUpstream = showsUpstreamUrlField(providerType, routingMode); - const requireUpstream = upstreamUrlRequired(providerType, routingMode); - const showRoutingMode = providerType === "custom"; - const showAuthType = - providerType === "custom" && (routingMode ?? "url") === "url"; - const showTargetNote = - providerType === "custom" && routingMode === "target"; - - async function onSubmit(values: AiProviderFormValues) { - setSaveLoading(true); - try { - const res = await api.post< - AxiosResponse - >( - `/ai-provider/${provider.providerId}`, - toAiProviderConfigurationPayload({ - ...values, - type: provider.type as AiProviderType - }) - ); - const updated = res.data.data.provider; - updateProvider(updated); - form.reset({ - name: updated.name, - type: updated.type as AiProviderType, - upstreamUrl: updated.upstreamUrl ?? "", - apiKey: "", - authType: (updated.authType as "bearer" | null) ?? "bearer", - routingMode: (updated.routingMode as "url" | "target") ?? "url", - skipTlsVerification: updated.skipTlsVerification, - budgetAmount: updated.budgetAmount, - budgetUnit: updated.budgetUnit as "usd" | "tokens" | null, - enabled: updated.enabled - }); - toast({ - title: t("success"), - description: t("aiProviderUpdated") - }); - router.refresh(); - } catch (e) { - toast({ - variant: "destructive", - title: t("aiProviderErrorUpdate"), - description: formatAxiosError(e, t("aiProviderErrorUpdate")) - }); - } finally { - setSaveLoading(false); - } - } - - return ( - - - - - {t("aiProviderConfiguration")} - - - {t("aiProviderConfigurationDescription")} - - - - - -
- - - {showRoutingMode && ( - - ( - - - {t( - "aiProviderRoutingMode" - )} - - - { - field.onChange( - value - ); - if ( - value === - "target" - ) { - form.setValue( - "upstreamUrl", - "" - ); - } - }} - /> - - - {t( - "aiProviderRoutingModeDescription" - )} - - - - )} - /> - - )} - - {showTargetNote && ( - - - - - {t( - "aiProviderRoutingModeTarget" - )} - - - {t( - "aiProviderRoutingModeTargetNote" - )} - - - - )} - - {showUpstream && ( - - ( - - - {t( - "aiProviderUpstreamUrl" - )} - {requireUpstream - ? "" - : " (optional)"} - - - - - - {requireUpstream - ? t( - "aiProviderUpstreamUrlDescription" - ) - : t( - "aiProviderUpstreamUrlOptionalDescription" - )} - - {provider.effectiveUpstreamUrl && ( - - {t( - "aiProviderEffectiveUpstreamUrl" - )} - {": "} - - { - provider.effectiveUpstreamUrl - } - - - )} - - - )} - /> - - )} - - {showAuthType && ( - - ( - - - {t( - "aiProviderAuthType" - )} - - - - {t( - "aiProviderAuthTypeDescription" - )} - - - - )} - /> - - )} - - - ( - - - {t("aiProviderApiKey")} - - - - - - {provider.apiKeyLastChars - ? `••••${provider.apiKeyLastChars}. ${t("aiProviderApiKeyDescription")}` - : t( - "aiProviderApiKeyDescription" - )} - - - - )} - /> - - - - ( - - - - - - - )} - /> - - -
- -
-
- - - -
-
- ); +export default async function AiProviderConfigurationRedirect({ + params +}: Props) { + const { orgId, providerId } = await params; + redirect(`/${orgId}/settings/ai-providers/${providerId}/network`); } diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx index b734f6084..5846ad86d 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx @@ -66,8 +66,12 @@ export default async function AiProviderLayout({ children, params }: Props) { href: "/{orgId}/settings/ai-providers/{providerId}/general" }, { - title: t("aiProviderConfiguration"), - href: "/{orgId}/settings/ai-providers/{providerId}/configuration" + title: t("aiProviderNetworkSettings"), + href: "/{orgId}/settings/ai-providers/{providerId}/network" + }, + { + title: t("aiProviderAuthSettings"), + href: "/{orgId}/settings/ai-providers/{providerId}/authentication" } ]; diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx new file mode 100644 index 000000000..90ef74d10 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { + ProxyResourceTargetsForm, + type ProxyResourceTargetsFormHandle +} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm"; +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSubsectionDescription, + SettingsSubsectionHeader, + SettingsSubsectionTitle +} from "@app/components/Settings"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +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 { + aiProviderFormSchema, + showsUpstreamUrlField, + toAiProviderNetworkPayload, + upstreamUrlRequired, + type AiProviderFormValues +} from "@app/lib/aiProviderFormSchema"; +import { aiProviderQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; +import type { AiProviderType } from "@server/lib/aiProviderDefaults"; +import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; +import { useQuery } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; +import { useTranslations } from "next-intl"; +import { useParams, useRouter } from "next/navigation"; +import { useRef, useState } from "react"; +import { useForm } from "react-hook-form"; + +export default function AiProviderNetworkPage() { + const { provider, updateProvider } = useAiProviderContext(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const params = useParams(); + const orgId = params.orgId as string; + const router = useRouter(); + const t = useTranslations(); + const [saveLoading, setSaveLoading] = useState(false); + const targetsFormRef = useRef(null); + + const form = useForm({ + resolver: zodResolver(aiProviderFormSchema), + defaultValues: { + name: provider.name, + type: provider.type as AiProviderType, + upstreamUrl: provider.upstreamUrl ?? "", + apiKey: "", + authType: (provider.authType as "bearer" | null) ?? "bearer", + routingMode: (provider.routingMode as "url" | "target") ?? "url", + skipTlsVerification: provider.skipTlsVerification, + budgetAmount: provider.budgetAmount, + budgetUnit: provider.budgetUnit as "usd" | "tokens" | null, + enabled: provider.enabled + } + }); + + const providerType = form.watch("type"); + const routingMode = form.watch("routingMode"); + const showUpstream = showsUpstreamUrlField(providerType, routingMode); + const requireUpstream = upstreamUrlRequired(providerType, routingMode); + const showRoutingMode = providerType === "custom"; + const isTargetModeSelected = routingMode === "target"; + const isTargetModeSaved = + provider.type === "custom" && provider.routingMode === "target"; + const showTargetsForm = showRoutingMode && isTargetModeSelected; + + const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery({ + ...aiProviderQueries.providerTargets({ + providerId: provider.providerId + }), + enabled: isTargetModeSaved + }); + + async function onSubmit(values: AiProviderFormValues) { + setSaveLoading(true); + try { + const res = await api.post< + AxiosResponse + >( + `/ai-provider/${provider.providerId}`, + toAiProviderNetworkPayload({ + ...values, + type: provider.type as AiProviderType + }) + ); + const updated = res.data.data.provider; + updateProvider(updated); + form.reset({ + name: updated.name, + type: updated.type as AiProviderType, + upstreamUrl: updated.upstreamUrl ?? "", + apiKey: "", + authType: (updated.authType as "bearer" | null) ?? "bearer", + routingMode: (updated.routingMode as "url" | "target") ?? "url", + skipTlsVerification: updated.skipTlsVerification, + budgetAmount: updated.budgetAmount, + budgetUnit: updated.budgetUnit as "usd" | "tokens" | null, + enabled: updated.enabled + }); + + if (values.routingMode === "target" && targetsFormRef.current) { + const targetsSaved = await targetsFormRef.current.save({ + silent: true + }); + if (!targetsSaved) { + return; + } + } + + toast({ + title: t("success"), + description: t("aiProviderUpdated") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiProviderErrorUpdate"), + description: formatAxiosError(e, t("aiProviderErrorUpdate")) + }); + } finally { + setSaveLoading(false); + } + } + + return ( + + + + + {t("aiProviderNetworkSettings")} + + + {t("aiProviderNetworkSettingsDescription")} + + + + + +
+ + + {showRoutingMode && ( + + ( + + + {t( + "aiProviderRoutingMode" + )} + + + { + field.onChange( + value + ); + if ( + value === + "target" + ) { + form.setValue( + "upstreamUrl", + "" + ); + } + }} + /> + + + {t( + "aiProviderRoutingModeDescription" + )} + + + + )} + /> + + )} + + {showUpstream && ( + + ( + + + {t( + "aiProviderUpstreamUrl" + )} + {requireUpstream + ? "" + : " (optional)"} + + + + + + {requireUpstream + ? t( + "aiProviderUpstreamUrlDescription" + ) + : t( + "aiProviderUpstreamUrlOptionalDescription" + )} + + + + )} + /> + + )} + + + ( + + + + + + + )} + /> + + +
+ +
+ + {showTargetsForm && + (!isTargetModeSaved || !isLoadingTargets) && ( +
+ + + {t("targets")} + + + {t("targetsDescription")} + + + +
+ )} +
+ + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 9a9258d8d..ec6eee6c7 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -1,5 +1,9 @@ "use client"; +import { + ProxyResourceTargetsForm, + type LocalTarget +} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm"; import { SettingsContainer, SettingsFormCell, @@ -7,16 +11,17 @@ import { SettingsSection, SettingsSectionBody, SettingsSectionDescription, - SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, - SettingsSectionTitle + SettingsSectionTitle, + SettingsSubsectionDescription, + SettingsSubsectionHeader, + SettingsSubsectionTitle } from "@app/components/Settings"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect"; import { StrategySelect } from "@app/components/StrategySelect"; import { SwitchInput } from "@app/components/SwitchInput"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; import { Form, @@ -39,7 +44,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { - aiProviderFormSchema, + aiProviderCreateFormSchema, emptyUpstreamForType, showsUpstreamUrlField, toAiProviderCreatePayload, @@ -49,10 +54,9 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import type { AxiosResponse } from "axios"; -import { InfoIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useForm } from "react-hook-form"; export default function CreateAiProviderPage() { @@ -63,9 +67,10 @@ export default function CreateAiProviderPage() { const router = useRouter(); const t = useTranslations(); const [loading, setLoading] = useState(false); + const targetsRef = useRef([]); const form = useForm({ - resolver: zodResolver(aiProviderFormSchema), + resolver: zodResolver(aiProviderCreateFormSchema), defaultValues: { name: "", type: "openai", @@ -86,26 +91,101 @@ export default function CreateAiProviderPage() { const showUpstream = showsUpstreamUrlField(providerType, routingMode); const requireUpstream = upstreamUrlRequired(providerType, routingMode); const showRoutingMode = providerType === "custom"; - const showAuthType = - providerType === "custom" && (routingMode ?? "url") === "url"; - const showTargetNote = - providerType === "custom" && routingMode === "target"; + const showAuthType = providerType === "custom"; + const showTargets = providerType === "custom" && routingMode === "target"; + + async function createTargets( + providerId: number, + localTargets: LocalTarget[] + ) { + for (const target of localTargets) { + const data = { + ip: target.ip, + port: target.port, + method: target.method, + enabled: target.enabled, + siteId: target.siteId, + hcEnabled: target.hcEnabled, + hcPath: target.hcPath || null, + hcMethod: target.hcMethod || null, + hcInterval: target.hcInterval || null, + hcTimeout: target.hcTimeout || null, + hcHeaders: target.hcHeaders || null, + hcScheme: target.hcScheme || null, + hcHostname: target.hcHostname || null, + hcPort: target.hcPort || null, + hcFollowRedirects: target.hcFollowRedirects || null, + hcStatus: target.hcStatus || null, + hcUnhealthyInterval: target.hcUnhealthyInterval || null, + hcMode: target.hcMode || null, + hcTlsServerName: target.hcTlsServerName, + hcHealthyThreshold: target.hcHealthyThreshold || null, + hcUnhealthyThreshold: target.hcUnhealthyThreshold || null, + path: target.path, + pathMatchType: target.pathMatchType, + rewritePath: target.rewritePath, + rewritePathType: target.rewritePathType, + priority: target.priority + }; + await api.put(`/ai-provider/${providerId}/target`, data); + } + } async function onSubmit(values: AiProviderFormValues) { + const targets = targetsRef.current; + + if (showTargets) { + const invalidTargets = targets.filter( + (target) => + !target.ip || + target.ip.trim() === "" || + !target.port || + target.port <= 0 || + isNaN(target.port) + ); + if (invalidTargets.length > 0) { + toast({ + variant: "destructive", + title: t("targetErrorInvalidIp"), + description: t("targetErrorInvalidIpDescription") + }); + return; + } + } + setLoading(true); try { const res = await api.put< AxiosResponse >(`/org/${orgId}/ai-provider`, toAiProviderCreatePayload(values)); + const providerId = res.data.data.provider.providerId; + + if (showTargets && targets.length > 0) { + try { + await createTargets(providerId, targets); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiProviderErrorCreate"), + description: formatAxiosError( + e, + t("aiProviderErrorCreate") + ) + }); + router.push( + `/${orgId}/settings/ai-providers/${providerId}/network` + ); + return; + } + } + toast({ title: t("success"), description: t("aiProviderCreated") }); - router.push( - `/${orgId}/settings/ai-providers/${res.data.data.provider.providerId}` - ); + router.push(`/${orgId}/settings/ai-providers/${providerId}`); } catch (e) { toast({ variant: "destructive", @@ -134,91 +214,143 @@ export default function CreateAiProviderPage() {
- - - - - {t("aiProviderGeneral")} - - - {t("aiProviderGeneralDescription")} - - +
+ + + + + {t("aiProviderGeneral")} + + + {t("aiProviderGeneralDescription")} + + - - - - - + + + + + ( + + + {t("name")} + + + + + + + )} + /> + + + + ( + + + {t("aiProviderType")} + + + { + field.onChange( + value + ); + form.setValue( + "upstreamUrl", + emptyUpstreamForType( + value + ) + ); + if ( + value !== + "custom" + ) { + form.setValue( + "routingMode", + "url" + ); + targetsRef.current = + []; + } + }} + /> + + + + )} + /> + + + + + + + + + + {t("aiProviderNetworkSettings")} + + + {t("aiProviderNetworkSettingsDescription")} + + + + + + + {showRoutingMode && ( ( - - - - - - - )} - /> - - - - ( - - - {t("name")} - - - - - - - )} - /> - - - - ( {t( - "aiProviderType" + "aiProviderRoutingMode" )} - + + {t( + "aiProviderRoutingModeDescription" + )} + )} /> + )} - {showRoutingMode && ( - - ( - - - {t( - "aiProviderRoutingMode" - )} - - - { - field.onChange( - value - ); - if ( - value === - "target" - ) { - form.setValue( - "upstreamUrl", - "" - ); - } - }} - /> - - - {t( - "aiProviderRoutingModeDescription" - )} - - - - )} - /> - - )} - - {showTargetNote && ( - - - - - {t( - "aiProviderRoutingModeTarget" - )} - - - {t( - "aiProviderRoutingModeTargetNote" - )} - - - - )} - - {showUpstream && ( - - ( - - - {t( - "aiProviderUpstreamUrl" - )} - {requireUpstream - ? "" - : " (optional)"} - - - - - - {requireUpstream - ? t( - "aiProviderUpstreamUrlDescription" - ) - : t( - "aiProviderUpstreamUrlOptionalDescription" - )} - - - - )} - /> - - )} - - {showAuthType && ( - - ( - - - {t( - "aiProviderAuthType" - )} - - - - {t( - "aiProviderAuthTypeDescription" - )} - - - - )} - /> - - )} - + {showUpstream && ( ( {t( - "aiProviderApiKey" + "aiProviderUpstreamUrl" )} + {requireUpstream + ? "" + : " (optional)"} + {requireUpstream + ? t( + "aiProviderUpstreamUrlDescription" + ) + : t( + "aiProviderUpstreamUrlOptionalDescription" + )} + + + + )} + /> + + )} + + + ( + + + + + + + )} + /> + + + + + {showTargets && ( +
+ + + {t("targets")} + + + {t("targetsDescription")} + + + { + targetsRef.current = nextTargets; + }} + allowedMethods={["http", "https"]} + emptyMessage={t( + "aiProviderTargetNoOne" + )} + embedded + hideSaveButton + /> +
+ )} +
+
+ + + + + {t("aiProviderAuthSettings")} + + + {t("aiProviderAuthSettingsDescription")} + + + + + + + {showAuthType && ( + + ( + + {t( - "aiProviderApiKeyDescription" + "aiProviderAuthType" + )} + + + + {t( + "aiProviderAuthTypeDescription" )} @@ -462,53 +546,68 @@ export default function CreateAiProviderPage() { )} /> + )} - - ( - - - - - - - )} - /> - - - - - - - - - - -
+ + ( + + + {t("aiProviderApiKey")} + + + + + + {t( + "aiProviderApiKeyDescription" + )} + + + + )} + /> + + + + +
+
+ +
+ + +
+ ); } diff --git a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx index 0db4cb156..db3bec315 100644 --- a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx +++ b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx @@ -42,7 +42,7 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { DockerManager, DockerState } from "@app/lib/docker"; -import { orgQueries, resourceQueries } from "@app/lib/queries"; +import { orgQueries, resourceQueries, aiProviderQueries } from "@app/lib/queries"; import { build } from "@server/build"; import { type GetResourceResponse } from "@server/routers/resource"; import { CreateTargetResponse } from "@server/routers/target"; @@ -63,9 +63,11 @@ import { ExternalLink, Info, Plus } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { + forwardRef, useActionState, useCallback, useEffect, + useImperativeHandle, useMemo, useState } from "react"; @@ -80,27 +82,52 @@ export type LocalTarget = Omit< "protocol" >; -interface ProxyResourceTargetsFormProps { +export type ProxyResourceTargetsFormHandle = { + save: (options?: { silent?: boolean }) => Promise; +}; + +type ProxyResourceTargetsFormProps = { orgId: string; isHttp: boolean; initialTargets?: LocalTarget[]; - /** Edit mode: when provided, shows a save button and polls for health status */ + /** Edit mode for a public resource: save button + health polling */ resource?: GetResourceResponse; + /** Edit mode for an AI provider: save button + health polling */ + providerId?: number; updateResource?: ResourceContextType["updateResource"]; /** Create mode: called whenever the targets list changes */ onChange?: (targets: LocalTarget[]) => void; -} + /** HTTP method options for address selector. Defaults to http/https/h2c. */ + allowedMethods?: ("http" | "https" | "h2c")[]; + emptyMessage?: string; + /** Render table without its own SettingsSection wrapper */ + embedded?: boolean; + /** Hide the built-in save button (use ref.save from parent) */ + hideSaveButton?: boolean; +}; -export function ProxyResourceTargetsForm({ - orgId, - isHttp, - initialTargets = [], - resource, - updateResource, - onChange -}: ProxyResourceTargetsFormProps) { +export const ProxyResourceTargetsForm = forwardRef< + ProxyResourceTargetsFormHandle, + ProxyResourceTargetsFormProps +>(function ProxyResourceTargetsForm( + { + orgId, + isHttp, + initialTargets = [], + resource, + providerId, + updateResource, + onChange, + allowedMethods = ["http", "https", "h2c"], + emptyMessage, + embedded = false, + hideSaveButton = false + }, + ref +) { const t = useTranslations(); const api = createApiClient(useEnvContext()); + const isEditMode = !!resource || !!providerId; const [targets, setTargets] = useState(initialTargets); const [targetsToRemove, setTargetsToRemove] = useState([]); @@ -111,7 +138,7 @@ export function ProxyResourceTargetsForm({ }, [targets]); // Poll health status only in edit mode - const { data: polledTargets } = useQuery({ + const { data: polledResourceTargets } = useQuery({ ...resourceQueries.resourceTargets({ resourceId: resource?.resourceId ?? 0 }), @@ -119,6 +146,18 @@ export function ProxyResourceTargetsForm({ enabled: !!resource }); + const { data: polledProviderTargets } = useQuery({ + ...aiProviderQueries.providerTargets({ + providerId: providerId ?? 0 + }), + refetchInterval: 10_000, + enabled: !!providerId + }); + + const polledTargets = providerId + ? polledProviderTargets + : polledResourceTargets; + useEffect(() => { if (!polledTargets) return; setTargets((prev) => @@ -427,6 +466,7 @@ export function ProxyResourceTargetsForm({ isHttp={isHttp} proxyTarget={row.original} updateTarget={updateTarget} + allowedMethods={allowedMethods} /> ); }, @@ -576,16 +616,22 @@ export function ProxyResourceTargetsForm({ refreshContainersForSite, openHealthCheckDialog, removeTarget, + allowedMethods, t ]); function addNewTarget() { + const defaultMethod = providerId + ? (allowedMethods[0] ?? "https") + : isHttp + ? "http" + : null; const newTarget: LocalTarget = { targetId: -Date.now(), ip: "", mode: ((resource?.mode as LocalTarget["mode"]) ?? (isHttp ? "http" : "tcp")) as LocalTarget["mode"], - method: isHttp ? "http" : null, + method: defaultMethod, port: 0, siteId: sites.length > 0 ? sites[0].siteId : 0, siteName: sites.length > 0 ? sites[0].name : "", @@ -595,8 +641,8 @@ export function ProxyResourceTargetsForm({ rewritePathType: null, priority: 100, enabled: true, - resourceId: resource?.resourceId ?? 0, - providerId: null, + resourceId: resource?.resourceId ?? null, + providerId: providerId ?? null, hcEnabled: false, hcPath: null, hcMethod: null, @@ -671,10 +717,16 @@ export function ProxyResourceTargetsForm({ } }, [isAdvancedMode]); - const [, formAction, isSubmitting] = useActionState(saveTargets, null); + const [, formAction, isSubmitting] = useActionState( + async () => { + await saveTargets(); + return null; + }, + null + ); const addTargetButton = ( - @@ -682,8 +734,8 @@ export function ProxyResourceTargetsForm({ const hasTargets = targets.length > 0; - async function saveTargets() { - if (!resource) return; + async function saveTargets(options?: { silent?: boolean }) { + if (!isEditMode) return true; const targetsWithInvalidFields = targets.filter( (target) => @@ -699,7 +751,7 @@ export function ProxyResourceTargetsForm({ title: t("targetErrorInvalidIp"), description: t("targetErrorInvalidIpDescription") }); - return; + return false; } try { @@ -743,9 +795,12 @@ export function ProxyResourceTargetsForm({ } if (target.new) { + const createPath = providerId + ? `/ai-provider/${providerId}/target` + : `/resource/${resource!.resourceId}/target`; const res = await api.put< AxiosResponse - >(`/resource/${resource.resourceId}/target`, data); + >(createPath, data); target.targetId = res.data.data.targetId; target.new = false; } else if (target.updated) { @@ -754,24 +809,33 @@ export function ProxyResourceTargetsForm({ } } - toast({ - title: - targets.length === 0 - ? t("targetTargetsCleared") - : t("settingsUpdated"), - description: - targets.length === 0 - ? t("targetTargetsClearedDescription") - : t("settingsUpdatedDescription") - }); + if (!options?.silent) { + toast({ + title: + targets.length === 0 + ? t("targetTargetsCleared") + : t("settingsUpdated"), + description: + targets.length === 0 + ? t("targetTargetsClearedDescription") + : t("settingsUpdatedDescription") + }); + } setTargetsToRemove([]); router.refresh(); - await queryClient.invalidateQueries( - resourceQueries.resourceTargets({ - resourceId: resource.resourceId - }) - ); + if (providerId) { + await queryClient.invalidateQueries( + aiProviderQueries.providerTargets({ providerId }) + ); + } else if (resource) { + await queryClient.invalidateQueries( + resourceQueries.resourceTargets({ + resourceId: resource.resourceId + }) + ); + } + return true; } catch (err) { console.error(err); toast({ @@ -782,151 +846,164 @@ export function ProxyResourceTargetsForm({ t("settingsErrorUpdateDescription") ) }); + return false; } } + useImperativeHandle(ref, () => ({ + save: saveTargets + })); + + const advancedModeToggleId = providerId + ? `advanced-mode-toggle-provider-${providerId}` + : resource + ? `advanced-mode-toggle-resource-${resource.resourceId}` + : "advanced-mode-toggle"; + + const targetsTable = ( + <> +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + const isSiteColumn = + header.column.id === "site"; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const isActionsColumn = + cell.column.id === "actions"; + const isSiteColumn = + cell.column.id === "site"; + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + )} + +
+
+ {hasTargets && ( +
+
+ {addTargetButton} +
+ + +
+
+
+ )} + {build === "saas" && + targets.length > 1 && + new Set(targets.map((t) => t.siteId)).size > 1 && ( +

+ {t("proxyMultiSiteRoundRobinNodeHelp")}{" "} + + {t("learnMore")} + + + . +

+ )} + + ); + return ( <> - - - {t("targets")} - - {t("targetsDescription")} - - - -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const isActionsColumn = - header.column.id === "actions"; - const isSiteColumn = - header.column.id === "site"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column - .columnDef - .header, - header.getContext() - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => { - const isActionsColumn = - cell.column.id === - "actions"; - const isSiteColumn = - cell.column.id === - "site"; - return ( - - {flexRender( - cell.column - .columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - )} - -
-
- {hasTargets && ( -
-
- {addTargetButton} -
- - -
-
-
- )} - {build === "saas" && - targets.length > 1 && - new Set(targets.map((t) => t.siteId)).size > 1 && ( -

- {t("proxyMultiSiteRoundRobinNodeHelp")}{" "} - - {t("learnMore")} - - - . -

- )} -
+ {embedded ? ( +
{targetsTable}
+ ) : ( + + + + {t("targets")} + + + {t("targetsDescription")} + + + {targetsTable} - {/* Save button — only shown in edit mode */} - {resource && ( -
- -
- )} -
+ {isEditMode && !hideSaveButton && ( +
+ +
+ )} +
+ )} {selectedTargetForHealthCheck && ( ); -} +}); diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index cb91bd734..e6971bc94 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -31,6 +31,7 @@ import { TicketCheck, Unplug, User, + UserCheck, UserCog, Users, Waypoints @@ -176,7 +177,7 @@ export const orgNavSections = ( { title: "sidebarApprovals", href: "/{orgId}/settings/access/approvals", - icon: + icon: } ] : []), @@ -188,7 +189,7 @@ export const orgNavSections = ( ] }, { - heading: "sidebarAi", + heading: "sidebarAiGateway", items: [ { title: "sidebarAiProviders", @@ -483,7 +484,7 @@ export const commandBarNavSections = ( ] }, { - heading: "sidebarAi", + heading: "sidebarAiGateway", items: [ { title: "commandAiProviders", diff --git a/src/components/AiProviderTypeSelect.tsx b/src/components/AiProviderTypeSelect.tsx index 72e076f53..ed0565f53 100644 --- a/src/components/AiProviderTypeSelect.tsx +++ b/src/components/AiProviderTypeSelect.tsx @@ -83,20 +83,14 @@ export function AiProviderTypeSelect({ aria-expanded={open} disabled={disabled} className={cn( - "w-full justify-between font-normal h-auto min-h-10 py-2", + "w-full justify-between", + !selected && "text-muted-foreground", className )} > -
- - {selected?.title ?? t("noneSelected")} - - {selected?.description && ( - - {selected.description} - - )} -
+ + {selected?.title ?? t("noneSelected")} + diff --git a/src/components/resource-target-address-item.tsx b/src/components/resource-target-address-item.tsx index 68acec7f1..58ef3b33b 100644 --- a/src/components/resource-target-address-item.tsx +++ b/src/components/resource-target-address-item.tsx @@ -133,12 +133,14 @@ export type ResourceTargetAddressItemProps = { updateTarget: (targetId: number, data: Partial) => void; proxyTarget: LocalTarget; isHttp: boolean; + allowedMethods?: ("http" | "https" | "h2c")[]; }; export function ResourceTargetAddressItem({ updateTarget, proxyTarget, - isHttp + isHttp, + allowedMethods = ["http", "https", "h2c"] }: ResourceTargetAddressItemProps) { return (
@@ -157,9 +159,15 @@ export function ResourceTargetAddressItem({ {proxyTarget.method || "http"} - http - https - h2c + {allowedMethods.includes("http") && ( + http + )} + {allowedMethods.includes("https") && ( + https + )} + {allowedMethods.includes("h2c") && ( + h2c + )} )} diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index 184be14c9..8861dcdbf 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -71,7 +71,7 @@ export const aiProviderFormSchema = z }); } - if (data.type === "custom" && routingMode === "url" && !data.authType) { + if (data.type === "custom" && !data.authType) { ctx.addIssue({ code: "custom", message: "authType is required for custom providers", @@ -96,6 +96,18 @@ export const aiProviderFormSchema = z export type AiProviderFormValues = z.infer; +export const aiProviderCreateFormSchema = aiProviderFormSchema.superRefine( + (data, ctx) => { + if (!data.apiKey?.trim()) { + ctx.addIssue({ + code: "custom", + message: "API key is required", + path: ["apiKey"] + }); + } + } +); + export function emptyUpstreamForType(type: AiProviderType): string { if (type === "custom") { return ""; @@ -145,7 +157,7 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) { upstreamUrl, apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined, authType: - values.type === "custom" && routingMode === "url" + values.type === "custom" ? (values.authType ?? "bearer") : (values.authType ?? undefined), skipTlsVerification: values.skipTlsVerification, @@ -176,7 +188,7 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) { routingMode: values.type === "custom" ? routingMode : "url", upstreamUrl, authType: - values.type === "custom" && routingMode === "url" + values.type === "custom" ? (values.authType ?? "bearer") : (values.authType ?? null), skipTlsVerification: values.skipTlsVerification ?? false, @@ -192,6 +204,23 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) { return payload; } +export function toAiProviderNetworkPayload(values: AiProviderFormValues) { + const full = toAiProviderUpdatePayload(values); + return { + routingMode: full.routingMode, + upstreamUrl: full.upstreamUrl, + skipTlsVerification: full.skipTlsVerification + }; +} + +export function toAiProviderAuthPayload(values: AiProviderFormValues) { + const full = toAiProviderUpdatePayload(values); + return { + authType: full.authType, + ...(values.apiKey !== undefined ? { apiKey: values.apiKey.trim() } : {}) + }; +} + export function toAiProviderConfigurationPayload(values: AiProviderFormValues) { const { name: _name, diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 96024c3a0..bdc2f413a 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1163,6 +1163,20 @@ export const logQueries = { }) }; +export const aiProviderQueries = { + providerTargets: ({ providerId }: { providerId: number }) => + queryOptions({ + queryKey: ["AI_PROVIDERS", providerId, "TARGETS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/ai-provider/${providerId}/targets`, { signal }); + + return res.data.data.targets; + } + }) +}; + export const resourceQueries = { resourceUsers: ({ resourceId }: { resourceId: number }) => queryOptions({ From 9b25ebd6cdca48efbb228a30f115c6af4d67d2d4 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 4 Aug 2026 10:19:22 -0400 Subject: [PATCH 186/494] remove token fallback --- server/routers/aiGateway/chatCompletions.ts | 45 +++------------------ 1 file changed, 5 insertions(+), 40 deletions(-) diff --git a/server/routers/aiGateway/chatCompletions.ts b/server/routers/aiGateway/chatCompletions.ts index 7a4ba3a84..d8ec7f37c 100644 --- a/server/routers/aiGateway/chatCompletions.ts +++ b/server/routers/aiGateway/chatCompletions.ts @@ -36,14 +36,6 @@ type ResolvedTarget = { allowedModelIds: number[] | null; }; -// Fallback for clients that hit the endpoint directly (e.g. an AI tool's -// "API key" field) instead of going through a browser session - badger -// forwards whatever Authorization header the client sent untouched in that -// case. This prefix lets us tell "this bearer value is a Pangolin resource -// access token" apart from an arbitrary/opaque API key a user might paste -// in, without guessing based on format alone. -const USER_TOKEN_PREFIX = "pu_"; - export type RequestUser = { userId: string; username: string; @@ -94,37 +86,7 @@ async function resolveRequestUser( } } - // User devices hitting the endpoint directly (no browser session to - // forward) fall back to a Pangolin resource access token passed as the - // client's "API key". - const authHeader = req.headers["authorization"]; - if (typeof authHeader !== "string" || !resourceId) { - return null; - } - - const bearer = authHeader.match(/^Bearer\s+(.+)$/i)?.[1]; - if (!bearer || !bearer.startsWith(USER_TOKEN_PREFIX)) { - return null; - } - - const [accessTokenId, accessToken] = bearer - .slice(USER_TOKEN_PREFIX.length) - .split("."); - if (!accessTokenId || !accessToken) { - return null; - } - - const { valid, tokenItem } = await verifyResourceAccessToken({ - accessToken, - accessTokenId, - resourceId - }); - - if (!valid || !tokenItem?.userId) { - return null; - } - - return buildRequestUser(tokenItem.userId, tokenItem.orgId); + return null; } async function resolveTarget(host: string): Promise { @@ -222,7 +184,10 @@ function getCompletionsPath(type: AiProviderType): string { return "/chat/completions"; } -export async function chatCompletions(req: Request, res: Response): Promise { +export async function chatCompletions( + req: Request, + res: Response +): Promise { try { const host = (req.headers.host || "").split(":")[0]; if (!host) { From a7e44944fb1bb4f74375e545e264e9b0f0b3723c Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 4 Aug 2026 10:43:00 -0400 Subject: [PATCH 187/494] Use ip to find user --- server/db/pg/schema/schema.ts | 4 +- server/routers/aiGateway/chatCompletions.ts | 95 ++++++++++++++++++++- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index e253cd6d5..51552233c 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -99,7 +99,7 @@ export const sites = pgTable( name: varchar("name").notNull(), pubKey: varchar("pubKey"), subnet: varchar("subnet"), - exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node + exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node and INCLUDES THE CIDR megabytesIn: real("bytesIn").default(0), megabytesOut: real("bytesOut").default(0), lastBandwidthUpdate: varchar("lastBandwidthUpdate"), @@ -1234,7 +1234,7 @@ export const clients = pgTable( olmId: text("olmId"), // to lock it to a specific olm optionally name: varchar("name").notNull(), pubKey: varchar("pubKey"), - exitNodeSubnet: varchar("exitNodeSubnet").notNull(), + exitNodeSubnet: varchar("exitNodeSubnet").notNull(), // INCLUDES THE CIDR megabytesIn: real("bytesIn"), megabytesOut: real("bytesOut"), lastBandwidthUpdate: varchar("lastBandwidthUpdate"), diff --git a/server/routers/aiGateway/chatCompletions.ts b/server/routers/aiGateway/chatCompletions.ts index d8ec7f37c..c9c32c2ba 100644 --- a/server/routers/aiGateway/chatCompletions.ts +++ b/server/routers/aiGateway/chatCompletions.ts @@ -4,7 +4,9 @@ import { AiProvider, aiModels, aiProviders, + clients, db, + exitNodes, resourceAiModels, resources, siteResourceAiModels, @@ -25,9 +27,64 @@ import { validateSessionToken } from "@server/auth/sessions/app"; import { getUserOrgRoles } from "@server/lib/userOrgRoles"; +import { isIpInCidr } from "@server/lib/ip"; +import { localCache } from "@server/lib/cache"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; +// Short-lived local caches so a burst of requests from the same IP/user +// doesn't hit the database on every single request. None of this is +// security-critical to cache aggressively (identity is re-derived from the +// session cookie or from a client's exit-node-scoped subnet each time), so +// a small TTL is just an efficiency win, not a trust boundary. +const EXIT_NODE_RANGES_CACHE_KEY = "aiGateway:exitNodeRanges"; +const EXIT_NODE_RANGES_TTL_SEC = 6000; +const CLIENT_BY_IP_TTL_SEC = 30; +const REQUEST_USER_TTL_SEC = 30; + +type CachedClient = { clientId: number; userId: string | null } | null; + +// The set of CIDRs an exit node manages; client exitNodeSubnets are always +// /32s carved out of one of these ranges. Checking against this small, +// cacheable list lets us skip the (much more frequent) per-IP client lookup +// entirely for traffic that could never match a client anyway. +async function getExitNodeRanges(): Promise { + const cached = localCache.get(EXIT_NODE_RANGES_CACHE_KEY); + if (cached) { + return cached; + } + + const rows = await db + .select({ address: exitNodes.address }) + .from(exitNodes); + const ranges = rows.map((r) => r.address); + + localCache.set( + EXIT_NODE_RANGES_CACHE_KEY, + ranges, + EXIT_NODE_RANGES_TTL_SEC + ); + return ranges; +} + +async function findClientByIp(ip: string): Promise { + const cacheKey = `aiGateway:clientByIp:${ip}`; + const cached = localCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const [client] = await db + .select({ clientId: clients.clientId, userId: clients.userId }) + .from(clients) + .where(eq(clients.exitNodeSubnet, `${ip}/32`)) + .limit(1); + + const result: CachedClient = client || null; + localCache.set(cacheKey, result, CLIENT_BY_IP_TTL_SEC); + return result; +} + type ResolvedTarget = { resourceId: number | null; orgId: string | null; @@ -48,6 +105,12 @@ async function buildRequestUser( userId: string, orgId: string | null ): Promise { + const cacheKey = `aiGateway:requestUser:${userId}:${orgId || ""}`; + const cached = localCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + const [user] = await db .select() .from(users) @@ -55,18 +118,22 @@ async function buildRequestUser( .limit(1); if (!user) { + localCache.set(cacheKey, null, REQUEST_USER_TTL_SEC); return null; } const orgRoles = orgId ? await getUserOrgRoles(user.userId, orgId) : []; - return { + const requestUser: RequestUser = { userId: user.userId, username: user.username, email: user.email, name: user.name, role: orgRoles.map((r) => r.roleName).join(", ") || null }; + + localCache.set(cacheKey, requestUser, REQUEST_USER_TTL_SEC); + return requestUser; } async function resolveRequestUser( @@ -86,7 +153,31 @@ async function resolveRequestUser( } } - return null; + // TODO: MAKE SURE THIS CAN NOT BE SPOOFED AND CAN BE TRUSTED AS AN INTERNAL ADDRESS FROM A NODE + + // No session cookie - fall back to identifying the caller by source IP. + // A client's exitNodeSubnet is a /32 handed out from one of our exit + // node's address ranges, so an IP that isn't inside any of those ranges + // can never belong to a client and we can skip the DB entirely. + const ip = req.ip; + if (!ip) { + return null; + } + + const exitNodeRanges = await getExitNodeRanges(); + const inExitNodeRange = exitNodeRanges.some((range) => + isIpInCidr(ip, range) + ); + if (!inExitNodeRange) { + return null; + } + + const client = await findClientByIp(ip); + if (!client || !client.userId) { + return null; + } + + return buildRequestUser(client.userId, orgId); } async function resolveTarget(host: string): Promise { From 0b30cfc341984b8de49012b7ec743dff3d5070e2 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 4 Aug 2026 11:48:36 -0400 Subject: [PATCH 188/494] Working on inference resource management --- server/lib/rebuildClientAssociations.ts | 19 +++++--- server/routers/olm/sync.ts | 43 +++++++++++++++---- .../siteResource/createSiteResource.ts | 8 ++-- .../siteResource/updateSiteResource.ts | 24 ++++++----- .../private/[niceId]/inference/page.tsx | 2 - src/lib/privateResourceForm.ts | 20 ++++++++- 6 files changed, 87 insertions(+), 29 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 194bfe446..63a71382d 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -1177,12 +1177,12 @@ async function syncClientExitNodeConnections( const connectPayloads: { clientId: string; message: { type: string; data: any }; - options: { compress: boolean }; + options: { compress: boolean; incrementConfigVersion: boolean }; }[] = []; const disconnectPayloads: { clientId: string; message: { type: string; data: any }; - options: { compress: boolean }; + options: { compress: boolean; incrementConfigVersion: boolean }; }[] = []; for (const client of clientsData) { @@ -1218,7 +1218,10 @@ async function syncClientExitNodeConnections( tunnelIP: client.exitNodeSubnet.split("/")[0] } }, - options: { compress: canCompress(olm.version, "olm") } + options: { + compress: canCompress(olm.version, "olm"), + incrementConfigVersion: true + } }); } else { disconnectPayloads.push({ @@ -1227,7 +1230,10 @@ async function syncClientExitNodeConnections( type: "olm/wg/exitnode/disconnect", data: {} }, - options: { compress: canCompress(olm.version, "olm") } + options: { + compress: canCompress(olm.version, "olm"), + incrementConfigVersion: true + } }); } } @@ -1293,7 +1299,10 @@ async function syncClientExitNodeAliasUpdate( newAliases } }, - options: { compress: canCompress(olm.version, "olm") } + options: { + compress: canCompress(olm.version, "olm"), + incrementConfigVersion: true // this is important information we would need to sync + } })); if (updatePayloads.length > 0) { diff --git a/server/routers/olm/sync.ts b/server/routers/olm/sync.ts index 46e1fbd88..19d759769 100644 --- a/server/routers/olm/sync.ts +++ b/server/routers/olm/sync.ts @@ -1,6 +1,7 @@ import { Client, db, + ExitNode, exitNodes, Olm, sites, @@ -48,12 +49,26 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) { } // NOTE: WE ARE HARDCODING THE RELAY PARAMETER TO FALSE HERE BUT IN THE REGISTER MESSAGE ITS DEFINED BY THE CLIENT - const siteConfigurations = await buildSiteConfigurationForOlmClient( - client, - client.pubKey, - false, - jitMode - ); + const { siteConfigurations, exitNodeAliases } = + await buildSiteConfigurationForOlmClient( + client, + client.pubKey, + false, + jitMode + ); + + // The exit node the client itself is assigned to (for site resources hosted + // on it, e.g. inference), same as what's sent in the initial olm/wg/connect + // message. This is separate from exitNodesData below, which is only the set + // of exit nodes used for hole punching to reach site peers. + let clientExitNode: ExitNode | null = null; + if (client.exitNodeId) { + [clientExitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, client.exitNodeId)) + .limit(1); + } // Get all exit nodes from sites where the client has peers const clientSites = await db @@ -113,11 +128,23 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) { type: "olm/sync", data: { sites: siteConfigurations, - exitNodes: exitNodesData + exitNodes: exitNodesData, // this is for the holepunch information + // this is for the backhaul connection to the exit node + exitNode: + clientExitNode && client.exitNodeSubnet + ? { + aliases: exitNodeAliases, + connect: exitNodeAliases.length > 0, // we do not need to connect to the exit node if we do not have inference resources and right now all site resources on the exit node have an alias + endpoint: `${clientExitNode.endpoint}:${clientExitNode.listenPort}`, + publicKey: clientExitNode.publicKey, + serverIP: clientExitNode.address.split("/")[0], + tunnelIP: client.exitNodeSubnet.split("/")[0] + } + : undefined } }, { - compress: canCompress(olm.version, "olm") + compress: canCompress(olm.version, "olm") // we dont increment the version here or we could get into a loop! } ).catch((error) => { logger.warn(`Error sending olm sync message:`, error); diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 9a6f91ad7..b80775b49 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -571,7 +571,7 @@ export async function createSiteResource( } let tcpPortRangeStringAdjusted = tcpPortRangeString; - if (mode === "http") { + if (mode === "http" || mode === "inference") { tcpPortRangeStringAdjusted = "443,80"; } else if (mode === "ssh") { tcpPortRangeStringAdjusted = destinationPort @@ -594,12 +594,14 @@ export async function createSiteResource( aliasAddress, tcpPortRangeString: tcpPortRangeStringAdjusted, udpPortRangeString: - mode == "http" || mode == "ssh" + mode == "http" || mode == "ssh" || mode == "inference" ? "" : udpPortRangeString, disableIcmp: disableIcmp || - (mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false + (mode == "http" || mode == "ssh" || mode == "inference" + ? true + : false), // default to true for http resources, otherwise false domainId, subdomain: finalSubdomain, fullDomain, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 0ae57c087..caf524804 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -166,8 +166,11 @@ const updateSiteResourceSchema = z if (data.mode === undefined && data.destination === undefined) { return true; } - // destination is only optional for ssh mode with native authDaemonMode - if (data.mode === "ssh" && data.authDaemonMode === "native") { + // destination is only optional for ssh mode with native authDaemonMode or inference + if ( + (data.mode === "ssh" && data.authDaemonMode === "native") || + data.mode == "inference" + ) { return true; } return ( @@ -558,8 +561,9 @@ export async function updateSiteResource( }) } : {}; + let tcpPortRangeStringAdjusted = tcpPortRangeString; - if (mode === "http") { + if (mode === "http" || mode == "inference") { tcpPortRangeStringAdjusted = "443,80"; } else if (mode === "ssh") { tcpPortRangeStringAdjusted = destinationPort @@ -583,20 +587,20 @@ export async function updateSiteResource( ? alias ? alias.trim() : null - : mode !== undefined && - mode !== "host" && - mode !== "ssh" - ? null - : undefined, + : undefined, tcpPortRangeString: tcpPortRangeStringAdjusted, udpPortRangeString: - mode == "http" || mode == "ssh" + mode == "http" || mode == "ssh" || mode == "inference" ? "" : udpPortRangeString, disableIcmp: mode !== undefined ? disableIcmp || - (mode == "http" || mode == "ssh" ? true : false) + (mode == "http" || + mode == "ssh" || + mode == "inference" + ? true + : false) : disableIcmp, domainId, subdomain: finalSubdomain, diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx index 3f27f1172..24aab5067 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx @@ -20,9 +20,7 @@ import { useTranslations } from "next-intl"; import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; import { PrivateResourceInferenceDestinationFields } from "@app/components/PrivateResourceDestinationFields"; -import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges"; import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; import { asAnyControl, diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index f2ff103f6..2f11b6e7b 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -211,6 +211,14 @@ export function buildCreateSiteResourcePayload( authDaemonPort: data.authDaemonPort }) }), + ...(data.mode === "inference" && { + alias: + data.alias && + typeof data.alias === "string" && + data.alias.trim() + ? data.alias + : undefined + }), ...((data.mode === "host" || data.mode === "cidr") && { tcpPortRangeString: data.tcpPortRangeString, udpPortRangeString: data.udpPortRangeString, @@ -237,7 +245,9 @@ export function buildUpdateSiteResourcePayload( enabled: data.enabled, ...(isNativeSsh ? { destination: null, destinationPort: null } - : { destination: data.destination ?? undefined }), + : data.mode !== "inference" + ? { destination: data.destination ?? undefined } + : {}), ...(data.mode === "http" && { scheme: data.scheme, ssl: data.ssl ?? false, @@ -281,6 +291,14 @@ export function buildUpdateSiteResourcePayload( authDaemonPort: data.authDaemonPort || null }) }), + ...(data.mode === "inference" && { + alias: + data.alias && + typeof data.alias === "string" && + data.alias.trim() + ? data.alias + : null + }), ...((data.mode === "host" || data.mode === "cidr") && { tcpPortRangeString: data.tcpPortRangeString, udpPortRangeString: data.udpPortRangeString, From 973925b35d8c3b17bb162498749880ae7c5e7192 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 4 Aug 2026 14:22:36 -0400 Subject: [PATCH 189/494] allow creating basic inference resource --- messages/en-US.json | 1 + .../siteResource/createSiteResource.ts | 5 +++- .../resources/private/create/page.tsx | 26 ++++++++++++++++- src/lib/privateResourceForm.ts | 28 ++++++++++++++----- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index d6759f822..81a60f0a1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2554,6 +2554,7 @@ "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeSsh": "SSH", + "createInternalResourceDialogModeInference": "Inference", "scheme": "Scheme", "createInternalResourceDialogScheme": "Scheme", "createInternalResourceDialogEnableSsl": "Enable TLS", diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index b80775b49..3244668f5 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -164,7 +164,10 @@ const createSiteResourceSchema = z .refine( (data) => { // destination is only optional for ssh mode with native authDaemonMode - if (data.mode === "ssh" && data.authDaemonMode === "native") { + if ( + (data.mode === "ssh" && data.authDaemonMode === "native") || + data.mode == "inference" + ) { return true; } return ( diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index 7e6144172..936409d40 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -153,7 +153,11 @@ export default function CreatePrivateResourcePage() { label: t("createInternalResourceDialogModeSsh") } ] - : []) + : []), + { + value: "inference" as const, + label: t("createInternalResourceDialogModeInference") + } ]; const submitDisabled = @@ -313,6 +317,25 @@ export default function CreatePrivateResourcePage() { "destinationPort", 443 ); + } else if ( + newMode === + "inference" + ) { + form.setValue( + "siteIds", + [] + ); + setSelectedSites( + [] + ); + form.setValue( + "destination", + null + ); + form.setValue( + "destinationPort", + null + ); } else { form.setValue( "destinationPort", @@ -377,6 +400,7 @@ export default function CreatePrivateResourcePage() { )} {(mode === "host" || + mode === "inference" || (mode === "ssh" && !isNativeSsh)) && ( Date: Tue, 4 Aug 2026 14:23:25 -0400 Subject: [PATCH 190/494] send the aliases when connecting for the first time --- server/lib/rebuildClientAssociations.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 63a71382d..fe1cdea6f 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -1116,7 +1116,8 @@ async function syncClientExitNodeConnections( const requiresExitNodeRows = await trx .select({ - clientId: clientSiteResourcesAssociationsCache.clientId + clientId: clientSiteResourcesAssociationsCache.clientId, + alias: siteResources.alias }) .from(clientSiteResourcesAssociationsCache) .innerJoin( @@ -1141,6 +1142,20 @@ async function syncClientExitNodeConnections( requiresExitNodeRows.map((r) => r.clientId) ); + // Aliases for every exit-node-backed resource this client can reach, so + // the live connect push carries the same alias list the register/reconnect + // path (buildSiteConfigurationForOlmClient) would compute. + const exitNodeAliasesByClientId = new Map(); + for (const row of requiresExitNodeRows) { + if (row.alias == null) continue; + const existing = exitNodeAliasesByClientId.get(row.clientId); + if (existing) { + existing.push(row.alias); + } else { + exitNodeAliasesByClientId.set(row.clientId, [row.alias]); + } + } + const exitNodeIds = Array.from( new Set( clientsData @@ -1215,7 +1230,10 @@ async function syncClientExitNodeConnections( relayPort, publicKey: exitNode.publicKey, serverIP: exitNode.address.split("/")[0], - tunnelIP: client.exitNodeSubnet.split("/")[0] + tunnelIP: client.exitNodeSubnet.split("/")[0], + aliases: exitNodeAliasesByClientId.get( + client.clientId + ) ?? [] } }, options: { From ed8545f8a252a15ce3a82577b1235567204f62c0 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 4 Aug 2026 14:49:41 -0400 Subject: [PATCH 191/494] update schema to support models and provider access on inferences resources and add budgets --- server/db/pg/schema/schema.ts | 84 +++++++++++++++++++++++++++--- server/db/sqlite/schema/schema.ts | 85 ++++++++++++++++++++++++++++--- 2 files changed, 156 insertions(+), 13 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 51552233c..6a24064a6 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -3,6 +3,7 @@ import { InferSelectModel, sql } from "drizzle-orm"; import { bigint, boolean, + check, index, integer, pgTable, @@ -214,7 +215,10 @@ export const resources = pgTable( aiProviderId: integer("aiProviderId").references( () => aiProviders.providerId, { onDelete: "set null" } - ) + ), + modelAccessMode: varchar("modelAccessMode").$type< + "passthrough" | "catalog" | "allowlist" + >() }, (t) => [ index("idx_resources_fulldomain") @@ -496,7 +500,10 @@ export const siteResources = pgTable( aiProviderId: integer("aiProviderId").references( () => aiProviders.providerId, { onDelete: "set null" } - ) + ), + modelAccessMode: varchar("modelAccessMode").$type< + "passthrough" | "catalog" | "allowlist" + >() }, (t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)] ); @@ -1623,8 +1630,6 @@ export const aiProviders = pgTable("aiProviders", { skipTlsVerification: boolean("skipTlsVerification") .notNull() .default(false), - budgetAmount: real("budgetAmount"), - budgetUnit: varchar("budgetUnit").$type<"usd" | "tokens">(), enabled: boolean("enabled").notNull().default(true), createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }).notNull() @@ -1639,8 +1644,6 @@ export const aiModels = pgTable( .references(() => aiProviders.providerId, { onDelete: "cascade" }), modelKey: varchar("modelKey").notNull(), name: varchar("name").notNull(), - budgetAmount: real("budgetAmount"), - budgetUnit: varchar("budgetUnit").$type<"usd" | "tokens">(), enabled: boolean("enabled").notNull().default(true), createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }).notNull() @@ -1648,6 +1651,73 @@ export const aiModels = pgTable( (t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)] ); +export const aiBudgets = pgTable( + "aiBudgets", + { + budgetId: serial("budgetId").primaryKey(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + providerId: integer("providerId").references( + () => aiProviders.providerId, + { onDelete: "cascade" } + ), + modelId: integer("modelId").references(() => aiModels.modelId, { + onDelete: "cascade" + }), + resourceId: integer("resourceId").references( + () => resources.resourceId, + { onDelete: "cascade" } + ), + siteResourceId: integer("siteResourceId").references( + () => siteResources.siteResourceId, + { onDelete: "cascade" } + ), + amount: real("amount").notNull(), + unit: varchar("unit").$type<"usd" | "tokens">().notNull(), + period: varchar("period") + .$type<"monthly">() + .notNull() + .default("monthly"), + enforcement: varchar("enforcement") + .$type<"hard" | "soft">() + .notNull() + .default("hard"), + enabled: boolean("enabled").notNull().default(true), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull() + }, + (t) => [ + check( + "ai_budget_one_scope", + sql`( + (CASE WHEN ${t.providerId} IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN ${t.modelId} IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN ${t.resourceId} IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN ${t.siteResourceId} IS NOT NULL THEN 1 ELSE 0 END) + ) = 1` + ), + unique("ai_budget_provider_uniq").on(t.providerId), + unique("ai_budget_model_uniq").on(t.modelId), + unique("ai_budget_resource_uniq").on(t.resourceId), + unique("ai_budget_site_resource_uniq").on(t.siteResourceId) + ] +); + +export const aiBudgetPeriods = pgTable( + "aiBudgetPeriods", + { + periodId: serial("periodId").primaryKey(), + budgetId: integer("budgetId") + .notNull() + .references(() => aiBudgets.budgetId, { onDelete: "cascade" }), + periodStart: bigint("periodStart", { mode: "number" }).notNull(), + periodEnd: bigint("periodEnd", { mode: "number" }).notNull(), + usedAmount: real("usedAmount").notNull().default(0) + }, + (t) => [unique("ai_budget_period_start_uniq").on(t.budgetId, t.periodStart)] +); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -1734,5 +1804,7 @@ export type UserPolicy = InferSelectModel; export type ResourcePolicyRule = InferSelectModel; export type AiProvider = InferSelectModel; export type AiModel = InferSelectModel; +export type AiBudget = InferSelectModel; +export type AiBudgetPeriod = InferSelectModel; export type ResourceAiModel = InferSelectModel; export type SiteResourceAiModel = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 3d3ff2ded..f453d36f7 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,6 +1,7 @@ import { randomUUID } from "crypto"; -import { InferSelectModel } from "drizzle-orm"; +import { InferSelectModel, sql } from "drizzle-orm"; import { + check, index, integer, primaryKey, @@ -219,7 +220,10 @@ export const resources = sqliteTable("resources", { aiProviderId: integer("aiProviderId").references( () => aiProviders.providerId, { onDelete: "set null" } - ) + ), + modelAccessMode: text("modelAccessMode").$type< + "passthrough" | "catalog" | "allowlist" + >() }); export const resourceAiModels = sqliteTable( @@ -483,7 +487,10 @@ export const siteResources = sqliteTable("siteResources", { aiProviderId: integer("aiProviderId").references( () => aiProviders.providerId, { onDelete: "set null" } - ) + ), + modelAccessMode: text("modelAccessMode").$type< + "passthrough" | "catalog" | "allowlist" + >() }); export const siteResourceAiModels = sqliteTable( @@ -1605,8 +1612,6 @@ export const aiProviders = sqliteTable("aiProviders", { skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" }) .notNull() .default(false), - budgetAmount: real("budgetAmount"), - budgetUnit: text("budgetUnit").$type<"usd" | "tokens">(), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt").notNull() @@ -1621,8 +1626,6 @@ export const aiModels = sqliteTable( .references(() => aiProviders.providerId, { onDelete: "cascade" }), modelKey: text("modelKey").notNull(), name: text("name").notNull(), - budgetAmount: real("budgetAmount"), - budgetUnit: text("budgetUnit").$type<"usd" | "tokens">(), enabled: integer("enabled", { mode: "boolean" }) .notNull() .default(true), @@ -1632,6 +1635,72 @@ export const aiModels = sqliteTable( (t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)] ); +export const aiBudgets = sqliteTable( + "aiBudgets", + { + budgetId: integer("budgetId").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + providerId: integer("providerId").references( + () => aiProviders.providerId, + { onDelete: "cascade" } + ), + modelId: integer("modelId").references(() => aiModels.modelId, { + onDelete: "cascade" + }), + resourceId: integer("resourceId").references( + () => resources.resourceId, + { onDelete: "cascade" } + ), + siteResourceId: integer("siteResourceId").references( + () => siteResources.siteResourceId, + { onDelete: "cascade" } + ), + amount: real("amount").notNull(), + unit: text("unit").$type<"usd" | "tokens">().notNull(), + period: text("period").$type<"monthly">().notNull().default("monthly"), + enforcement: text("enforcement") + .$type<"hard" | "soft">() + .notNull() + .default("hard"), + enabled: integer("enabled", { mode: "boolean" }) + .notNull() + .default(true), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt").notNull() + }, + (t) => [ + check( + "ai_budget_one_scope", + sql`( + (CASE WHEN ${t.providerId} IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN ${t.modelId} IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN ${t.resourceId} IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN ${t.siteResourceId} IS NOT NULL THEN 1 ELSE 0 END) + ) = 1` + ), + unique("ai_budget_provider_uniq").on(t.providerId), + unique("ai_budget_model_uniq").on(t.modelId), + unique("ai_budget_resource_uniq").on(t.resourceId), + unique("ai_budget_site_resource_uniq").on(t.siteResourceId) + ] +); + +export const aiBudgetPeriods = sqliteTable( + "aiBudgetPeriods", + { + periodId: integer("periodId").primaryKey({ autoIncrement: true }), + budgetId: integer("budgetId") + .notNull() + .references(() => aiBudgets.budgetId, { onDelete: "cascade" }), + periodStart: integer("periodStart").notNull(), + periodEnd: integer("periodEnd").notNull(), + usedAmount: real("usedAmount").notNull().default(0) + }, + (t) => [unique("ai_budget_period_start_uniq").on(t.budgetId, t.periodStart)] +); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -1716,5 +1785,7 @@ export type RolePolicy = InferSelectModel; export type UserPolicy = InferSelectModel; export type AiProvider = InferSelectModel; export type AiModel = InferSelectModel; +export type AiBudget = InferSelectModel; +export type AiBudgetPeriod = InferSelectModel; export type ResourceAiModel = InferSelectModel; export type SiteResourceAiModel = InferSelectModel; From 2bf426bc222aa41591eaab0edbbaf3d83a5d8c69 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 4 Aug 2026 15:36:31 -0400 Subject: [PATCH 192/494] dont restrict to enterprise and dont link with providers yet --- .../private/lib/traefik/getTraefikConfig.ts | 50 ++++++++----------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 8e1b1eca2..3c8a1cab2 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -427,34 +427,26 @@ export async function getTraefikConfig( ) ); - let siteResourcesInference: { - siteResourceId: number; - alias: string | null; - ssl: boolean | null; - enabled: boolean | null; - }[] = []; - if (build == "enterprise") { - siteResourcesInference = await db - .select({ - siteResourceId: siteResources.siteResourceId, - alias: siteResources.alias, - ssl: siteResources.ssl, - enabled: siteResources.enabled - }) - .from(siteResources) - .innerJoin( - aiProviders, - eq(siteResources.aiProviderId, aiProviders.providerId) + const siteResourcesInference = await db + .select({ + siteResourceId: siteResources.siteResourceId, + alias: siteResources.alias, + ssl: siteResources.ssl, + enabled: siteResources.enabled + }) + .from(siteResources) + // .innerJoin( + // aiProviders, + // eq(siteResources.aiProviderId, aiProviders.providerId) + // ) + .where( + and( + eq(siteResources.mode, "inference"), + eq(siteResources.enabled, true), + // eq(aiProviders.enabled, true), + isNotNull(siteResources.alias) ) - .where( - and( - eq(siteResources.mode, "inference"), - eq(siteResources.enabled, true), - eq(aiProviders.enabled, true), - isNotNull(siteResources.alias) - ) - ); - } + ); let validCerts: CertificateResult[] = []; if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { @@ -1586,9 +1578,7 @@ export async function getTraefikConfig( tls = { certResolver: resolverName, - ...(preferWildcard - ? { domains: [{ main: wildCard }] } - : {}) + ...(preferWildcard ? { domains: [{ main: wildCard }] } : {}) }; } else { const matchingCert = validCerts.find( From ec5a2b0cbe9841f60a7c3bf7c63b202d83f1830c Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 4 Aug 2026 15:36:40 -0400 Subject: [PATCH 193/494] add peers to exit node --- server/routers/olm/handleOlmRegisterMessage.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 95c5e0d47..c0cee86d9 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -28,6 +28,7 @@ import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes"; +import { addPeer } from "../gerbil/peers"; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; @@ -393,6 +394,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { ); } + if (clientSubnet && exitNodeId) { + try { + // add the peer to the exit node so it can connect + await addPeer(exitNodeId, { + publicKey: publicKey, + allowedIps: [clientSubnet] + }); + } catch (error) { + logger.error(`Failed to add peer to exit node: ${error}`); + } + } + let staleHolePunchChainCount: number | undefined; const hasChainId = chainId !== undefined && chainId !== null && String(chainId) !== ""; @@ -490,7 +503,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, publicKey: exitNode.publicKey, serverIP: exitNode.address.split("/")[0], - tunnelIP: client.exitNodeSubnet.split("/")[0] + tunnelIP: `${client.exitNodeSubnet.split("/")[0]}/${exitNode.address.split("/")[1]}` // we need to use the exit node's subnet mask here because the client will be using the exit node's subnet mask for its routing table so we can address it } : undefined, chainId: chainId From 2cfd7e867beaa01ba02390ed781c8d9fbb4a0b8e Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 4 Aug 2026 15:45:44 -0400 Subject: [PATCH 194/494] delete the client peers from the exit nodes properly --- server/lib/deleteOrg.ts | 3 +++ server/routers/olm/handleOlmRegisterMessage.ts | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/server/lib/deleteOrg.ts b/server/lib/deleteOrg.ts index 20c8a3e23..43921e41b 100644 --- a/server/lib/deleteOrg.ts +++ b/server/lib/deleteOrg.ts @@ -93,6 +93,9 @@ export async function deleteOrgById( await trx.delete(sites).where(eq(sites.siteId, site.siteId)); } for (const client of orgClients) { + if (client.exitNodeId && client.pubKey) { + await deletePeer(client.exitNodeId, client.pubKey); + } const [olm] = await trx .select() .from(olms) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index c0cee86d9..5cd38dabe 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -28,7 +28,7 @@ import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes"; -import { addPeer } from "../gerbil/peers"; +import { addPeer, deletePeer } from "../gerbil/peers"; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; @@ -394,6 +394,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { ); } + if (client.pubKey && client.pubKey !== publicKey && client.exitNodeId) { + // test the old client to see if its different then remove + logger.info("Public key mismatch. Deleting old peer..."); + await deletePeer(client.exitNodeId, client.pubKey); + } + if (clientSubnet && exitNodeId) { try { // add the peer to the exit node so it can connect From e38359c74f44fc0cdc0e9e0a29b40d46970ab83b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 4 Aug 2026 15:54:24 -0400 Subject: [PATCH 195/494] add crud for adding providers and models to resources --- server/db/pg/schema/schema.ts | 58 +- server/db/sqlite/schema/schema.ts | 58 +- server/lib/aiInferenceResource.ts | 512 ++++++++++++++++++ server/lib/traefik/getTraefikConfig.ts | 10 +- .../private/lib/traefik/getTraefikConfig.ts | 23 +- server/routers/aiGateway/chatCompletions.ts | 288 +++++++--- server/routers/aiProvider/createAiModel.ts | 25 +- server/routers/aiProvider/createAiProvider.ts | 9 - server/routers/aiProvider/updateAiModel.ts | 26 +- server/routers/aiProvider/updateAiProvider.ts | 32 +- server/routers/aiProvider/validation.ts | 24 - server/routers/external.ts | 94 ++++ server/routers/integration.ts | 86 +++ .../routers/resource/addAiModelToResource.ts | 44 +- .../resource/addAiProviderToResource.ts | 152 ++++++ server/routers/resource/createResource.ts | 56 +- server/routers/resource/index.ts | 4 + .../routers/resource/listResourceAiModels.ts | 2 +- .../resource/listResourceAiProviders.ts | 94 ++++ .../resource/removeAiModelFromResource.ts | 8 +- .../resource/removeAiProviderFromResource.ts | 165 ++++++ .../routers/resource/setResourceAiModels.ts | 48 +- .../resource/setResourceAiProviders.ts | 137 +++++ server/routers/resource/updateResource.ts | 15 +- .../siteResource/addAiModelToSiteResource.ts | 49 +- .../addAiProviderToSiteResource.ts | 152 ++++++ .../siteResource/createSiteResource.ts | 50 +- server/routers/siteResource/index.ts | 4 + .../siteResource/listSiteResourceAiModels.ts | 14 +- .../listSiteResourceAiProviders.ts | 94 ++++ .../removeAiModelFromSiteResource.ts | 9 +- .../removeAiProviderFromSiteResource.ts | 164 ++++++ .../siteResource/setSiteResourceAiModels.ts | 72 +-- .../setSiteResourceAiProviders.ts | 139 +++++ .../siteResource/updateSiteResource.ts | 24 +- .../[providerId]/authentication/page.tsx | 4 - .../[providerId]/network/page.tsx | 4 - .../settings/ai-providers/create/page.tsx | 2 - src/lib/aiProviderFormSchema.ts | 30 - 39 files changed, 2343 insertions(+), 438 deletions(-) create mode 100644 server/lib/aiInferenceResource.ts create mode 100644 server/routers/resource/addAiProviderToResource.ts create mode 100644 server/routers/resource/listResourceAiProviders.ts create mode 100644 server/routers/resource/removeAiProviderFromResource.ts create mode 100644 server/routers/resource/setResourceAiProviders.ts create mode 100644 server/routers/siteResource/addAiProviderToSiteResource.ts create mode 100644 server/routers/siteResource/listSiteResourceAiProviders.ts create mode 100644 server/routers/siteResource/removeAiProviderFromSiteResource.ts create mode 100644 server/routers/siteResource/setSiteResourceAiProviders.ts diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 6a24064a6..b323ae411 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -211,14 +211,7 @@ export const resources = pgTable( authDaemonPort: integer("authDaemonPort").default(22123), status: varchar("status") .$type<"pending" | "approved">() - .default("approved"), - aiProviderId: integer("aiProviderId").references( - () => aiProviders.providerId, - { onDelete: "set null" } - ), - modelAccessMode: varchar("modelAccessMode").$type< - "passthrough" | "catalog" | "allowlist" - >() + .default("approved") }, (t) => [ index("idx_resources_fulldomain") @@ -229,6 +222,23 @@ export const resources = pgTable( ] ); +export const resourceAiProviders = pgTable( + "resourceAiProviders", + { + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + providerId: integer("providerId") + .notNull() + .references(() => aiProviders.providerId, { onDelete: "cascade" }), + modelAccessMode: varchar("modelAccessMode") + .$type<"passthrough" | "catalog" | "allowlist">() + .notNull() + .default("passthrough") + }, + (t) => [primaryKey({ columns: [t.resourceId, t.providerId] })] +); + export const resourceAiModels = pgTable( "resourceAiModels", { @@ -496,18 +506,30 @@ export const siteResources = pgTable( fullDomain: varchar("fullDomain"), status: varchar("status") .$type<"pending" | "approved">() - .default("approved"), - aiProviderId: integer("aiProviderId").references( - () => aiProviders.providerId, - { onDelete: "set null" } - ), - modelAccessMode: varchar("modelAccessMode").$type< - "passthrough" | "catalog" | "allowlist" - >() + .default("approved") }, (t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)] ); +export const siteResourceAiProviders = pgTable( + "siteResourceAiProviders", + { + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { + onDelete: "cascade" + }), + providerId: integer("providerId") + .notNull() + .references(() => aiProviders.providerId, { onDelete: "cascade" }), + modelAccessMode: varchar("modelAccessMode") + .$type<"passthrough" | "catalog" | "allowlist">() + .notNull() + .default("passthrough") + }, + (t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })] +); + export const siteResourceAiModels = pgTable( "siteResourceAiModels", { @@ -1806,5 +1828,9 @@ export type AiProvider = InferSelectModel; export type AiModel = InferSelectModel; export type AiBudget = InferSelectModel; export type AiBudgetPeriod = InferSelectModel; +export type ResourceAiProvider = InferSelectModel; +export type SiteResourceAiProvider = InferSelectModel< + typeof siteResourceAiProviders +>; export type ResourceAiModel = InferSelectModel; export type SiteResourceAiModel = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index f453d36f7..c8f25dfed 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -216,16 +216,26 @@ export const resources = sqliteTable("resources", { .$type<"site" | "remote" | "native">() .default("site"), authDaemonPort: integer("authDaemonPort").default(22123), - status: text("status").$type<"pending" | "approved">().default("approved"), - aiProviderId: integer("aiProviderId").references( - () => aiProviders.providerId, - { onDelete: "set null" } - ), - modelAccessMode: text("modelAccessMode").$type< - "passthrough" | "catalog" | "allowlist" - >() + status: text("status").$type<"pending" | "approved">().default("approved") }); +export const resourceAiProviders = sqliteTable( + "resourceAiProviders", + { + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + providerId: integer("providerId") + .notNull() + .references(() => aiProviders.providerId, { onDelete: "cascade" }), + modelAccessMode: text("modelAccessMode") + .$type<"passthrough" | "catalog" | "allowlist">() + .notNull() + .default("passthrough") + }, + (t) => [primaryKey({ columns: [t.resourceId, t.providerId] })] +); + export const resourceAiModels = sqliteTable( "resourceAiModels", { @@ -483,16 +493,28 @@ export const siteResources = sqliteTable("siteResources", { }), subdomain: text("subdomain"), fullDomain: text("fullDomain"), - status: text("status").$type<"pending" | "approved">().default("approved"), - aiProviderId: integer("aiProviderId").references( - () => aiProviders.providerId, - { onDelete: "set null" } - ), - modelAccessMode: text("modelAccessMode").$type< - "passthrough" | "catalog" | "allowlist" - >() + status: text("status").$type<"pending" | "approved">().default("approved") }); +export const siteResourceAiProviders = sqliteTable( + "siteResourceAiProviders", + { + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { + onDelete: "cascade" + }), + providerId: integer("providerId") + .notNull() + .references(() => aiProviders.providerId, { onDelete: "cascade" }), + modelAccessMode: text("modelAccessMode") + .$type<"passthrough" | "catalog" | "allowlist">() + .notNull() + .default("passthrough") + }, + (t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })] +); + export const siteResourceAiModels = sqliteTable( "siteResourceAiModels", { @@ -1787,5 +1809,9 @@ export type AiProvider = InferSelectModel; export type AiModel = InferSelectModel; export type AiBudget = InferSelectModel; export type AiBudgetPeriod = InferSelectModel; +export type ResourceAiProvider = InferSelectModel; +export type SiteResourceAiProvider = InferSelectModel< + typeof siteResourceAiProviders +>; export type ResourceAiModel = InferSelectModel; export type SiteResourceAiModel = InferSelectModel; diff --git a/server/lib/aiInferenceResource.ts b/server/lib/aiInferenceResource.ts new file mode 100644 index 000000000..4175a11e8 --- /dev/null +++ b/server/lib/aiInferenceResource.ts @@ -0,0 +1,512 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { + aiModels, + aiProviders, + db, + resourceAiModels, + resourceAiProviders, + siteResourceAiModels, + siteResourceAiProviders, + type Transaction +} from "@server/db"; +import { z } from "zod"; + +type DbOrTrx = Transaction | typeof db; + +export const modelAccessModeSchema = z.enum([ + "passthrough", + "catalog", + "allowlist" +]); + +export type ModelAccessMode = z.infer; + +export const resourceAiProviderAttachmentSchema = z.strictObject({ + providerId: z.number().int().positive(), + modelAccessMode: modelAccessModeSchema.optional() +}); + +export type ResourceAiProviderInput = z.infer< + typeof resourceAiProviderAttachmentSchema +>; + +export type ResourceAiProviderAttachment = { + providerId: number; + modelAccessMode: ModelAccessMode; +}; + +export type InferenceFieldsError = { + error: string; +}; + +export function isInferenceFieldsError( + value: { error: string } | object +): value is InferenceFieldsError { + return "error" in value; +} + +function normalizeAttachments( + inputs: ResourceAiProviderInput[] +): ResourceAiProviderAttachment[] { + const byProvider = new Map(); + for (const input of inputs) { + byProvider.set( + input.providerId, + input.modelAccessMode ?? "passthrough" + ); + } + return [...byProvider.entries()].map(([providerId, modelAccessMode]) => ({ + providerId, + modelAccessMode + })); +} + +/** + * Validate provider attachments for an org. + * At most one passthrough provider is allowed per resource. + */ +export async function resolveProviderAttachments(input: { + orgId: string; + attachments: ResourceAiProviderInput[]; + requireAtLeastOne: boolean; +}): Promise { + const attachments = normalizeAttachments(input.attachments); + + if (input.requireAtLeastOne && attachments.length === 0) { + return { + error: "At least one AI provider is required for inference-mode resources" + }; + } + + 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 []; + } + + const providerIds = attachments.map((a) => a.providerId); + const providers = await db + .select({ + providerId: aiProviders.providerId, + orgId: aiProviders.orgId, + enabled: aiProviders.enabled + }) + .from(aiProviders) + .where( + and( + inArray(aiProviders.providerId, providerIds), + eq(aiProviders.orgId, input.orgId) + ) + ); + + if (providers.length !== providerIds.length) { + return { + error: "One or more AI providers were not found in this organization" + }; + } + + const disabled = providers.find((p) => !p.enabled); + if (disabled) { + return { + error: `AI provider with ID ${disabled.providerId} is disabled` + }; + } + + return attachments; +} + +export async function assertInferenceModeAllowsProviderFields(input: { + mode: string; + hasProviderAttachments: boolean; +}): Promise { + if (input.mode === "inference") { + return null; + } + if (input.hasProviderAttachments) { + return { + error: "AI providers can only be attached to inference-mode resources" + }; + } + return null; +} + +export async function setPublicResourceAiProviders( + resourceId: number, + attachments: ResourceAiProviderAttachment[], + trx: DbOrTrx = db +): Promise { + await trx + .delete(resourceAiProviders) + .where(eq(resourceAiProviders.resourceId, resourceId)); + + if (attachments.length > 0) { + await trx.insert(resourceAiProviders).values( + attachments.map((a) => ({ + resourceId, + providerId: a.providerId, + modelAccessMode: a.modelAccessMode + })) + ); + } + + await prunePublicResourceAllowlistToAllowlistProviders(resourceId, trx); +} + +export async function setSiteResourceAiProviders( + siteResourceId: number, + attachments: ResourceAiProviderAttachment[], + trx: DbOrTrx = db +): Promise { + await trx + .delete(siteResourceAiProviders) + .where(eq(siteResourceAiProviders.siteResourceId, siteResourceId)); + + if (attachments.length > 0) { + await trx.insert(siteResourceAiProviders).values( + attachments.map((a) => ({ + siteResourceId, + providerId: a.providerId, + modelAccessMode: a.modelAccessMode + })) + ); + } + + await pruneSiteResourceAllowlistToAllowlistProviders(siteResourceId, trx); +} + +export async function clearPublicResourceAiConfig( + resourceId: number, + trx: DbOrTrx = db +): Promise { + await trx + .delete(resourceAiModels) + .where(eq(resourceAiModels.resourceId, resourceId)); + await trx + .delete(resourceAiProviders) + .where(eq(resourceAiProviders.resourceId, resourceId)); +} + +export async function clearSiteResourceAiConfig( + siteResourceId: number, + trx: DbOrTrx = db +): Promise { + await trx + .delete(siteResourceAiModels) + .where(eq(siteResourceAiModels.siteResourceId, siteResourceId)); + await trx + .delete(siteResourceAiProviders) + .where(eq(siteResourceAiProviders.siteResourceId, siteResourceId)); +} + +async function prunePublicResourceAllowlistToAllowlistProviders( + resourceId: number, + trx: DbOrTrx = db +): Promise { + const allowlistProviders = await trx + .select({ providerId: resourceAiProviders.providerId }) + .from(resourceAiProviders) + .where( + and( + eq(resourceAiProviders.resourceId, resourceId), + eq(resourceAiProviders.modelAccessMode, "allowlist") + ) + ); + + if (allowlistProviders.length === 0) { + await trx + .delete(resourceAiModels) + .where(eq(resourceAiModels.resourceId, resourceId)); + return; + } + + const validModels = await trx + .select({ modelId: aiModels.modelId }) + .from(aiModels) + .where( + inArray( + aiModels.providerId, + allowlistProviders.map((p) => p.providerId) + ) + ); + const validIds = validModels.map((m) => m.modelId); + + const existing = await trx + .select({ modelId: resourceAiModels.modelId }) + .from(resourceAiModels) + .where(eq(resourceAiModels.resourceId, resourceId)); + + const toRemove = existing + .map((e) => e.modelId) + .filter((id) => !validIds.includes(id)); + + if (toRemove.length > 0) { + await trx + .delete(resourceAiModels) + .where( + and( + eq(resourceAiModels.resourceId, resourceId), + inArray(resourceAiModels.modelId, toRemove) + ) + ); + } +} + +async function pruneSiteResourceAllowlistToAllowlistProviders( + siteResourceId: number, + trx: DbOrTrx = db +): Promise { + const allowlistProviders = await trx + .select({ providerId: siteResourceAiProviders.providerId }) + .from(siteResourceAiProviders) + .where( + and( + eq(siteResourceAiProviders.siteResourceId, siteResourceId), + eq(siteResourceAiProviders.modelAccessMode, "allowlist") + ) + ); + + if (allowlistProviders.length === 0) { + await trx + .delete(siteResourceAiModels) + .where(eq(siteResourceAiModels.siteResourceId, siteResourceId)); + return; + } + + const validModels = await trx + .select({ modelId: aiModels.modelId }) + .from(aiModels) + .where( + inArray( + aiModels.providerId, + allowlistProviders.map((p) => p.providerId) + ) + ); + const validIds = validModels.map((m) => m.modelId); + + const existing = await trx + .select({ modelId: siteResourceAiModels.modelId }) + .from(siteResourceAiModels) + .where(eq(siteResourceAiModels.siteResourceId, siteResourceId)); + + const toRemove = existing + .map((e) => e.modelId) + .filter((id) => !validIds.includes(id)); + + if (toRemove.length > 0) { + await trx + .delete(siteResourceAiModels) + .where( + and( + eq(siteResourceAiModels.siteResourceId, siteResourceId), + inArray(siteResourceAiModels.modelId, toRemove) + ) + ); + } +} + +export async function listPublicResourceAiProviders(resourceId: number) { + return db + .select({ + providerId: resourceAiProviders.providerId, + modelAccessMode: resourceAiProviders.modelAccessMode, + name: aiProviders.name, + type: aiProviders.type, + enabled: aiProviders.enabled + }) + .from(resourceAiProviders) + .innerJoin( + aiProviders, + eq(resourceAiProviders.providerId, aiProviders.providerId) + ) + .where(eq(resourceAiProviders.resourceId, resourceId)); +} + +export async function listSiteResourceAiProviders(siteResourceId: number) { + return db + .select({ + providerId: siteResourceAiProviders.providerId, + modelAccessMode: siteResourceAiProviders.modelAccessMode, + name: aiProviders.name, + type: aiProviders.type, + enabled: aiProviders.enabled + }) + .from(siteResourceAiProviders) + .innerJoin( + aiProviders, + eq(siteResourceAiProviders.providerId, aiProviders.providerId) + ) + .where(eq(siteResourceAiProviders.siteResourceId, siteResourceId)); +} + +/** + * Allowlist APIs require an inference resource with at least one + * attached provider in allowlist mode. + */ +export async function assertPublicAllowlistApiEligible(resource: { + resourceId: number; + mode: string; +}): Promise { + if (resource.mode !== "inference") { + return "AI model allowlists are only supported on inference-mode resources"; + } + + const [row] = await db + .select({ providerId: resourceAiProviders.providerId }) + .from(resourceAiProviders) + .where( + and( + eq(resourceAiProviders.resourceId, resource.resourceId), + eq(resourceAiProviders.modelAccessMode, "allowlist") + ) + ) + .limit(1); + + if (!row) { + return "Attach at least one AI provider with modelAccessMode=allowlist before managing allowed models"; + } + return null; +} + +export async function assertSiteAllowlistApiEligible(siteResource: { + siteResourceId: number; + mode: string; +}): Promise { + if (siteResource.mode !== "inference") { + return "AI model allowlists are only supported on inference-mode resources"; + } + + const [row] = await db + .select({ providerId: siteResourceAiProviders.providerId }) + .from(siteResourceAiProviders) + .where( + and( + eq( + siteResourceAiProviders.siteResourceId, + siteResource.siteResourceId + ), + eq(siteResourceAiProviders.modelAccessMode, "allowlist") + ) + ) + .limit(1); + + if (!row) { + return "Attach at least one AI provider with modelAccessMode=allowlist before managing allowed models"; + } + return null; +} + +/** + * Models must belong to providers attached to this resource in allowlist mode, + * and those providers must belong to the resource's org. + */ +export async function assertModelsBelongToPublicAllowlistProviders(input: { + orgId: string; + resourceId: number; + modelIds: number[]; +}): Promise { + const uniqueIds = [...new Set(input.modelIds)]; + if (uniqueIds.length === 0) { + return null; + } + + const allowlistProviders = await db + .select({ providerId: resourceAiProviders.providerId }) + .from(resourceAiProviders) + .innerJoin( + aiProviders, + eq(resourceAiProviders.providerId, aiProviders.providerId) + ) + .where( + and( + eq(resourceAiProviders.resourceId, input.resourceId), + eq(resourceAiProviders.modelAccessMode, "allowlist"), + eq(aiProviders.orgId, input.orgId) + ) + ); + + if (allowlistProviders.length === 0) { + return "No allowlist AI providers are attached to this resource"; + } + + const validModels = await db + .select({ modelId: aiModels.modelId }) + .from(aiModels) + .innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId)) + .where( + and( + inArray(aiModels.modelId, uniqueIds), + inArray( + aiModels.providerId, + allowlistProviders.map((p) => p.providerId) + ), + eq(aiProviders.orgId, input.orgId) + ) + ); + + if (validModels.length !== uniqueIds.length) { + return "One or more model IDs do not exist or do not belong to an allowlist provider on this resource"; + } + + return null; +} + +export async function assertModelsBelongToSiteAllowlistProviders(input: { + orgId: string; + siteResourceId: number; + modelIds: number[]; +}): Promise { + const uniqueIds = [...new Set(input.modelIds)]; + if (uniqueIds.length === 0) { + return null; + } + + const allowlistProviders = await db + .select({ providerId: siteResourceAiProviders.providerId }) + .from(siteResourceAiProviders) + .innerJoin( + aiProviders, + eq(siteResourceAiProviders.providerId, aiProviders.providerId) + ) + .where( + and( + eq( + siteResourceAiProviders.siteResourceId, + input.siteResourceId + ), + eq(siteResourceAiProviders.modelAccessMode, "allowlist"), + eq(aiProviders.orgId, input.orgId) + ) + ); + + if (allowlistProviders.length === 0) { + return "No allowlist AI providers are attached to this site resource"; + } + + const validModels = await db + .select({ modelId: aiModels.modelId }) + .from(aiModels) + .innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId)) + .where( + and( + inArray(aiModels.modelId, uniqueIds), + inArray( + aiModels.providerId, + allowlistProviders.map((p) => p.providerId) + ), + eq(aiProviders.orgId, input.orgId) + ) + ); + + if (validModels.length !== uniqueIds.length) { + return "One or more model IDs do not exist or do not belong to an allowlist provider on this site resource"; + } + + return null; +} diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 4f5dd3860..574b8a374 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -1,4 +1,4 @@ -import { db, targetHealthCheck, domains, aiProviders } from "@server/db"; +import { db, targetHealthCheck, domains, aiProviders, resourceAiProviders } from "@server/db"; import { and, eq, @@ -214,7 +214,7 @@ export async function getTraefikConfig( // central AI gateway), so they can't be reached via the targets->sites // join above - query them separately and include them on every exit node. const inferenceResources = await db - .select({ + .selectDistinct({ resourceId: resources.resourceId, resourceName: resources.name, fullDomain: resources.fullDomain, @@ -226,9 +226,13 @@ export async function getTraefikConfig( preferWildcardCert: domains.preferWildcardCert }) .from(resources) + .innerJoin( + resourceAiProviders, + eq(resources.resourceId, resourceAiProviders.resourceId) + ) .innerJoin( aiProviders, - eq(resources.aiProviderId, aiProviders.providerId) + eq(resourceAiProviders.providerId, aiProviders.providerId) ) .leftJoin(domains, eq(domains.domainId, resources.domainId)) .where( diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 8e1b1eca2..abe2fc691 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -42,7 +42,9 @@ import { siteResources, Target, targets, - aiProviders + aiProviders, + resourceAiProviders, + siteResourceAiProviders } from "@server/db"; import { sanitize, @@ -402,7 +404,7 @@ export async function getTraefikConfig( // so they can't be reached via the joins above - query them separately // and include them on every exit node. const inferenceResources = await db - .select({ + .selectDistinct({ resourceId: resources.resourceId, fullDomain: resources.fullDomain, ssl: resources.ssl, @@ -414,9 +416,13 @@ export async function getTraefikConfig( preferWildcardCert: domains.preferWildcardCert }) .from(resources) + .innerJoin( + resourceAiProviders, + eq(resources.resourceId, resourceAiProviders.resourceId) + ) .innerJoin( aiProviders, - eq(resources.aiProviderId, aiProviders.providerId) + eq(resourceAiProviders.providerId, aiProviders.providerId) ) .leftJoin(domains, eq(domains.domainId, resources.domainId)) .where( @@ -435,16 +441,23 @@ export async function getTraefikConfig( }[] = []; if (build == "enterprise") { siteResourcesInference = await db - .select({ + .selectDistinct({ siteResourceId: siteResources.siteResourceId, alias: siteResources.alias, ssl: siteResources.ssl, enabled: siteResources.enabled }) .from(siteResources) + .innerJoin( + siteResourceAiProviders, + eq( + siteResources.siteResourceId, + siteResourceAiProviders.siteResourceId + ) + ) .innerJoin( aiProviders, - eq(siteResources.aiProviderId, aiProviders.providerId) + eq(siteResourceAiProviders.providerId, aiProviders.providerId) ) .where( and( diff --git a/server/routers/aiGateway/chatCompletions.ts b/server/routers/aiGateway/chatCompletions.ts index c9c32c2ba..51508778a 100644 --- a/server/routers/aiGateway/chatCompletions.ts +++ b/server/routers/aiGateway/chatCompletions.ts @@ -8,8 +8,10 @@ import { db, exitNodes, resourceAiModels, + resourceAiProviders, resources, siteResourceAiModels, + siteResourceAiProviders, siteResources, users } from "@server/db"; @@ -21,7 +23,6 @@ import { AiProviderType, resolveAiProviderConfig } from "@server/lib/aiProviderDefaults"; -import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { SESSION_COOKIE_NAME, validateSessionToken @@ -31,6 +32,7 @@ import { isIpInCidr } from "@server/lib/ip"; import { localCache } from "@server/lib/cache"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; +import type { ModelAccessMode } from "@server/lib/aiInferenceResource"; // Short-lived local caches so a burst of requests from the same IP/user // doesn't hit the database on every single request. None of this is @@ -85,14 +87,23 @@ async function findClientByIp(ip: string): Promise { return result; } +type ProviderAttachment = { + provider: AiProvider; + modelAccessMode: ModelAccessMode; +}; + type ResolvedTarget = { resourceId: number | null; orgId: string | null; - provider: AiProvider; - // null = no restriction; every enabled model on the provider is allowed - allowedModelIds: number[] | null; + attachments: ProviderAttachment[]; + // model IDs on this resource's allowlist (resource-wide) + allowedModelIds: number[]; }; +type ProviderSelection = + | { ok: true; provider: AiProvider } + | { ok: false; status: number; message: string }; + export type RequestUser = { userId: string; username: string; @@ -184,88 +195,245 @@ async function resolveTarget(host: string): Promise { const [resourceRow] = await db .select({ resourceId: resources.resourceId, - orgId: resources.orgId, - provider: aiProviders + orgId: resources.orgId }) .from(resources) - .innerJoin( - aiProviders, - eq(resources.aiProviderId, aiProviders.providerId) - ) .where( and( eq(resources.fullDomain, host), eq(resources.mode, "inference"), - eq(resources.enabled, true), - eq(aiProviders.enabled, true) + eq(resources.enabled, true) ) ) .limit(1); if (resourceRow) { - const restrictions = await db - .select({ modelId: resourceAiModels.modelId }) - .from(resourceAiModels) - .where(eq(resourceAiModels.resourceId, resourceRow.resourceId)); + const attachmentRows = await db + .select({ + modelAccessMode: resourceAiProviders.modelAccessMode, + provider: aiProviders + }) + .from(resourceAiProviders) + .innerJoin( + aiProviders, + eq(resourceAiProviders.providerId, aiProviders.providerId) + ) + .where( + and( + eq(resourceAiProviders.resourceId, resourceRow.resourceId), + eq(aiProviders.enabled, true) + ) + ); + + if (attachmentRows.length === 0) { + return null; + } + + const hasAllowlist = attachmentRows.some( + (a) => a.modelAccessMode === "allowlist" + ); + let allowedModelIds: number[] = []; + if (hasAllowlist) { + const restrictions = await db + .select({ modelId: resourceAiModels.modelId }) + .from(resourceAiModels) + .where(eq(resourceAiModels.resourceId, resourceRow.resourceId)); + allowedModelIds = restrictions.map((r) => r.modelId); + } return { resourceId: resourceRow.resourceId, orgId: resourceRow.orgId, - provider: resourceRow.provider, - allowedModelIds: restrictions.length - ? restrictions.map((r) => r.modelId) - : null + attachments: attachmentRows.map((a) => ({ + provider: a.provider, + modelAccessMode: a.modelAccessMode as ModelAccessMode + })), + allowedModelIds }; } const [siteResourceRow] = await db .select({ siteResourceId: siteResources.siteResourceId, - orgId: siteResources.orgId, - provider: aiProviders + orgId: siteResources.orgId }) .from(siteResources) - .innerJoin( - aiProviders, - eq(siteResources.aiProviderId, aiProviders.providerId) - ) .where( and( eq(siteResources.alias, host), eq(siteResources.mode, "inference"), - eq(siteResources.enabled, true), - eq(aiProviders.enabled, true) + eq(siteResources.enabled, true) ) ) .limit(1); if (siteResourceRow) { - const restrictions = await db - .select({ modelId: siteResourceAiModels.modelId }) - .from(siteResourceAiModels) + const attachmentRows = await db + .select({ + modelAccessMode: siteResourceAiProviders.modelAccessMode, + provider: aiProviders + }) + .from(siteResourceAiProviders) + .innerJoin( + aiProviders, + eq(siteResourceAiProviders.providerId, aiProviders.providerId) + ) .where( - eq( - siteResourceAiModels.siteResourceId, - siteResourceRow.siteResourceId + and( + eq( + siteResourceAiProviders.siteResourceId, + siteResourceRow.siteResourceId + ), + eq(aiProviders.enabled, true) ) ); + if (attachmentRows.length === 0) { + return null; + } + + const hasAllowlist = attachmentRows.some( + (a) => a.modelAccessMode === "allowlist" + ); + let allowedModelIds: number[] = []; + if (hasAllowlist) { + const restrictions = await db + .select({ modelId: siteResourceAiModels.modelId }) + .from(siteResourceAiModels) + .where( + eq( + siteResourceAiModels.siteResourceId, + siteResourceRow.siteResourceId + ) + ); + allowedModelIds = restrictions.map((r) => r.modelId); + } + return { // siteResources have no per-user auth/policy stack today (see // the routing comment in getTraefikConfig.ts), so there's no // resource access token scope to validate a user token against. resourceId: null, orgId: siteResourceRow.orgId, - provider: siteResourceRow.provider, - allowedModelIds: restrictions.length - ? restrictions.map((r) => r.modelId) - : null + attachments: attachmentRows.map((a) => ({ + provider: a.provider, + modelAccessMode: a.modelAccessMode as ModelAccessMode + })), + allowedModelIds }; } return null; } +async function providerMatchesModel( + attachment: ProviderAttachment, + requestedModel: string, + allowedModelIds: number[] +): Promise { + if (attachment.modelAccessMode === "passthrough") { + return true; + } + + const [matchedModel] = await db + .select({ + modelId: aiModels.modelId, + enabled: aiModels.enabled + }) + .from(aiModels) + .where( + and( + eq(aiModels.providerId, attachment.provider.providerId), + eq(aiModels.modelKey, requestedModel) + ) + ) + .limit(1); + + if (!matchedModel) { + return false; + } + + if (attachment.modelAccessMode === "catalog") { + return matchedModel.enabled; + } + + // allowlist + return allowedModelIds.includes(matchedModel.modelId); +} + +async function selectProvider( + attachments: ProviderAttachment[], + allowedModelIds: number[], + requestedModel: string | undefined +): Promise { + 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, + message: "A model must be specified for this resource" + }; + } + + const candidates: ProviderAttachment[] = []; + for (const attachment of attachments) { + if (attachment.modelAccessMode === "passthrough") { + candidates.push(attachment); + continue; + } + if ( + await providerMatchesModel( + attachment, + requestedModel, + allowedModelIds + ) + ) { + candidates.push(attachment); + } + } + + if (candidates.length === 1) { + return { ok: true, provider: candidates[0].provider }; + } + + if (candidates.length > 1) { + return { + ok: false, + status: HttpCode.FORBIDDEN, + message: `Model "${requestedModel}" is ambiguous across multiple AI providers on this resource` + }; + } + + // 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, + message: `Model "${requestedModel}" is not permitted on this resource` + }; +} + // Generic OpenAI-wire-compatible passthrough. Anthropic's native API uses a // different path/schema; everything else here is OpenAI-compatible today. function getCompletionsPath(type: AiProviderType): string { @@ -296,7 +464,7 @@ export async function chatCompletions( }); } - const { provider, allowedModelIds, resourceId, orgId } = target; + const { attachments, allowedModelIds, resourceId, orgId } = target; // Best-effort identity resolution - not yet enforced, but lets us // start making per-user access decisions (e.g. model/role-based @@ -311,39 +479,19 @@ export async function chatCompletions( const requestedModel = typeof req.body?.model === "string" ? req.body.model : undefined; - if (allowedModelIds) { - if (!requestedModel) { - return res.status(HttpCode.FORBIDDEN).json({ - error: { - message: - "This resource restricts access to specific models; a model must be specified" - } - }); - } - - const [matchedModel] = await db - .select({ modelId: aiModels.modelId }) - .from(aiModels) - .where( - and( - eq(aiModels.providerId, provider.providerId), - eq(aiModels.modelKey, requestedModel) - ) - ) - .limit(1); - - if ( - !matchedModel || - !allowedModelIds.includes(matchedModel.modelId) - ) { - return res.status(HttpCode.FORBIDDEN).json({ - error: { - message: `Model "${requestedModel}" is not permitted on this resource` - } - }); - } + const selection = await selectProvider( + attachments, + allowedModelIds, + requestedModel + ); + if (!selection.ok) { + return res.status(selection.status).json({ + error: { message: selection.message } + }); } + const { provider } = selection; + if (!provider.apiKey) { return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ error: { message: "AI provider has no API key configured" } diff --git a/server/routers/aiProvider/createAiModel.ts b/server/routers/aiProvider/createAiModel.ts index 7fda989d2..dfe2a62e1 100644 --- a/server/routers/aiProvider/createAiModel.ts +++ b/server/routers/aiProvider/createAiModel.ts @@ -9,26 +9,16 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types"; -import { - aiBudgetUnitSchema, - refineBudgetFields -} from "@server/routers/aiProvider/validation"; const paramsSchema = z.strictObject({ providerId: z.coerce.number().int().positive() }); -const bodySchema = z - .strictObject({ - modelKey: z.string().nonempty(), - name: z.string().nonempty(), - budgetAmount: z.number().positive().optional().nullable(), - budgetUnit: aiBudgetUnitSchema.optional().nullable(), - enabled: z.boolean().optional() - }) - .superRefine((data, ctx) => { - refineBudgetFields(data, ctx); - }); +const bodySchema = z.strictObject({ + modelKey: z.string().nonempty(), + name: z.string().nonempty(), + enabled: z.boolean().optional() +}); registry.registerPath({ method: "put", @@ -79,8 +69,7 @@ export async function createAiModel( } const { providerId } = parsedParams.data; - const { modelKey, name, budgetAmount, budgetUnit, enabled } = - parsedBody.data; + const { modelKey, name, enabled } = parsedBody.data; const [provider] = req.aiProvider && req.aiProvider.providerId === providerId @@ -127,8 +116,6 @@ export async function createAiModel( providerId, modelKey, name, - budgetAmount: budgetAmount ?? null, - budgetUnit: budgetUnit ?? null, enabled: enabled ?? true, createdAt: now, updatedAt: now diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts index 1d58344cb..6fb3f395a 100644 --- a/server/routers/aiProvider/createAiProvider.ts +++ b/server/routers/aiProvider/createAiProvider.ts @@ -13,10 +13,8 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/ import { toPublicAiProvider } from "@server/routers/aiProvider/types"; import { aiAuthTypeSchema, - aiBudgetUnitSchema, aiProviderTypeSchema, aiRoutingModeSchema, - refineBudgetFields, refineProviderUpstreamFields } from "@server/routers/aiProvider/validation"; @@ -33,13 +31,10 @@ const bodySchema = z authType: aiAuthTypeSchema.optional().nullable(), routingMode: aiRoutingModeSchema.optional(), skipTlsVerification: z.boolean().optional(), - budgetAmount: z.number().positive().optional().nullable(), - budgetUnit: aiBudgetUnitSchema.optional().nullable(), enabled: z.boolean().optional() }) .superRefine((data, ctx) => { refineProviderUpstreamFields(data, ctx); - refineBudgetFields(data, ctx); }); registry.registerPath({ @@ -99,8 +94,6 @@ export async function createAiProvider( authType, routingMode, skipTlsVerification, - budgetAmount, - budgetUnit, enabled } = parsedBody.data; @@ -126,8 +119,6 @@ export async function createAiProvider( authType: authType ?? null, routingMode: resolvedRoutingMode, skipTlsVerification: skipTlsVerification ?? false, - budgetAmount: budgetAmount ?? null, - budgetUnit: budgetUnit ?? null, enabled: enabled ?? true, createdAt: now, updatedAt: now diff --git a/server/routers/aiProvider/updateAiModel.ts b/server/routers/aiProvider/updateAiModel.ts index fa94e8da4..fe9ccf273 100644 --- a/server/routers/aiProvider/updateAiModel.ts +++ b/server/routers/aiProvider/updateAiModel.ts @@ -9,26 +9,16 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq, ne } from "drizzle-orm"; import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types"; -import { - aiBudgetUnitSchema, - refineBudgetFields -} from "@server/routers/aiProvider/validation"; const paramsSchema = z.strictObject({ modelId: z.coerce.number().int().positive() }); -const bodySchema = z - .strictObject({ - modelKey: z.string().nonempty().optional(), - name: z.string().nonempty().optional(), - budgetAmount: z.number().positive().optional().nullable(), - budgetUnit: aiBudgetUnitSchema.optional().nullable(), - enabled: z.boolean().optional() - }) - .superRefine((data, ctx) => { - refineBudgetFields(data, ctx); - }); +const bodySchema = z.strictObject({ + modelKey: z.string().nonempty().optional(), + name: z.string().nonempty().optional(), + enabled: z.boolean().optional() +}); registry.registerPath({ method: "post", @@ -135,12 +125,6 @@ export async function updateAiModel( if (body.name !== undefined) { updateData.name = body.name; } - if (body.budgetAmount !== undefined) { - updateData.budgetAmount = body.budgetAmount; - } - if (body.budgetUnit !== undefined) { - updateData.budgetUnit = body.budgetUnit; - } if (body.enabled !== undefined) { updateData.enabled = body.enabled; } diff --git a/server/routers/aiProvider/updateAiProvider.ts b/server/routers/aiProvider/updateAiProvider.ts index 39787fb93..35e27fba2 100644 --- a/server/routers/aiProvider/updateAiProvider.ts +++ b/server/routers/aiProvider/updateAiProvider.ts @@ -14,10 +14,8 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/ import { toPublicAiProvider } from "@server/routers/aiProvider/types"; import { aiAuthTypeSchema, - aiBudgetUnitSchema, aiProviderTypeSchema, aiRoutingModeSchema, - refineBudgetFields, refineProviderUpstreamFields } from "@server/routers/aiProvider/validation"; import type { @@ -29,21 +27,15 @@ const paramsSchema = z.strictObject({ providerId: z.coerce.number().int().positive() }); -const bodySchema = z - .strictObject({ - name: z.string().nonempty().optional(), - upstreamUrl: z.url().optional().nullable(), - apiKey: z.string().optional(), - authType: aiAuthTypeSchema.optional().nullable(), - routingMode: aiRoutingModeSchema.optional(), - skipTlsVerification: z.boolean().optional(), - budgetAmount: z.number().positive().optional().nullable(), - budgetUnit: aiBudgetUnitSchema.optional().nullable(), - enabled: z.boolean().optional() - }) - .superRefine((data, ctx) => { - refineBudgetFields(data, ctx); - }); +const bodySchema = z.strictObject({ + name: z.string().nonempty().optional(), + upstreamUrl: z.url().optional().nullable(), + apiKey: z.string().optional(), + authType: aiAuthTypeSchema.optional().nullable(), + routingMode: aiRoutingModeSchema.optional(), + skipTlsVerification: z.boolean().optional(), + enabled: z.boolean().optional() +}); registry.registerPath({ method: "post", @@ -168,12 +160,6 @@ export async function updateAiProvider( if (body.enabled !== undefined) { updateData.enabled = body.enabled; } - if (body.budgetAmount !== undefined) { - updateData.budgetAmount = body.budgetAmount; - } - if (body.budgetUnit !== undefined) { - updateData.budgetUnit = body.budgetUnit; - } if (nextRoutingMode === "target") { updateData.upstreamUrl = null; } else if (body.upstreamUrl !== undefined) { diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts index c830c08de..a5e2aa962 100644 --- a/server/routers/aiProvider/validation.ts +++ b/server/routers/aiProvider/validation.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { providerRequiresUpstreamUrl, - type AiBudgetUnit, type AiProviderRoutingMode, type AiProviderType } from "@server/lib/aiProviderDefaults"; @@ -18,33 +17,10 @@ export const aiProviderTypeSchema = z.enum([ "custom" ]); -export const aiBudgetUnitSchema = z.enum(["usd", "tokens"]); - export const aiAuthTypeSchema = z.enum(["bearer"]); export const aiRoutingModeSchema = z.enum(["url", "target"]); -export function refineBudgetFields( - data: { - budgetAmount?: number | null; - budgetUnit?: AiBudgetUnit | null; - }, - ctx: z.RefinementCtx -) { - const hasAmount = - data.budgetAmount !== undefined && data.budgetAmount !== null; - const hasUnit = data.budgetUnit !== undefined && data.budgetUnit !== null; - - if (hasAmount !== hasUnit) { - ctx.addIssue({ - code: "custom", - message: - "budgetAmount and budgetUnit must both be set or both omitted", - path: hasAmount ? ["budgetUnit"] : ["budgetAmount"] - }); - } -} - export function refineProviderUpstreamFields( data: { type: AiProviderType; diff --git a/server/routers/external.ts b/server/routers/external.ts index 4ebf58cae..94165cc8d 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -414,6 +414,13 @@ authenticated.get( siteResource.listSiteResourceAiModels ); +authenticated.get( + "/site-resource/:siteResourceId/ai-providers", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceAiModels), + siteResource.listSiteResourceAiProviders +); + authenticated.post( "/site-resource/:siteResourceId/roles", verifySiteResourceAccess, @@ -432,6 +439,46 @@ authenticated.post( siteResource.setSiteResourceAiModels ); +authenticated.post( + "/site-resource/:siteResourceId/ai-models/add", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + siteResource.addAiModelToSiteResource +); + +authenticated.post( + "/site-resource/:siteResourceId/ai-models/remove", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + siteResource.removeAiModelFromSiteResource +); + +authenticated.post( + "/site-resource/:siteResourceId/ai-providers", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + siteResource.setSiteResourceAiProviders +); + +authenticated.post( + "/site-resource/:siteResourceId/ai-providers/add", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + siteResource.addAiProviderToSiteResource +); + +authenticated.post( + "/site-resource/:siteResourceId/ai-providers/remove", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + siteResource.removeAiProviderFromSiteResource +); + authenticated.post( "/site-resource/:siteResourceId/users", verifySiteResourceAccess, @@ -673,6 +720,13 @@ authenticated.get( resource.listResourceAiModels ); +authenticated.get( + "/resource/:resourceId/ai-providers", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceAiModels), + resource.listResourceAiProviders +); + authenticated.get( "/resource/:resourceId", verifyResourceAccess, @@ -884,6 +938,46 @@ authenticated.post( resource.setResourceAiModels ); +authenticated.post( + "/resource/:resourceId/ai-models/add", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + resource.addAiModelToResource +); + +authenticated.post( + "/resource/:resourceId/ai-models/remove", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + resource.removeAiModelFromResource +); + +authenticated.post( + "/resource/:resourceId/ai-providers", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + resource.setResourceAiProviders +); + +authenticated.post( + "/resource/:resourceId/ai-providers/add", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + resource.addAiProviderToResource +); + +authenticated.post( + "/resource/:resourceId/ai-providers/remove", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + resource.removeAiProviderFromResource +); + authenticated.put( "/resource-policy/:resourcePolicyId/access-control", verifyResourcePolicyAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 41757e7a0..e08c14920 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -256,6 +256,16 @@ authenticated.get( siteResource.listSiteResourceAiModels ); +authenticated.get( + [ + "/site-resource/:siteResourceId/ai-providers", + "/private-resource/:siteResourceId/ai-providers" + ], + verifyApiKeySiteResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceAiModels), + siteResource.listSiteResourceAiProviders +); + authenticated.post( [ "/site-resource/:siteResourceId/roles", @@ -341,6 +351,39 @@ authenticated.post( siteResource.removeAiModelFromSiteResource ); +authenticated.post( + [ + "/site-resource/:siteResourceId/ai-providers", + "/private-resource/:siteResourceId/ai-providers" + ], + verifyApiKeySiteResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + siteResource.setSiteResourceAiProviders +); + +authenticated.post( + [ + "/site-resource/:siteResourceId/ai-providers/add", + "/private-resource/:siteResourceId/ai-providers/add" + ], + verifyApiKeySiteResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + siteResource.addAiProviderToSiteResource +); + +authenticated.post( + [ + "/site-resource/:siteResourceId/ai-providers/remove", + "/private-resource/:siteResourceId/ai-providers/remove" + ], + verifyApiKeySiteResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + siteResource.removeAiProviderFromSiteResource +); + authenticated.post( [ "/site-resource/:siteResourceId/users/add", @@ -563,6 +606,16 @@ authenticated.get( resource.listResourceAiModels ); +authenticated.get( + [ + "/resource/:resourceId/ai-providers", + "/public-resource/:resourceId/ai-providers" + ], + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceAiModels), + resource.listResourceAiProviders +); + authenticated.get( ["/resource/:resourceId", "/public-resource/:resourceId"], verifyApiKeyResourceAccess, @@ -775,6 +828,17 @@ authenticated.post( resource.setResourceAiModels ); +authenticated.post( + [ + "/resource/:resourceId/ai-providers", + "/public-resource/:resourceId/ai-providers" + ], + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + resource.setResourceAiProviders +); + authenticated.post( ["/resource/:resourceId/users", "/public-resource/:resourceId/users"], verifyApiKeyResourceAccess, @@ -989,6 +1053,28 @@ authenticated.post( resource.removeAiModelFromResource ); +authenticated.post( + [ + "/resource/:resourceId/ai-providers/add", + "/public-resource/:resourceId/ai-providers/add" + ], + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + resource.addAiProviderToResource +); + +authenticated.post( + [ + "/resource/:resourceId/ai-providers/remove", + "/public-resource/:resourceId/ai-providers/remove" + ], + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceAiModels), + logActionAudit(ActionsEnum.setResourceAiModels), + resource.removeAiProviderFromResource +); + authenticated.post( [ "/resource/:resourceId/users/add", diff --git a/server/routers/resource/addAiModelToResource.ts b/server/routers/resource/addAiModelToResource.ts index fd33a3e91..3121c9ffa 100644 --- a/server/routers/resource/addAiModelToResource.ts +++ b/server/routers/resource/addAiModelToResource.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, resources, resourceAiModels, aiModels } from "@server/db"; +import { db, resources, resourceAiModels } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -8,7 +8,10 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; - +import { + assertPublicAllowlistApiEligible, + assertModelsBelongToPublicAllowlistProviders +} from "@server/lib/aiInferenceResource"; const addAiModelToResourceBodySchema = z.strictObject({ modelId: z.int().positive() }); @@ -21,7 +24,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/ai-models/add", description: - "Add a single AI model to a resource's model restriction allow-list.", + "Add a single catalog model to an inference resource allowlist. Requires at least one attached AI provider in allowlist mode. The model must belong to a provider attached in allowlist mode.", tags: [OpenAPITags.PublicResource], request: { params: addAiModelToResourceParamsSchema, @@ -95,33 +98,18 @@ export async function addAiModelToResource( ); } - if (!resource.aiProviderId) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Resource has no AI provider linked" - ) - ); + const eligibleError = await assertPublicAllowlistApiEligible(resource); + if (eligibleError) { + return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError)); } - const [model] = await db - .select() - .from(aiModels) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, resource.aiProviderId) - ) - ) - .limit(1); - - if (!model) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - "Model not found or does not belong to this resource's AI provider" - ) - ); + const modelError = await assertModelsBelongToPublicAllowlistProviders({ + orgId: resource.orgId, + resourceId, + modelIds: [modelId] + }); + if (modelError) { + return next(createHttpError(HttpCode.BAD_REQUEST, modelError)); } const existingEntry = await db diff --git a/server/routers/resource/addAiProviderToResource.ts b/server/routers/resource/addAiProviderToResource.ts new file mode 100644 index 000000000..d51cf244c --- /dev/null +++ b/server/routers/resource/addAiProviderToResource.ts @@ -0,0 +1,152 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { + isInferenceFieldsError, + listPublicResourceAiProviders, + modelAccessModeSchema, + resolveProviderAttachments, + setPublicResourceAiProviders +} from "@server/lib/aiInferenceResource"; + +const addAiProviderToResourceBodySchema = z.strictObject({ + providerId: z.number().int().positive(), + modelAccessMode: modelAccessModeSchema.optional() +}); + +const addAiProviderToResourceParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/ai-providers/add", + description: + "Add or replace a single AI provider attachment on an inference resource.", + tags: [OpenAPITags.PublicResource], + request: { + params: addAiProviderToResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addAiProviderToResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function addAiProviderToResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = addAiProviderToResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { providerId, modelAccessMode } = parsedBody.data; + + const parsedParams = addAiProviderToResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + if (resource.mode !== "inference") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI providers can only be attached to inference-mode resources" + ) + ); + } + + const existing = await listPublicResourceAiProviders(resourceId); + const nextAttachments = [ + ...existing + .filter((a) => a.providerId !== providerId) + .map((a) => ({ + providerId: a.providerId, + modelAccessMode: a.modelAccessMode + })), + { providerId, modelAccessMode } + ]; + + const attachments = await resolveProviderAttachments({ + orgId: resource.orgId, + attachments: nextAttachments, + requireAtLeastOne: true + }); + if (isInferenceFieldsError(attachments)) { + return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error)); + } + + await setPublicResourceAiProviders(resourceId, attachments); + + return response(res, { + data: {}, + success: true, + error: false, + message: "AI provider added to resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index e56896e55..4c880c3af 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -38,6 +38,13 @@ import { } from "@server/db/names"; import { usageService } from "@server/lib/billing/usageService"; import { LimitId } from "@server/lib/billing"; +import { + isInferenceFieldsError, + resolveProviderAttachments, + resourceAiProviderAttachmentSchema, + setPublicResourceAiProviders, + type ResourceAiProviderAttachment +} from "@server/lib/aiInferenceResource"; const createResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -98,13 +105,11 @@ const createHttpResourceSchema = z authDaemonPort: z.int().positive().optional(), authDaemonMode: z.enum(["site", "remote", "native"]).optional(), // Inference settings - aiProviderId: z - .number() - .int() - .positive() + aiProviders: z + .array(resourceAiProviderAttachmentSchema) .optional() .describe( - "For inference-mode resources: the AI provider this resource proxies chat completions to." + "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." ) }) .refine( @@ -377,11 +382,35 @@ async function createHttpResource( authDaemonPort, authDaemonMode, pamMode, - aiProviderId + aiProviders: aiProviderInputs } = parsedBody.data; const subdomain = parsedBody.data.subdomain; const stickySession = parsedBody.data.stickySession; + const effectiveMode = mode ?? "http"; + + let providerAttachments: ResourceAiProviderAttachment[] = []; + if (effectiveMode === "inference") { + const resolved = await resolveProviderAttachments({ + orgId, + attachments: aiProviderInputs ?? [], + requireAtLeastOne: true + }); + if (isInferenceFieldsError(resolved)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, resolved.error) + ); + } + providerAttachments = resolved; + } else if (aiProviderInputs && aiProviderInputs.length > 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI providers can only be attached to inference-mode resources" + ) + ); + } + // Wildcard subdomains are a paid feature if (subdomain && subdomain.includes("*")) { const isLicensed = await isLicensedOrSubscribed( @@ -422,7 +451,7 @@ async function createHttpResource( } if ( - ["ssh", "rdp", "vnc"].includes(mode!) && + ["ssh", "rdp", "vnc"].includes(effectiveMode) && !isLicensedOrSubscribed( orgId!, tierMatrix[TierFeature.AdvancedPublicResources] @@ -555,7 +584,7 @@ async function createHttpResource( orgId, name, subdomain: finalSubdomain, - mode: mode, + mode: effectiveMode, pamMode: pamMode, authDaemonMode: authDaemonMode, authDaemonPort: authDaemonPort, @@ -564,11 +593,18 @@ async function createHttpResource( postAuthPath: postAuthPath, wildcard, health: "unknown", - defaultResourcePolicyId: defaultPolicy.resourcePolicyId, - aiProviderId: aiProviderId ?? null + defaultResourcePolicyId: defaultPolicy.resourcePolicyId }) .returning(); + if (providerAttachments.length > 0) { + await setPublicResourceAiProviders( + newResource[0].resourceId, + providerAttachments, + trx + ); + } + await trx.insert(roleResources).values({ roleId: adminRole[0].roleId, resourceId: newResource[0].resourceId diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 32992d364..a09cf0452 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -39,3 +39,7 @@ export * from "./listResourceAiModels"; export * from "./setResourceAiModels"; export * from "./addAiModelToResource"; export * from "./removeAiModelFromResource"; +export * from "./listResourceAiProviders"; +export * from "./setResourceAiProviders"; +export * from "./addAiProviderToResource"; +export * from "./removeAiProviderFromResource"; diff --git a/server/routers/resource/listResourceAiModels.ts b/server/routers/resource/listResourceAiModels.ts index f6875faff..0a5dea778 100644 --- a/server/routers/resource/listResourceAiModels.ts +++ b/server/routers/resource/listResourceAiModels.ts @@ -34,7 +34,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/ai-models", description: - "List the AI models a resource is restricted to. An empty list means the resource is not restricted and every enabled model on its linked AI provider is allowed.", + "List catalog models on this resource's allowlist. Only enforced when modelAccessMode=allowlist; an empty allowlist denies all models.", tags: [OpenAPITags.PublicResource], request: { params: listResourceAiModelsParamsSchema diff --git a/server/routers/resource/listResourceAiProviders.ts b/server/routers/resource/listResourceAiProviders.ts new file mode 100644 index 000000000..2c81c45d5 --- /dev/null +++ b/server/routers/resource/listResourceAiProviders.ts @@ -0,0 +1,94 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { listPublicResourceAiProviders } from "@server/lib/aiInferenceResource"; + +const listResourceAiProvidersParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +export type ListResourceAiProvidersResponse = { + providers: Awaited>; +}; + +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/ai-providers", + description: "List AI providers attached to an inference resource.", + tags: [OpenAPITags.PublicResource], + request: { + params: listResourceAiProvidersParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function listResourceAiProviders( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listResourceAiProvidersParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const providers = await listPublicResourceAiProviders(resourceId); + + return response(res, { + data: { providers }, + success: true, + error: false, + message: "Resource AI providers retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/removeAiModelFromResource.ts b/server/routers/resource/removeAiModelFromResource.ts index 47445bbf5..7b5cb012f 100644 --- a/server/routers/resource/removeAiModelFromResource.ts +++ b/server/routers/resource/removeAiModelFromResource.ts @@ -8,6 +8,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { assertPublicAllowlistApiEligible } from "@server/lib/aiInferenceResource"; const removeAiModelFromResourceBodySchema = z.strictObject({ modelId: z.int().positive() @@ -21,7 +22,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/ai-models/remove", description: - "Remove a single AI model from a resource's model restriction allow-list.", + "Remove a single catalog model from an inference resource allowlist. Requires at least one attached AI provider in allowlist mode.", tags: [OpenAPITags.PublicResource], request: { params: removeAiModelFromResourceParamsSchema, @@ -97,6 +98,11 @@ export async function removeAiModelFromResource( ); } + const eligibleError = await assertPublicAllowlistApiEligible(resource); + if (eligibleError) { + return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError)); + } + const existingEntry = await db .select() .from(resourceAiModels) diff --git a/server/routers/resource/removeAiProviderFromResource.ts b/server/routers/resource/removeAiProviderFromResource.ts new file mode 100644 index 000000000..d3a4294ad --- /dev/null +++ b/server/routers/resource/removeAiProviderFromResource.ts @@ -0,0 +1,165 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { + isInferenceFieldsError, + listPublicResourceAiProviders, + resolveProviderAttachments, + setPublicResourceAiProviders +} from "@server/lib/aiInferenceResource"; + +const removeAiProviderFromResourceBodySchema = z.strictObject({ + providerId: z.number().int().positive() +}); + +const removeAiProviderFromResourceParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/ai-providers/remove", + description: + "Remove an AI provider attachment from an inference resource. At least one provider must remain.", + tags: [OpenAPITags.PublicResource], + request: { + params: removeAiProviderFromResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeAiProviderFromResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function removeAiProviderFromResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = removeAiProviderFromResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { providerId } = parsedBody.data; + + const parsedParams = + removeAiProviderFromResourceParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + if (resource.mode !== "inference") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI providers can only be attached to inference-mode resources" + ) + ); + } + + const existing = await listPublicResourceAiProviders(resourceId); + const found = existing.find((a) => a.providerId === providerId); + if (!found) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "AI provider is not attached to this resource" + ) + ); + } + + const remaining = existing + .filter((a) => a.providerId !== providerId) + .map((a) => ({ + providerId: a.providerId, + modelAccessMode: a.modelAccessMode + })); + + if (remaining.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "At least one AI provider is required for inference-mode resources" + ) + ); + } + + const attachments = await resolveProviderAttachments({ + orgId: resource.orgId, + attachments: remaining, + requireAtLeastOne: true + }); + if (isInferenceFieldsError(attachments)) { + return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error)); + } + + await setPublicResourceAiProviders(resourceId, attachments); + + return response(res, { + data: {}, + success: true, + error: false, + message: "AI provider removed from resource successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/setResourceAiModels.ts b/server/routers/resource/setResourceAiModels.ts index 2ef56f4fc..0c3323ebe 100644 --- a/server/routers/resource/setResourceAiModels.ts +++ b/server/routers/resource/setResourceAiModels.ts @@ -1,13 +1,17 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, resources, resourceAiModels, aiModels } from "@server/db"; -import { eq, and, inArray } from "drizzle-orm"; +import { db, resources, resourceAiModels } from "@server/db"; +import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { + assertPublicAllowlistApiEligible, + assertModelsBelongToPublicAllowlistProviders +} from "@server/lib/aiInferenceResource"; const setResourceAiModelsBodySchema = z.strictObject({ modelIds: z.array(z.int().positive()) @@ -21,7 +25,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/ai-models", description: - "Set the AI models a resource is restricted to. This replaces all existing restrictions. Pass an empty array to remove the restriction (allow every enabled model on the linked provider).", + "Replace the allowlist of catalog models for an inference resource. Requires at least one attached AI provider in allowlist mode. Models must belong to a provider attached in allowlist mode. An empty array denies all models.", tags: [OpenAPITags.PublicResource], request: { params: setResourceAiModelsParamsSchema, @@ -95,34 +99,18 @@ export async function setResourceAiModels( ); } - if (modelIds.length > 0) { - if (!resource.aiProviderId) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Resource has no AI provider linked" - ) - ); - } + const eligibleError = await assertPublicAllowlistApiEligible(resource); + if (eligibleError) { + return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError)); + } - const validModels = await db - .select({ modelId: aiModels.modelId }) - .from(aiModels) - .where( - and( - inArray(aiModels.modelId, modelIds), - eq(aiModels.providerId, resource.aiProviderId) - ) - ); - - if (validModels.length !== new Set(modelIds).size) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "One or more model IDs do not exist or do not belong to this resource's AI provider" - ) - ); - } + const modelError = await assertModelsBelongToPublicAllowlistProviders({ + orgId: resource.orgId, + resourceId, + modelIds + }); + if (modelError) { + return next(createHttpError(HttpCode.BAD_REQUEST, modelError)); } await db.transaction(async (trx) => { diff --git a/server/routers/resource/setResourceAiProviders.ts b/server/routers/resource/setResourceAiProviders.ts new file mode 100644 index 000000000..a1de8f176 --- /dev/null +++ b/server/routers/resource/setResourceAiProviders.ts @@ -0,0 +1,137 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { + isInferenceFieldsError, + resolveProviderAttachments, + resourceAiProviderAttachmentSchema, + setPublicResourceAiProviders +} from "@server/lib/aiInferenceResource"; + +const setResourceAiProvidersBodySchema = z.strictObject({ + providers: z.array(resourceAiProviderAttachmentSchema) +}); + +const setResourceAiProvidersParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +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.", + tags: [OpenAPITags.PublicResource], + request: { + params: setResourceAiProvidersParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceAiProvidersBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function setResourceAiProviders( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setResourceAiProvidersBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { providers } = parsedBody.data; + + const parsedParams = setResourceAiProvidersParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + if (resource.mode !== "inference") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI providers can only be attached to inference-mode resources" + ) + ); + } + + const attachments = await resolveProviderAttachments({ + orgId: resource.orgId, + attachments: providers, + requireAtLeastOne: true + }); + if (isInferenceFieldsError(attachments)) { + return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error)); + } + + await setPublicResourceAiProviders(resourceId, attachments); + + return response(res, { + data: {}, + success: true, + error: false, + message: "AI providers set for resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index c67cb2795..0d6f8f8aa 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -120,15 +120,6 @@ const updateHttpResourceBodySchema = z .optional() .describe( "ID of the resource policy to apply to this resource. Set to null to remove the resource policy and fall back to the inline policy settings." - ), - aiProviderId: z - .number() - .int() - .positive() - .nullable() - .optional() - .describe( - "For inference-mode resources: the AI provider this resource proxies chat completions to. Set to null to unlink." ) }) .refine((data) => Object.keys(data).length > 0, { @@ -354,8 +345,10 @@ export async function updateResource( ); } - if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { - // HANDLE UPDATING HTTP RESOURCES + if ( + ["http", "ssh", "rdp", "vnc", "inference"].includes(resource.mode) + ) { + // HANDLE UPDATING HTTP / BROWSER / INFERENCE RESOURCES return await updateHttpResource( { req, diff --git a/server/routers/siteResource/addAiModelToSiteResource.ts b/server/routers/siteResource/addAiModelToSiteResource.ts index 2e55f100b..0b6a004d7 100644 --- a/server/routers/siteResource/addAiModelToSiteResource.ts +++ b/server/routers/siteResource/addAiModelToSiteResource.ts @@ -1,11 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { - db, - siteResources, - siteResourceAiModels, - aiModels -} from "@server/db"; +import { db, siteResources, siteResourceAiModels } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -13,6 +8,10 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { + assertSiteAllowlistApiEligible, + assertModelsBelongToSiteAllowlistProviders +} from "@server/lib/aiInferenceResource"; const addAiModelToSiteResourceBodySchema = z.strictObject({ modelId: z.int().positive() @@ -26,7 +25,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/ai-models/add", description: - "Add a single AI model to a site resource's model restriction allow-list.", + "Add a single catalog model to an inference site resource allowlist. Requires at least one attached AI provider in allowlist mode. The model must belong to a provider attached in allowlist mode.", tags: [OpenAPITags.PrivateResource], request: { params: addAiModelToSiteResourceParamsSchema, @@ -102,33 +101,19 @@ export async function addAiModelToSiteResource( ); } - if (!siteResource.aiProviderId) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Site resource has no AI provider linked" - ) - ); + const eligibleError = + await assertSiteAllowlistApiEligible(siteResource); + if (eligibleError) { + return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError)); } - const [model] = await db - .select() - .from(aiModels) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, siteResource.aiProviderId) - ) - ) - .limit(1); - - if (!model) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - "Model not found or does not belong to this site resource's AI provider" - ) - ); + const modelError = await assertModelsBelongToSiteAllowlistProviders({ + orgId: siteResource.orgId, + siteResourceId, + modelIds: [modelId] + }); + if (modelError) { + return next(createHttpError(HttpCode.BAD_REQUEST, modelError)); } const existingEntry = await db diff --git a/server/routers/siteResource/addAiProviderToSiteResource.ts b/server/routers/siteResource/addAiProviderToSiteResource.ts new file mode 100644 index 000000000..bae346347 --- /dev/null +++ b/server/routers/siteResource/addAiProviderToSiteResource.ts @@ -0,0 +1,152 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { + isInferenceFieldsError, + listSiteResourceAiProviders, + modelAccessModeSchema, + resolveProviderAttachments, + setSiteResourceAiProviders +} from "@server/lib/aiInferenceResource"; + +const addAiProviderToSiteResourceBodySchema = z.strictObject({ + providerId: z.number().int().positive(), + modelAccessMode: modelAccessModeSchema.optional() +}); + +const addAiProviderToSiteResourceParamsSchema = z.strictObject({ + siteResourceId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/ai-providers/add", + description: + "Add or replace a single AI provider attachment on an inference site resource.", + tags: [OpenAPITags.PrivateResource], + request: { + params: addAiProviderToSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addAiProviderToSiteResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function addAiProviderToSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = addAiProviderToSiteResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { providerId, modelAccessMode } = parsedBody.data; + + const parsedParams = addAiProviderToSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + if (siteResource.mode !== "inference") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI providers can only be attached to inference-mode resources" + ) + ); + } + + const existing = await listSiteResourceAiProviders(siteResourceId); + const nextAttachments = [ + ...existing + .filter((a) => a.providerId !== providerId) + .map((a) => ({ + providerId: a.providerId, + modelAccessMode: a.modelAccessMode + })), + { providerId, modelAccessMode } + ]; + + const attachments = await resolveProviderAttachments({ + orgId: siteResource.orgId, + attachments: nextAttachments, + requireAtLeastOne: true + }); + if (isInferenceFieldsError(attachments)) { + return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error)); + } + + await setSiteResourceAiProviders(siteResourceId, attachments); + + return response(res, { + data: {}, + success: true, + error: false, + message: "AI provider added to site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 3244668f5..47dcfc59b 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -39,6 +39,13 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica import { build } from "@server/build"; import { usageService } from "@server/lib/billing/usageService"; import { LimitId } from "@server/lib/billing"; +import { + isInferenceFieldsError, + resolveProviderAttachments, + resourceAiProviderAttachmentSchema, + setSiteResourceAiProviders, + type ResourceAiProviderAttachment +} from "@server/lib/aiInferenceResource"; const createSiteResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -79,13 +86,11 @@ const createSiteResourceSchema = z pamMode: z.enum(["passthrough", "push"]).optional(), domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org subdomain: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org - aiProviderId: z - .number() - .int() - .positive() + aiProviders: z + .array(resourceAiProviderAttachmentSchema) .optional() .describe( - "For inference-mode site resources: the AI provider this resource proxies chat completions to." + "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." ) }) .strict() @@ -334,7 +339,7 @@ export async function createSiteResource( pamMode, domainId, subdomain, - aiProviderId + aiProviders: aiProviderInputs } = parsedBody.data; // Backward compatibility: merge deprecated siteId into siteIds array @@ -343,6 +348,28 @@ export async function createSiteResource( siteIds.push(siteId); } + let providerAttachments: ResourceAiProviderAttachment[] = []; + if (mode === "inference") { + const resolved = await resolveProviderAttachments({ + orgId, + attachments: aiProviderInputs ?? [], + requireAtLeastOne: true + }); + if (isInferenceFieldsError(resolved)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, resolved.error) + ); + } + providerAttachments = resolved; + } else if (aiProviderInputs && aiProviderInputs.length > 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI providers can only be attached to inference-mode resources" + ) + ); + } + if (build == "saas") { const usage = await usageService.getUsage( orgId, @@ -608,8 +635,7 @@ export async function createSiteResource( domainId, subdomain: finalSubdomain, fullDomain, - requiresExitNodeConnection: mode === "inference", // in the future we might want to have different modes that do this - aiProviderId: aiProviderId ?? null + requiresExitNodeConnection: mode === "inference" // in the future we might want to have different modes that do this }; if (isLicensedSshPam) { if (authDaemonPort !== undefined) @@ -625,6 +651,14 @@ export async function createSiteResource( const siteResourceId = newSiteResource.siteResourceId; + if (providerAttachments.length > 0) { + await setSiteResourceAiProviders( + siteResourceId, + providerAttachments, + trx + ); + } + //////////////////// update the associations //////////////////// if (network) { diff --git a/server/routers/siteResource/index.ts b/server/routers/siteResource/index.ts index 2acaf1e33..046ed0a13 100644 --- a/server/routers/siteResource/index.ts +++ b/server/routers/siteResource/index.ts @@ -21,3 +21,7 @@ export * from "./listSiteResourceAiModels"; export * from "./setSiteResourceAiModels"; export * from "./addAiModelToSiteResource"; export * from "./removeAiModelFromSiteResource"; +export * from "./listSiteResourceAiProviders"; +export * from "./setSiteResourceAiProviders"; +export * from "./addAiProviderToSiteResource"; +export * from "./removeAiProviderFromSiteResource"; diff --git a/server/routers/siteResource/listSiteResourceAiModels.ts b/server/routers/siteResource/listSiteResourceAiModels.ts index 26dea7cd0..26e709140 100644 --- a/server/routers/siteResource/listSiteResourceAiModels.ts +++ b/server/routers/siteResource/listSiteResourceAiModels.ts @@ -1,11 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { - db, - siteResources, - siteResourceAiModels, - aiModels -} from "@server/db"; +import { db, siteResources, siteResourceAiModels, aiModels } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -27,10 +22,7 @@ async function query(siteResourceId: number) { enabled: aiModels.enabled }) .from(siteResourceAiModels) - .innerJoin( - aiModels, - eq(siteResourceAiModels.modelId, aiModels.modelId) - ) + .innerJoin(aiModels, eq(siteResourceAiModels.modelId, aiModels.modelId)) .where(eq(siteResourceAiModels.siteResourceId, siteResourceId)); } @@ -42,7 +34,7 @@ registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/ai-models", description: - "List the AI models a site resource is restricted to. An empty list means the site resource is not restricted and every enabled model on its linked AI provider is allowed.", + "List catalog models on this site resource's allowlist. Only enforced when modelAccessMode=allowlist; an empty allowlist denies all models.", tags: [OpenAPITags.PrivateResource], request: { params: listSiteResourceAiModelsParamsSchema diff --git a/server/routers/siteResource/listSiteResourceAiProviders.ts b/server/routers/siteResource/listSiteResourceAiProviders.ts new file mode 100644 index 000000000..bcbe37e8b --- /dev/null +++ b/server/routers/siteResource/listSiteResourceAiProviders.ts @@ -0,0 +1,94 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { listSiteResourceAiProviders as listAttachments } from "@server/lib/aiInferenceResource"; + +const listSiteResourceAiProvidersParamsSchema = z.strictObject({ + siteResourceId: z.coerce.number().int().positive() +}); + +export type ListSiteResourceAiProvidersResponse = { + providers: Awaited>; +}; + +registry.registerPath({ + method: "get", + path: "/site-resource/{siteResourceId}/ai-providers", + description: "List AI providers attached to an inference site resource.", + tags: [OpenAPITags.PrivateResource], + request: { + params: listSiteResourceAiProvidersParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function listSiteResourceAiProviders( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listSiteResourceAiProvidersParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + const providers = await listAttachments(siteResourceId); + + return response(res, { + data: { providers }, + success: true, + error: false, + message: "Site resource AI providers retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/siteResource/removeAiModelFromSiteResource.ts b/server/routers/siteResource/removeAiModelFromSiteResource.ts index 5af2f4d87..eb8d649a2 100644 --- a/server/routers/siteResource/removeAiModelFromSiteResource.ts +++ b/server/routers/siteResource/removeAiModelFromSiteResource.ts @@ -8,6 +8,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { assertSiteAllowlistApiEligible } from "@server/lib/aiInferenceResource"; const removeAiModelFromSiteResourceBodySchema = z.strictObject({ modelId: z.int().positive() @@ -21,7 +22,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/ai-models/remove", description: - "Remove a single AI model from a site resource's model restriction allow-list.", + "Remove a single catalog model from an inference site resource allowlist. Requires at least one attached AI provider in allowlist mode.", tags: [OpenAPITags.PrivateResource], request: { params: removeAiModelFromSiteResourceParamsSchema, @@ -96,6 +97,12 @@ export async function removeAiModelFromSiteResource( ); } + const eligibleError = + await assertSiteAllowlistApiEligible(siteResource); + if (eligibleError) { + return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError)); + } + const existingEntry = await db .select() .from(siteResourceAiModels) diff --git a/server/routers/siteResource/removeAiProviderFromSiteResource.ts b/server/routers/siteResource/removeAiProviderFromSiteResource.ts new file mode 100644 index 000000000..65781859c --- /dev/null +++ b/server/routers/siteResource/removeAiProviderFromSiteResource.ts @@ -0,0 +1,164 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { + isInferenceFieldsError, + listSiteResourceAiProviders, + resolveProviderAttachments, + setSiteResourceAiProviders +} from "@server/lib/aiInferenceResource"; + +const removeAiProviderFromSiteResourceBodySchema = z.strictObject({ + providerId: z.number().int().positive() +}); + +const removeAiProviderFromSiteResourceParamsSchema = z.strictObject({ + siteResourceId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/ai-providers/remove", + description: + "Remove an AI provider attachment from an inference site resource. At least one provider must remain.", + tags: [OpenAPITags.PrivateResource], + request: { + params: removeAiProviderFromSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeAiProviderFromSiteResourceBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function removeAiProviderFromSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = + removeAiProviderFromSiteResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { providerId } = parsedBody.data; + + const parsedParams = + removeAiProviderFromSiteResourceParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + if (siteResource.mode !== "inference") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI providers can only be attached to inference-mode resources" + ) + ); + } + + const existing = await listSiteResourceAiProviders(siteResourceId); + const found = existing.find((a) => a.providerId === providerId); + if (!found) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "AI provider is not attached to this site resource" + ) + ); + } + + const remaining = existing + .filter((a) => a.providerId !== providerId) + .map((a) => ({ + providerId: a.providerId, + modelAccessMode: a.modelAccessMode + })); + + if (remaining.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "At least one AI provider is required for inference-mode resources" + ) + ); + } + + const attachments = await resolveProviderAttachments({ + orgId: siteResource.orgId, + attachments: remaining, + requireAtLeastOne: true + }); + if (isInferenceFieldsError(attachments)) { + return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error)); + } + + await setSiteResourceAiProviders(siteResourceId, attachments); + + return response(res, { + data: {}, + success: true, + error: false, + message: "AI provider removed from site resource successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/siteResource/setSiteResourceAiModels.ts b/server/routers/siteResource/setSiteResourceAiModels.ts index 1366187af..391634553 100644 --- a/server/routers/siteResource/setSiteResourceAiModels.ts +++ b/server/routers/siteResource/setSiteResourceAiModels.ts @@ -1,18 +1,17 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { - db, - siteResources, - siteResourceAiModels, - aiModels -} from "@server/db"; -import { eq, and, inArray } from "drizzle-orm"; +import { db, siteResources, siteResourceAiModels } from "@server/db"; +import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { + assertSiteAllowlistApiEligible, + assertModelsBelongToSiteAllowlistProviders +} from "@server/lib/aiInferenceResource"; const setSiteResourceAiModelsBodySchema = z.strictObject({ modelIds: z.array(z.int().positive()) @@ -26,7 +25,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/ai-models", description: - "Set the AI models a site resource is restricted to. This replaces all existing restrictions. Pass an empty array to remove the restriction (allow every enabled model on the linked provider).", + "Replace the allowlist of catalog models for an inference site resource. Requires at least one attached AI provider in allowlist mode. Models must belong to a provider attached in allowlist mode. An empty array denies all models.", tags: [OpenAPITags.PrivateResource], request: { params: setSiteResourceAiModelsParamsSchema, @@ -102,52 +101,33 @@ export async function setSiteResourceAiModels( ); } - if (modelIds.length > 0) { - if (!siteResource.aiProviderId) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Site resource has no AI provider linked" - ) - ); - } + const eligibleError = + await assertSiteAllowlistApiEligible(siteResource); + if (eligibleError) { + return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError)); + } - const validModels = await db - .select({ modelId: aiModels.modelId }) - .from(aiModels) - .where( - and( - inArray(aiModels.modelId, modelIds), - eq(aiModels.providerId, siteResource.aiProviderId) - ) - ); - - if (validModels.length !== new Set(modelIds).size) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "One or more model IDs do not exist or do not belong to this site resource's AI provider" - ) - ); - } + const modelError = await assertModelsBelongToSiteAllowlistProviders({ + orgId: siteResource.orgId, + siteResourceId, + modelIds + }); + if (modelError) { + return next(createHttpError(HttpCode.BAD_REQUEST, modelError)); } await db.transaction(async (trx) => { await trx .delete(siteResourceAiModels) - .where( - eq(siteResourceAiModels.siteResourceId, siteResourceId) - ); + .where(eq(siteResourceAiModels.siteResourceId, siteResourceId)); if (modelIds.length > 0) { - await trx - .insert(siteResourceAiModels) - .values( - modelIds.map((modelId) => ({ - siteResourceId, - modelId - })) - ); + await trx.insert(siteResourceAiModels).values( + modelIds.map((modelId) => ({ + siteResourceId, + modelId + })) + ); } }); diff --git a/server/routers/siteResource/setSiteResourceAiProviders.ts b/server/routers/siteResource/setSiteResourceAiProviders.ts new file mode 100644 index 000000000..93bf8a866 --- /dev/null +++ b/server/routers/siteResource/setSiteResourceAiProviders.ts @@ -0,0 +1,139 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { + isInferenceFieldsError, + resolveProviderAttachments, + resourceAiProviderAttachmentSchema, + setSiteResourceAiProviders as replaceAttachments +} from "@server/lib/aiInferenceResource"; + +const setSiteResourceAiProvidersBodySchema = z.strictObject({ + providers: z.array(resourceAiProviderAttachmentSchema) +}); + +const setSiteResourceAiProvidersParamsSchema = z.strictObject({ + siteResourceId: z.coerce.number().int().positive() +}); + +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.", + tags: [OpenAPITags.PrivateResource], + request: { + params: setSiteResourceAiProvidersParamsSchema, + body: { + content: { + "application/json": { + schema: setSiteResourceAiProvidersBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function setSiteResourceAiProviders( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setSiteResourceAiProvidersBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { providers } = parsedBody.data; + + const parsedParams = setSiteResourceAiProvidersParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + if (siteResource.mode !== "inference") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI providers can only be attached to inference-mode resources" + ) + ); + } + + const attachments = await resolveProviderAttachments({ + orgId: siteResource.orgId, + attachments: providers, + requireAtLeastOne: true + }); + if (isInferenceFieldsError(attachments)) { + return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error)); + } + + await replaceAttachments(siteResourceId, attachments); + + return response(res, { + data: {}, + success: true, + error: false, + message: "AI providers set for site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index caf524804..e99d60e1e 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -29,6 +29,7 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; +import { clearSiteResourceAiConfig } from "@server/lib/aiInferenceResource"; const updateSiteResourceParamsSchema = z.strictObject({ siteResourceId: z.coerce.number().int().positive() @@ -78,16 +79,7 @@ const updateSiteResourceSchema = z authDaemonMode: z.enum(["site", "remote", "native"]).optional(), pamMode: z.enum(["passthrough", "push"]).optional(), domainId: z.string().optional(), - subdomain: z.string().optional(), - aiProviderId: z - .number() - .int() - .positive() - .nullable() - .optional() - .describe( - "For inference-mode site resources: the AI provider this resource proxies chat completions to. Set to null to unlink." - ) + subdomain: z.string().optional() }) .strict() .refine( @@ -341,8 +333,7 @@ export async function updateSiteResource( authDaemonMode, pamMode, domainId, - subdomain, - aiProviderId + subdomain } = parsedBody.data; // Backward compatibility: merge deprecated siteId into siteIds array @@ -608,12 +599,19 @@ export async function updateSiteResource( networkId: mode === "inference" ? null : undefined, requiresExitNodeConnection: mode !== undefined ? mode === "inference" : undefined, - aiProviderId: aiProviderId, ...sshPamSet }) .where(and(eq(siteResources.siteResourceId, siteResourceId))) .returning(); + const effectiveMode = mode ?? existingSiteResource.mode; + if ( + existingSiteResource.mode === "inference" && + effectiveMode !== "inference" + ) { + await clearSiteResourceAiConfig(siteResourceId, trx); + } + //////////////////// update the associations //////////////////// if (mode === "inference") { diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx index f054c7901..02d1d2142 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx @@ -66,8 +66,6 @@ export default function AiProviderAuthenticationPage() { authType: (provider.authType as "bearer" | null) ?? "bearer", routingMode: (provider.routingMode as "url" | "target") ?? "url", skipTlsVerification: provider.skipTlsVerification, - budgetAmount: provider.budgetAmount, - budgetUnit: provider.budgetUnit as "usd" | "tokens" | null, enabled: provider.enabled } }); @@ -96,8 +94,6 @@ export default function AiProviderAuthenticationPage() { authType: (updated.authType as "bearer" | null) ?? "bearer", routingMode: (updated.routingMode as "url" | "target") ?? "url", skipTlsVerification: updated.skipTlsVerification, - budgetAmount: updated.budgetAmount, - budgetUnit: updated.budgetUnit as "usd" | "tokens" | null, enabled: updated.enabled }); toast({ diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx index 90ef74d10..2aa06d01d 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx @@ -75,8 +75,6 @@ export default function AiProviderNetworkPage() { authType: (provider.authType as "bearer" | null) ?? "bearer", routingMode: (provider.routingMode as "url" | "target") ?? "url", skipTlsVerification: provider.skipTlsVerification, - budgetAmount: provider.budgetAmount, - budgetUnit: provider.budgetUnit as "usd" | "tokens" | null, enabled: provider.enabled } }); @@ -120,8 +118,6 @@ export default function AiProviderNetworkPage() { authType: (updated.authType as "bearer" | null) ?? "bearer", routingMode: (updated.routingMode as "url" | "target") ?? "url", skipTlsVerification: updated.skipTlsVerification, - budgetAmount: updated.budgetAmount, - budgetUnit: updated.budgetUnit as "usd" | "tokens" | null, enabled: updated.enabled }); diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index ec6eee6c7..3111f0b3e 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -79,8 +79,6 @@ export default function CreateAiProviderPage() { authType: "bearer", routingMode: "url", skipTlsVerification: false, - budgetAmount: null, - budgetUnit: null, enabled: true } }); diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index 8861dcdbf..26245620c 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -26,8 +26,6 @@ export const aiProviderFormSchema = z authType: z.enum(["bearer"]).optional().nullable(), routingMode: z.enum(["url", "target"]).optional(), skipTlsVerification: z.boolean().optional(), - budgetAmount: z.number().positive().nullable().optional(), - budgetUnit: z.enum(["usd", "tokens"]).optional().nullable(), enabled: z.boolean().optional() }) .superRefine((data, ctx) => { @@ -78,20 +76,6 @@ export const aiProviderFormSchema = z path: ["authType"] }); } - - const hasAmount = - data.budgetAmount !== undefined && data.budgetAmount !== null; - const hasUnit = - data.budgetUnit !== undefined && data.budgetUnit !== null; - - if (hasAmount !== hasUnit) { - ctx.addIssue({ - code: "custom", - message: - "budgetAmount and budgetUnit must both be set or both omitted", - path: hasAmount ? ["budgetUnit"] : ["budgetAmount"] - }); - } }); export type AiProviderFormValues = z.infer; @@ -145,11 +129,6 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) { ? upstreamRaw : null; - const hasBudget = - values.budgetAmount !== undefined && - values.budgetAmount !== null && - values.budgetUnit; - return { name: values.name.trim(), type: values.type, @@ -161,8 +140,6 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) { ? (values.authType ?? "bearer") : (values.authType ?? undefined), skipTlsVerification: values.skipTlsVerification, - budgetAmount: hasBudget ? values.budgetAmount : null, - budgetUnit: hasBudget ? values.budgetUnit : null, enabled: values.enabled ?? true }; } @@ -178,11 +155,6 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) { ? upstreamRaw : null; - const hasBudget = - values.budgetAmount !== undefined && - values.budgetAmount !== null && - values.budgetUnit; - const payload: Record = { name: values.name.trim(), routingMode: values.type === "custom" ? routingMode : "url", @@ -192,8 +164,6 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) { ? (values.authType ?? "bearer") : (values.authType ?? null), skipTlsVerification: values.skipTlsVerification ?? false, - budgetAmount: hasBudget ? values.budgetAmount : null, - budgetUnit: hasBudget ? values.budgetUnit : null, enabled: values.enabled ?? true }; From e99cd52e99c1a693001aa53e8e5948bc1acba0aa Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 4 Aug 2026 21:55:34 +0200 Subject: [PATCH 196/494] =?UTF-8?q?=F0=9F=90=9B=20Fix=20log=20retention=20?= =?UTF-8?q?access=20pulling=20from=20the=20wrong=20org=20settings=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/private/lib/logAccessAudit.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/private/lib/logAccessAudit.ts b/server/private/lib/logAccessAudit.ts index ff0c1d779..12b30f99f 100644 --- a/server/private/lib/logAccessAudit.ts +++ b/server/private/lib/logAccessAudit.ts @@ -28,7 +28,7 @@ async function getAccessDays(orgId: string): Promise { const [org] = await db .select({ - settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction + settingsLogRetentionDaysAccess: orgs.settingsLogRetentionDaysAccess }) .from(orgs) .where(eq(orgs.orgId, orgId)) @@ -41,11 +41,11 @@ async function getAccessDays(orgId: string): Promise { // store the result in cache await cache.set( `org_${orgId}_accessDays`, - org.settingsLogRetentionDaysAction, + org.settingsLogRetentionDaysAccess, 300 ); - return org.settingsLogRetentionDaysAction; + return org.settingsLogRetentionDaysAccess; } export async function cleanUpOldLogs(orgId: string, retentionDays: number) { From 7759d878352d09ab1cfde3d8cd7b89fc0e13fb39 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 4 Aug 2026 16:54:23 -0400 Subject: [PATCH 197/494] add basic ui for private inference resource --- messages/en-US.json | 14 ++ server/db/pg/schema/schema.ts | 8 +- server/db/sqlite/schema/schema.ts | 8 +- server/lib/aiInferenceResource.ts | 79 ++++-- server/routers/aiGateway/chatCompletions.ts | 32 --- server/routers/resource/createResource.ts | 6 +- .../resource/setResourceAiProviders.ts | 6 +- .../siteResource/createSiteResource.ts | 2 +- .../setSiteResourceAiProviders.ts | 6 +- .../ai-providers/[providerId]/layout.tsx | 4 + .../ai-providers/[providerId]/models/page.tsx | 150 +++++++++++ .../private/[niceId]/general/page.tsx | 30 ++- .../private/[niceId]/inference/page.tsx | 112 +-------- .../resources/private/[niceId]/layout.tsx | 45 ++-- .../private/[niceId]/providers/page.tsx | 232 ++++++++++++++++++ .../resources/private/create/page.tsx | 91 ++++++- src/components/AiProvidersSelector.tsx | 61 +++++ src/lib/privateResourceForm.ts | 92 +++++-- src/lib/queries.ts | 58 +++++ 19 files changed, 828 insertions(+), 208 deletions(-) create mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx create mode 100644 src/app/[orgId]/settings/resources/private/[niceId]/providers/page.tsx create mode 100644 src/components/AiProvidersSelector.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 81a60f0a1..2fc4e778e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index b323ae411..868b2a121 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -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] })] ); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index c8f25dfed..2ae941895 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -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] })] ); diff --git a/server/lib/aiInferenceResource.ts b/server/lib/aiInferenceResource.ts index 4175a11e8..6e0f490d8 100644 --- a/server/lib/aiInferenceResource.ts +++ b/server/lib/aiInferenceResource.ts @@ -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; @@ -50,10 +46,7 @@ function normalizeAttachments( ): ResourceAiProviderAttachment[] { const byProvider = new Map(); 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 { + 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(); + 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; } diff --git a/server/routers/aiGateway/chatCompletions.ts b/server/routers/aiGateway/chatCompletions.ts index 51508778a..f1ac231ff 100644 --- a/server/routers/aiGateway/chatCompletions.ts +++ b/server/routers/aiGateway/chatCompletions.ts @@ -331,10 +331,6 @@ async function providerMatchesModel( requestedModel: string, allowedModelIds: number[] ): Promise { - 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 { - 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, diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 4c880c3af..4d546468f 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -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) { diff --git a/server/routers/resource/setResourceAiProviders.ts b/server/routers/resource/setResourceAiProviders.ts index a1de8f176..0cd0d5d84 100644 --- a/server/routers/resource/setResourceAiProviders.ts +++ b/server/routers/resource/setResourceAiProviders.ts @@ -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); diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 47dcfc59b..77d9d5f51 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -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() diff --git a/server/routers/siteResource/setSiteResourceAiProviders.ts b/server/routers/siteResource/setSiteResourceAiProviders.ts index 93bf8a866..c6e708064 100644 --- a/server/routers/siteResource/setSiteResourceAiProviders.ts +++ b/server/routers/siteResource/setSiteResourceAiProviders.ts @@ -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); diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx index 5846ad86d..4647347f5 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx @@ -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" diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx new file mode 100644 index 000000000..f90143492 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx @@ -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([]); + const [activeTagIndex, setActiveTagIndex] = useState(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 ( + + + + + {t("aiProviderModels")} + + + {t("aiProviderModelsDescription")} + + + + + + { + const next = + typeof newTags === "function" + ? newTags(tags) + : newTags; + setTags(next as Tag[]); + }} + allowDuplicates={false} + sortTags + delimiterList={[",", "Enter"]} + disabled={modelsQuery.isLoading || saveLoading} + /> + + + + + + + + + ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx index 8dbdb3809..b0af75ec0 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx @@ -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; const form = useForm({ @@ -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() { )} /> + {isInference && ( + + + + )} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx index 24aab5067..34534fdd4 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx @@ -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; - - const form = useForm({ - 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 ( - - - - - {t("hostSettings")} - - - {t("editInternalResourceDialogDestinationDescription")} - - - - - -
- - - - - - -
- -
-
- - - - -
-
+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` ); } diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx index f07b92673..57064e545 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx @@ -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 ( <> diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/providers/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/providers/page.tsx new file mode 100644 index 000000000..ead7ab24b --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/providers/page.tsx @@ -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; + + const [selectedProviders, setSelectedProviders] = useState< + SelectedAiProvider[] + >([]); + + const attachedQuery = useQuery({ + ...resourceQueries.siteResourceAiProviders({ + siteResourceId: siteResource.id + }), + enabled: siteResource.mode === "inference" + }); + + const form = useForm({ + 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 ( + + + + + {t("aiResourceProviders")} + + + {t("aiResourceProvidersDescription")} + + + + + +
+ + + + ( + + + {t( + "aiResourceProviders" + )} + + + { + setSelectedProviders( + providers + ); + form.setValue( + "providerIds", + providers.map( + (p) => + parseInt( + p.id, + 10 + ) + ), + { + shouldValidate: true + } + ); + }} + /> + + + {t( + "aiResourceProvidersHelp" + )} + + + + )} + /> + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index 936409d40..9420da17b 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -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([]); + const [selectedProviders, setSelectedProviders] = useState< + SelectedAiProvider[] + >([]); const formSchema = useMemo(() => createCreateFormSchema(t), [t]); type FormValues = z.infer; @@ -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() { )} + {mode === "inference" && ( + + + + {t("aiResourceProviders")} + + + {t("aiResourceProvidersDescription")} + + + + + + + ( + + + {t( + "aiResourceProviders" + )} + + + { + setSelectedProviders( + providers + ); + form.setValue( + "providerIds", + providers.map( + ( + p + ) => + parseInt( + p.id, + 10 + ) + ), + { + shouldValidate: true + } + ); + }} + /> + + + {t( + "aiResourceProvidersHelp" + )} + + + + )} + /> + + + + + + )} +
)} - { !["tcp", "udp"].includes( - resource.mode - ) && !env.flags.disableEnterpriseFeatures && ( + {!["tcp", "udp", "inference"].includes( + resource.mode + ) && + !env.flags + .disableEnterpriseFeatures && ( <> diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx new file mode 100644 index 000000000..8ea8d2dd5 --- /dev/null +++ b/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +export const metadata: Metadata = { + title: "Public Resource" +}; + +export default async function PublicResourceInferencePage(props: { + params: Promise<{ niceId: string; orgId: string }>; +}) { + const params = await props.params; + redirect( + `/${params.orgId}/settings/resources/public/${params.niceId}/providers` + ); +} diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index 1599cdceb..1c786be19 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -82,16 +82,29 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { redirect(`/${params.orgId}/settings/resources`); } - const navItems = [ - { - title: t("general"), - href: `/{orgId}/settings/resources/public/{niceId}/general` - }, - { - title: t(`${resource.mode}Settings`), - href: `/{orgId}/settings/resources/public/{niceId}/${resource.mode}` - } - ]; + const isInference = resource.mode === "inference"; + + const navItems = isInference + ? [ + { + title: t("general"), + href: `/{orgId}/settings/resources/public/{niceId}/general` + }, + { + title: t("aiResourceProviders"), + href: `/{orgId}/settings/resources/public/{niceId}/providers` + } + ] + : [ + { + title: t("general"), + href: `/{orgId}/settings/resources/public/{niceId}/general` + }, + { + title: t(`${resource.mode}Settings`), + href: `/{orgId}/settings/resources/public/{niceId}/${resource.mode}` + } + ]; if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { navItems.push( diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/providers/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/providers/page.tsx new file mode 100644 index 000000000..67ef0fa9e --- /dev/null +++ b/src/app/[orgId]/settings/resources/public/[niceId]/providers/page.tsx @@ -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 { useResourceContext } from "@app/hooks/useResourceContext"; +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 PublicResourceProvidersPage() { + const t = useTranslations(); + const router = useRouter(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const queryClient = useQueryClient(); + const { resource } = useResourceContext(); + + useEffect(() => { + if (resource.mode !== "inference") { + router.replace( + `/${resource.orgId}/settings/resources/public/${resource.niceId}/general` + ); + } + }, [router, resource.mode, resource.niceId, resource.orgId]); + + const formSchema = useMemo( + () => + z.object({ + providerIds: z + .array(z.number().int().positive()) + .min(1, t("aiResourceProvidersRequired")) + }), + [t] + ); + type FormValues = z.infer; + + const [selectedProviders, setSelectedProviders] = useState< + SelectedAiProvider[] + >([]); + + const attachedQuery = useQuery({ + ...resourceQueries.resourceAiProviders({ + resourceId: resource.resourceId + }), + enabled: resource.mode === "inference" + }); + + const form = useForm({ + 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(`/resource/${resource.resourceId}/ai-providers`, { + providers: data.providerIds.map((providerId) => ({ + providerId, + modelAccessMode: "catalog" + })) + }); + + await queryClient.invalidateQueries( + resourceQueries.resourceAiProviders({ + resourceId: resource.resourceId + }) + ); + + toast({ + title: t("success"), + description: t("aiResourceProvidersUpdated") + }); + } catch (error) { + toast({ + variant: "destructive", + title: t("aiResourceProvidersErrorUpdate"), + description: formatAxiosError( + error, + t("aiResourceProvidersErrorUpdate") + ) + }); + } + }, null); + + if (resource.mode !== "inference") { + return null; + } + + return ( + + + + + {t("aiResourceProviders")} + + + {t("aiResourceProvidersDescription")} + + + + + +
+ + + + ( + + + {t( + "aiResourceProviders" + )} + + + { + setSelectedProviders( + providers + ); + form.setValue( + "providerIds", + providers.map( + (p) => + parseInt( + p.id, + 10 + ) + ), + { + shouldValidate: true + } + ); + }} + /> + + + {t( + "aiResourceProvidersHelp" + )} + + + + )} + /> + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index eaf4e5527..c0e0a0f8e 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -72,6 +72,10 @@ import { LocalTarget, ProxyResourceTargetsForm } from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm"; +import { + AiProvidersSelector, + type SelectedAiProvider +} from "@app/components/AiProvidersSelector"; import { AxiosResponse } from "axios"; import { ChevronsUpDown, ExternalLink } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -245,6 +249,10 @@ export default function Page() { // Target management state (managed by ProxyResourceTargetsForm; mirrored here for onSubmit) const [targets, setTargets] = useState([]); + const [selectedProviders, setSelectedProviders] = useState< + SelectedAiProvider[] + >([]); + const [showProvidersError, setShowProvidersError] = useState(false); // SSH-specific state const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( @@ -333,7 +341,7 @@ export default function Page() { !env.flags.disableEnterpriseFeatures; const availableTypes = useMemo((): NewResourceType[] => { - const base: NewResourceType[] = ["http"]; + const base: NewResourceType[] = ["http", "inference"]; if (enterpriseModesAllowed) { base.push("ssh", "rdp", "vnc"); } @@ -347,6 +355,9 @@ export default function Page() { if (!availableTypes.includes(resourceType)) { setResourceType("http"); } + if (resourceType !== "inference") { + setShowProvidersError(false); + } }, [availableTypes, resourceType]); const baseResourceFormSchema = useMemo( @@ -478,29 +489,42 @@ export default function Page() { ? finalizeSubdomainSanitize(httpData.subdomain, true) : undefined; - const effectiveMode = isNative - ? "native" - : standardDaemonLocation; - const portVal = sshDaemonPortForm.getValues().authDaemonPort; - const effectivePort = - !isNative && - standardDaemonLocation === "remote" && - pamMode === "push" && - portVal - ? Number(portVal) - : undefined; - Object.assign(payload, { subdomain: sanitizedSubdomain ? toASCII(sanitizedSubdomain) : undefined, domainId: httpData.domainId, protocol: "tcp", - mode: resourceType, - pamMode, - authDaemonMode: effectiveMode, - authDaemonPort: effectivePort || undefined + mode: resourceType }); + + if (resourceType === "inference") { + Object.assign(payload, { + aiProviders: selectedProviders.map((provider) => ({ + providerId: parseInt(provider.id, 10), + modelAccessMode: "catalog" + })) + }); + } else if (resourceType === "ssh") { + const effectiveMode = isNative + ? "native" + : standardDaemonLocation; + const portVal = + sshDaemonPortForm.getValues().authDaemonPort; + const effectivePort = + !isNative && + standardDaemonLocation === "remote" && + pamMode === "push" && + portVal + ? Number(portVal) + : undefined; + + Object.assign(payload, { + pamMode, + authDaemonMode: effectiveMode, + authDaemonPort: effectivePort || undefined + }); + } } else { const tcpUdpData = tcpUdpForm.getValues(); Object.assign(payload, { @@ -529,7 +553,11 @@ export default function Page() { const newNiceId = res.data.data.niceId; setNiceId(newNiceId); - if (resourceType === "http") { + if (resourceType === "inference") { + router.push( + `/${orgId}/settings/resources/public/${newNiceId}/general` + ); + } else if (resourceType === "http") { if (targets.length > 0) { try { for (const target of targets) { @@ -754,16 +782,17 @@ export default function Page() { let typeLabels: Partial> = { http: "HTTP", + inference: t("createInternalResourceDialogModeInference"), tcp: "TCP", udp: "UDP" }; if (enterpriseModesAllowed) { - typeLabels = { + typeLabels = { ...typeLabels, ssh: "SSH", rdp: "RDP", - vnc: "VNC", + vnc: "VNC" }; } @@ -1376,6 +1405,71 @@ export default function Page() { /> )} + {resourceType === "inference" && ( + + + + {t("aiResourceProviders")} + + + {t( + "aiResourceProvidersDescription" + )} + + + + + + +
+ + { + setSelectedProviders( + providers + ); + if ( + providers.length > + 0 + ) { + setShowProvidersError( + false + ); + } + }} + /> +

+ {t( + "aiResourceProvidersHelp" + )} +

+ {showProvidersError && ( +

+ {t( + "aiResourceProvidersRequired" + )} +

+ )} +
+
+
+
+
+
+ )} +
diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index 09f451a69..48d7b4486 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -312,6 +312,12 @@ export default function PublicResourcesTable({ { value: "vnc", label: t("vncTitle") + }, + { + value: "inference", + label: t( + "createInternalResourceDialogModeInference" + ) } ]} selectedValue={ @@ -334,7 +340,11 @@ export default function PublicResourcesTable({ ? resourceRow.ssl ? "HTTPS" : "HTTP" - : resourceRow.mode?.toUpperCase()} + : resourceRow.mode === "inference" + ? t( + "createInternalResourceDialogModeInference" + ) + : resourceRow.mode?.toUpperCase()} ); } @@ -428,7 +438,7 @@ export default function PublicResourcesTable({ const resourceRow = row.original; if ( - !["http", "ssh", "rdp", "vnc"].includes( + !["http", "ssh", "rdp", "vnc", "inference"].includes( resourceRow.mode || "" ) ) { @@ -894,7 +904,9 @@ function ResourceEnabledForm({ resource, onToggleResourceEnabled }: ResourceEnabledFormProps) { - const enabled = ["http", "ssh", "rdp", "vnc"].includes(resource.mode || "") + const enabled = ["http", "ssh", "rdp", "vnc", "inference"].includes( + resource.mode || "" + ) ? !!resource.domainId && resource.enabled : resource.enabled; const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled); @@ -912,7 +924,7 @@ function ResourceEnabledForm({ */} - {["http", "ssh", "rdp", "vnc"].includes(resource.mode) ? ( + {isDomainResource ? ( <> URL @@ -94,33 +100,39 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { ? resource.ssl ? "HTTPS" : "HTTP" - : resource.mode?.toUpperCase()} + : resource.mode === "inference" + ? t( + "createInternalResourceDialogModeInference" + ) + : resource.mode?.toUpperCase()} )} - - - {t("authentication")} - - - {authInfo.password || - authInfo.pincode || - authInfo.sso || - authInfo.whitelist || - authInfo.headerAuth ? ( -
- - {t("protected")} -
- ) : ( -
- - {t("notProtected")} -
- )} -
-
+ {showAuth && ( + + + {t("authentication")} + + + {authInfo.password || + authInfo.pincode || + authInfo.sso || + authInfo.whitelist || + authInfo.headerAuth ? ( +
+ + {t("protected")} +
+ ) : ( +
+ + {t("notProtected")} +
+ )} +
+
+ )} ) : ( <> @@ -138,7 +150,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {t("port")} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 27cf1f7db..9a0cb2403 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1300,6 +1300,26 @@ export const resourceQueries = { return res.data.data.providers; } }), + resourceAiProviders: ({ resourceId }: { resourceId: number }) => + queryOptions({ + queryKey: ["RESOURCES", resourceId, "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; + }>; + }> + >(`/resource/${resourceId}/ai-providers`, { + signal + }); + return res.data.data.providers; + } + }), resourceTargets: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "TARGETS"] as const, From 83e20c2dfa3679b26bd21cb4c3b57d18b949734d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 4 Aug 2026 17:36:00 -0400 Subject: [PATCH 205/494] adjust placeholder ui for rules and targets --- .../public/ProxyResourceTargetsForm.tsx | 1 + .../PolicyAccessRulesTable.tsx | 1 + src/components/ui/data-table-empty-state.tsx | 30 ++++++++++++++----- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx index db3bec315..bc2f985e5 100644 --- a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx +++ b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx @@ -930,6 +930,7 @@ export const ProxyResourceTargetsForm = forwardRef< colSpan={columns.length} message={emptyMessage ?? t("targetNoOne")} action={addTargetButton} + compact /> )} diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx index 2ff2e1915..e47395c4e 100644 --- a/src/components/resource-policy/PolicyAccessRulesTable.tsx +++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx @@ -961,6 +961,7 @@ export function PolicyAccessRulesTable({ colSpan={columns.length} message={t("rulesNoOne")} action={emptyStateAction} + compact /> )} diff --git a/src/components/ui/data-table-empty-state.tsx b/src/components/ui/data-table-empty-state.tsx index e734f7726..b510029a6 100644 --- a/src/components/ui/data-table-empty-state.tsx +++ b/src/components/ui/data-table-empty-state.tsx @@ -5,34 +5,48 @@ import { useTranslations } from "next-intl"; import { type ReactNode } from "react"; const PLACEHOLDER_ROW_COUNT = 5; +const COMPACT_PLACEHOLDER_ROW_COUNT = 2; type DataTableEmptyStateProps = { colSpan: number; action?: ReactNode; message?: string; + compact?: boolean; }; export function DataTableEmptyState({ colSpan, action, - message + message, + compact = false }: DataTableEmptyStateProps) { const t = useTranslations(); + const placeholderRows = compact + ? COMPACT_PLACEHOLDER_ROW_COUNT + : PLACEHOLDER_ROW_COUNT; + const minHeightClass = compact ? "min-h-[4.5rem]" : "min-h-[11rem]"; + return ( -
+
- {Array.from({ length: PLACEHOLDER_ROW_COUNT }).map( - (_, i) => ( -
- ) - )} + {Array.from({ length: placeholderRows }).map((_, i) => ( +
+ ))}
-
+

{message ?? t("noResults")}

From 835a30cffe95ff29b9b41bf27b19bc9b74017fb7 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 4 Aug 2026 17:44:34 -0400 Subject: [PATCH 206/494] Show the cert status of the namespace domains properly --- .../certificates/getBatchedCertificates.ts | 32 ++++++++++++++++--- src/hooks/useCertificate.ts | 2 +- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/server/private/routers/certificates/getBatchedCertificates.ts b/server/private/routers/certificates/getBatchedCertificates.ts index 1c390b7dd..2ab5fd288 100644 --- a/server/private/routers/certificates/getBatchedCertificates.ts +++ b/server/private/routers/certificates/getBatchedCertificates.ts @@ -10,12 +10,12 @@ * * This file is not licensed under the AGPLv3. */ -import { certificates, db, domains, orgDomains } from "@server/db"; +import { certificates, db, domainNamespaces, domains, orgDomains } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { type GetBatchedCertificateResponse } from "@server/routers/certificates/types"; import HttpCode from "@server/types/HttpCode"; -import { and, eq, inArray, or } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, or } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -63,14 +63,28 @@ async function query(orgId: string, domainList: string[]) { }) .from(certificates) .innerJoin(domains, eq(certificates.domainId, domains.domainId)) - .innerJoin( + .leftJoin( orgDomains, and( eq(domains.domainId, orgDomains.domainId), eq(orgDomains.orgId, orgId) ) ) - .where(and(inArray(certificates.domain, domainList))); + .leftJoin( + domainNamespaces, + eq(domains.domainId, domainNamespaces.domainId) + ) + .where( + and( + inArray(certificates.domain, domainList), + // Namespace domains are shared across all orgs, so they skip + // the org-ownership check (mirrors verifyCertificateAccess). + or( + isNotNull(orgDomains.orgId), + isNotNull(domainNamespaces.domainNamespaceId) + ) + ) + ); // All non resolved domain certificates might be `ns` or `wildcard`, // which means exact domain certificates do not exist @@ -110,19 +124,27 @@ async function query(orgId: string, domainList: string[]) { }) .from(certificates) .innerJoin(domains, eq(certificates.domainId, domains.domainId)) - .innerJoin( + .leftJoin( orgDomains, and( eq(domains.domainId, orgDomains.domainId), eq(orgDomains.orgId, orgId) ) ) + .leftJoin( + domainNamespaces, + eq(domains.domainId, domainNamespaces.domainId) + ) .where( and( eq(certificates.wildcard, true), or( inArray(certificates.domain, [...domainLevelDownSet]), inArray(certificates.domain, [...wildcardDomainSet]) + ), + or( + isNotNull(orgDomains.orgId), + isNotNull(domainNamespaces.domainNamespaceId) ) ) ); diff --git a/src/hooks/useCertificate.ts b/src/hooks/useCertificate.ts index 1a4ff46f4..9afbb766e 100644 --- a/src/hooks/useCertificate.ts +++ b/src/hooks/useCertificate.ts @@ -111,7 +111,7 @@ export function useCertificate({ let certError: string | null = null; if (restartCert.isError) { certError = "Failed to restart"; - } else if (isError || initialCertValue === null) { + } else if (isError || (!isLoading && data === null)) { // Null value means failed to get the certificate certError = "Failed"; } From 425a99e5eef1395831a6d1cf4887a3674806a98b Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 5 Aug 2026 09:59:41 -0400 Subject: [PATCH 207/494] Show the auth settings for testing --- src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index 1c786be19..302650bf2 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -106,7 +106,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { } ]; - if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { + if (["http", "ssh", "rdp", "vnc", "inference"].includes(resource.mode)) { navItems.push( { title: t("authentication"), From 7afddb5eb58e831b4f399650d2d07f3f504d1519 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 5 Aug 2026 10:00:36 -0400 Subject: [PATCH 208/494] Clean logging --- server/routers/aiGateway/chatCompletions.ts | 2 +- server/routers/olm/handleOlmRegisterMessage.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/routers/aiGateway/chatCompletions.ts b/server/routers/aiGateway/chatCompletions.ts index 41fd1f4e3..f5de5cc75 100644 --- a/server/routers/aiGateway/chatCompletions.ts +++ b/server/routers/aiGateway/chatCompletions.ts @@ -427,7 +427,7 @@ export async function chatCompletions( .json({ error: { message: "Missing Host header" } }); } - logger.info(`++++++++++++++AI gateway request for host: ${host}`); + logger.info(`AI gateway request for host: ${host}`); const target = await resolveTarget(host); if (!target) { diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 5cd38dabe..c5cc7cc7c 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -489,9 +489,9 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { jitMode ); - logger.info( - `+++++++++++++++++++++++++++++++ ExitNode Aliases: ${exitNodeAliases}` - ); + // logger.info( + // `ExitNode Aliases: ${exitNodeAliases}` + // ); // Return connect message with all site configurations return { From 3d062389e9c79a479a5862b62cc2d1f2a4938712 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 5 Aug 2026 10:14:40 -0400 Subject: [PATCH 209/494] update oss with gateway traefik routers --- server/lib/traefik/getTraefikConfig.ts | 177 ++++++++++++++++++++++--- 1 file changed, 162 insertions(+), 15 deletions(-) diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index ee972ea68..f53c501ea 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -1,4 +1,11 @@ -import { db, targetHealthCheck, domains, aiProviders, resourceAiProviders } from "@server/db"; +import { + db, + targetHealthCheck, + domains, + aiProviders, + resourceAiProviders, + siteResources +} from "@server/db"; import { and, eq, @@ -222,24 +229,25 @@ export async function getTraefikConfig( subdomain: resources.subdomain, domainId: resources.domainId, enabled: resources.enabled, + wildcard: resources.wildcard, domainCertResolver: domains.certResolver, preferWildcardCert: domains.preferWildcardCert }) .from(resources) - .innerJoin( - resourceAiProviders, - eq(resources.resourceId, resourceAiProviders.resourceId) - ) - .innerJoin( - aiProviders, - eq(resourceAiProviders.providerId, aiProviders.providerId) - ) + // .innerJoin( + // resourceAiProviders, + // eq(resources.resourceId, resourceAiProviders.resourceId) + // ) + // .innerJoin( + // aiProviders, + // eq(resourceAiProviders.providerId, aiProviders.providerId) + // ) .leftJoin(domains, eq(domains.domainId, resources.domainId)) .where( and( eq(resources.mode, "inference"), - eq(resources.enabled, true), - eq(aiProviders.enabled, true) + eq(resources.enabled, true) + // eq(aiProviders.enabled, true) ) ); @@ -709,6 +717,23 @@ export async function getTraefikConfig( } if (aiGatewayUrl) { + // The AI gateway may live on a different host than the inference + // resource itself (e.g. a remote exit node forwarding to the + // central dashboard over a tunnel). passHostHeader would forward + // the resource's own Host, which that external host won't + // recognize, so we pin the Host header to the gateway's own host + // and smuggle the original resource host through in "p-host" + // instead. + let aiGatewayHost: string | undefined; + try { + aiGatewayHost = new URL(aiGatewayUrl).host; + } catch { + aiGatewayHost = undefined; + } + + // Public inference resources: same TLS/cert-resolver handling as + // plain http-mode resources, but the service points at the AI + // gateway instead of any real backend targets. for (const ir of inferenceResources) { if (!ir.enabled) continue; if (!ir.domainId || !ir.fullDomain) continue; @@ -720,7 +745,14 @@ export async function getTraefikConfig( const irKey = `inference-r${ir.resourceId}`; const routerName = `${irKey}-router`; const serviceName = `${irKey}-service`; - const rule = `Host(\`${fullDomain}\`)`; + + let rule: string; + if (ir.wildcard && fullDomain.startsWith("*.")) { + const escaped = fullDomain.slice(2).replace(/\./g, "\\."); + rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`; + } else { + rule = `Host(\`${fullDomain}\`)`; + } const domainParts = fullDomain.split("."); let wildCard; @@ -751,13 +783,39 @@ export async function getTraefikConfig( ...(preferWildcard ? { domains: [{ main: wildCard }] } : {}) }; + const irHeadersMiddlewareName = `${irKey}-headers-middleware`; + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + config_output.http.middlewares[irHeadersMiddlewareName] = { + headers: { + customRequestHeaders: { + ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), + "p-host": fullDomain + } + } + }; + const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares = [ badgerMiddlewareName, + irHeadersMiddlewareName, ...additionalMiddlewares ]; + if (ir.ssl) { + config_output.http.routers[routerName + "-redirect"] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [redirectHttpsMiddlewareName], + service: serviceName, + rule, + priority: 100 + }; + } + config_output.http.routers[routerName] = { entryPoints: [ ir.ssl @@ -771,7 +829,84 @@ export async function getTraefikConfig( ...(ir.ssl ? { tls } : {}) }; - if (ir.ssl) { + config_output.http.services[serviceName] = { + loadBalancer: { + servers: [{ url: aiGatewayUrl }] + } + }; + } + + // Private (siteResource) inference resources: routed by their alias + // instead of a public fullDomain, and deliberately WITHOUT the + // badger middleware - no per-user auth/policy stack exists for + // siteResources today, so gating here is reachability-only for now. + const siteResourcesInference = await db + .selectDistinct({ + siteResourceId: siteResources.siteResourceId, + alias: siteResources.alias, + ssl: siteResources.ssl, + enabled: siteResources.enabled + }) + .from(siteResources) + .where( + and( + eq(siteResources.mode, "inference"), + eq(siteResources.enabled, true), + isNotNull(siteResources.alias) + ) + ); + + for (const sr of siteResourcesInference) { + if (!sr.enabled || !sr.alias) continue; + + if (!config_output.http.routers) config_output.http.routers = {}; + if (!config_output.http.services) config_output.http.services = {}; + + const alias = sr.alias; + const srKey = `inference-sr${sr.siteResourceId}`; + const routerName = `${srKey}-router`; + const serviceName = `${srKey}-service`; + const rule = `Host(\`${alias}\`)`; + + const domainParts = alias.split("."); + const wildCard = + domainParts.length <= 2 + ? `*.${domainParts.join(".")}` + : `*.${domainParts.slice(1).join(".")}`; + + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; + + const tls = { + certResolver: globalDefaultResolver, + ...(globalDefaultPreferWildcard + ? { domains: [{ main: wildCard }] } + : {}) + }; + + const srHeadersMiddlewareName = `${srKey}-headers-middleware`; + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + config_output.http.middlewares[srHeadersMiddlewareName] = { + headers: { + customRequestHeaders: { + ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), + "p-host": alias + } + } + }; + + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; + const routerMiddlewares = [ + srHeadersMiddlewareName, + ...additionalMiddlewares + ]; + + if (sr.ssl) { config_output.http.routers[routerName + "-redirect"] = { entryPoints: [ config.getRawConfig().traefik.http_entrypoint @@ -783,10 +918,22 @@ export async function getTraefikConfig( }; } + config_output.http.routers[routerName] = { + entryPoints: [ + sr.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint + ], + middlewares: routerMiddlewares, + service: serviceName, + rule, + priority: 100, + ...(sr.ssl ? { tls } : {}) + }; + config_output.http.services[serviceName] = { loadBalancer: { - servers: [{ url: aiGatewayUrl }], - passHostHeader: true + servers: [{ url: aiGatewayUrl }] } }; } From bc80f91a45e9d9e1c8fd1166cc91349e31da9263 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 5 Aug 2026 10:24:26 -0400 Subject: [PATCH 210/494] gateway endpoint mvp --- server/routers/aiGateway/chatCompletions.ts | 251 ++++++++++++-------- 1 file changed, 157 insertions(+), 94 deletions(-) diff --git a/server/routers/aiGateway/chatCompletions.ts b/server/routers/aiGateway/chatCompletions.ts index f5de5cc75..2f1bfc451 100644 --- a/server/routers/aiGateway/chatCompletions.ts +++ b/server/routers/aiGateway/chatCompletions.ts @@ -1,5 +1,5 @@ import { Request, Response } from "express"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { AiProvider, aiModels, @@ -94,10 +94,12 @@ type ProviderAttachment = { type ResolvedTarget = { resourceId: number | null; + siteResourceId: number | null; orgId: string | null; attachments: ProviderAttachment[]; - // model IDs on this resource's allowlist (resource-wide) - allowedModelIds: number[]; + // Model IDs on the resource allowlist that belong to allowlist-mode + // providers. Empty when no attached provider uses allowlist mode. + allowlistedModelIds: Set; }; type ProviderSelection = @@ -192,6 +194,9 @@ async function resolveRequestUser( } async function resolveTarget(host: string): Promise { + // TODO: eventually we need to know if it's a private or public resource + // and not just simply check the fullDomain in case there is a private resource with the same fullDomain + const [resourceRow] = await db .select({ resourceId: resources.resourceId, @@ -229,26 +234,40 @@ async function resolveTarget(host: string): Promise { return null; } - const hasAllowlist = attachmentRows.some( - (a) => a.modelAccessMode === "allowlist" - ); - let allowedModelIds: number[] = []; - if (hasAllowlist) { + const attachments: ProviderAttachment[] = attachmentRows.map((a) => ({ + provider: a.provider, + modelAccessMode: a.modelAccessMode as ModelAccessMode + })); + + const allowlistProviderIds = attachments + .filter((a) => a.modelAccessMode === "allowlist") + .map((a) => a.provider.providerId); + const allowlistedModelIds = new Set(); + if (allowlistProviderIds.length > 0) { const restrictions = await db .select({ modelId: resourceAiModels.modelId }) .from(resourceAiModels) - .where(eq(resourceAiModels.resourceId, resourceRow.resourceId)); - allowedModelIds = restrictions.map((r) => r.modelId); + .innerJoin( + aiModels, + eq(resourceAiModels.modelId, aiModels.modelId) + ) + .where( + and( + eq(resourceAiModels.resourceId, resourceRow.resourceId), + inArray(aiModels.providerId, allowlistProviderIds) + ) + ); + for (const row of restrictions) { + allowlistedModelIds.add(row.modelId); + } } return { resourceId: resourceRow.resourceId, + siteResourceId: null, orgId: resourceRow.orgId, - attachments: attachmentRows.map((a) => ({ - provider: a.provider, - modelAccessMode: a.modelAccessMode as ModelAccessMode - })), - allowedModelIds + attachments, + allowlistedModelIds }; } @@ -292,74 +311,52 @@ async function resolveTarget(host: string): Promise { return null; } - const hasAllowlist = attachmentRows.some( - (a) => a.modelAccessMode === "allowlist" - ); - let allowedModelIds: number[] = []; - if (hasAllowlist) { + const attachments: ProviderAttachment[] = attachmentRows.map((a) => ({ + provider: a.provider, + modelAccessMode: a.modelAccessMode as ModelAccessMode + })); + + const allowlistProviderIds = attachments + .filter((a) => a.modelAccessMode === "allowlist") + .map((a) => a.provider.providerId); + const allowlistedModelIds = new Set(); + if (allowlistProviderIds.length > 0) { const restrictions = await db .select({ modelId: siteResourceAiModels.modelId }) .from(siteResourceAiModels) + .innerJoin( + aiModels, + eq(siteResourceAiModels.modelId, aiModels.modelId) + ) .where( - eq( - siteResourceAiModels.siteResourceId, - siteResourceRow.siteResourceId + and( + eq( + siteResourceAiModels.siteResourceId, + siteResourceRow.siteResourceId + ), + inArray(aiModels.providerId, allowlistProviderIds) ) ); - allowedModelIds = restrictions.map((r) => r.modelId); + for (const row of restrictions) { + allowlistedModelIds.add(row.modelId); + } } return { - // siteResources have no per-user auth/policy stack today (see - // the routing comment in getTraefikConfig.ts), so there's no - // resource access token scope to validate a user token against. resourceId: null, + siteResourceId: siteResourceRow.siteResourceId, orgId: siteResourceRow.orgId, - attachments: attachmentRows.map((a) => ({ - provider: a.provider, - modelAccessMode: a.modelAccessMode as ModelAccessMode - })), - allowedModelIds + attachments, + allowlistedModelIds }; } return null; } -async function providerMatchesModel( - attachment: ProviderAttachment, - requestedModel: string, - allowedModelIds: number[] -): Promise { - const [matchedModel] = await db - .select({ - modelId: aiModels.modelId, - enabled: aiModels.enabled - }) - .from(aiModels) - .where( - and( - eq(aiModels.providerId, attachment.provider.providerId), - eq(aiModels.modelKey, requestedModel) - ) - ) - .limit(1); - - if (!matchedModel) { - return false; - } - - if (attachment.modelAccessMode === "catalog") { - return matchedModel.enabled; - } - - // allowlist - return allowedModelIds.includes(matchedModel.modelId); -} - async function selectProvider( attachments: ProviderAttachment[], - allowedModelIds: number[], + allowlistedModelIds: Set, requestedModel: string | undefined ): Promise { if (!requestedModel) { @@ -370,21 +367,55 @@ async function selectProvider( }; } - const candidates: ProviderAttachment[] = []; - for (const attachment of attachments) { - if ( - await providerMatchesModel( - attachment, - requestedModel, - allowedModelIds + const providerById = new Map( + attachments.map((a) => [a.provider.providerId, a]) + ); + const providerIds = [...providerById.keys()]; + if (providerIds.length === 0) { + return { + ok: false, + status: HttpCode.FORBIDDEN, + message: `Model "${requestedModel}" is not permitted on this resource` + }; + } + + // One lookup for the requested model key across all attached providers. + const matchingModels = await db + .select({ + modelId: aiModels.modelId, + providerId: aiModels.providerId, + enabled: aiModels.enabled + }) + .from(aiModels) + .where( + and( + inArray(aiModels.providerId, providerIds), + eq(aiModels.modelKey, requestedModel) ) - ) { - candidates.push(attachment); + ); + + const candidates: AiProvider[] = []; + for (const model of matchingModels) { + const attachment = providerById.get(model.providerId); + if (!attachment) { + continue; + } + + if (attachment.modelAccessMode === "catalog") { + if (model.enabled) { + candidates.push(attachment.provider); + } + continue; + } + + // allowlist: only models explicitly attached to the resource + if (allowlistedModelIds.has(model.modelId)) { + candidates.push(attachment.provider); } } if (candidates.length === 1) { - return { ok: true, provider: candidates[0].provider }; + return { ok: true, provider: candidates[0] }; } if (candidates.length > 1) { @@ -402,15 +433,6 @@ async function selectProvider( }; } -// Generic OpenAI-wire-compatible passthrough. Anthropic's native API uses a -// different path/schema; everything else here is OpenAI-compatible today. -function getCompletionsPath(type: AiProviderType): string { - if (type === "anthropic") { - return "/v1/messages"; - } - return "/chat/completions"; -} - export async function chatCompletions( req: Request, res: Response @@ -438,7 +460,9 @@ export async function chatCompletions( }); } - const { attachments, allowedModelIds, resourceId, orgId } = target; + const { attachments, allowlistedModelIds, resourceId, orgId } = target; + + logger.debug("+++++ gateway target: ", target); // Best-effort identity resolution - not yet enforced, but lets us // start making per-user access decisions (e.g. model/role-based @@ -455,7 +479,7 @@ export async function chatCompletions( const selection = await selectProvider( attachments, - allowedModelIds, + allowlistedModelIds, requestedModel ); if (!selection.ok) { @@ -490,16 +514,35 @@ export async function chatCompletions( }); } - const targetUrl = `${upstreamUrl.replace(/\/$/, "")}${getCompletionsPath( - provider.type as AiProviderType - )}`; + const targetUrl = `${upstreamUrl.replace(/\/$/, "")}`; - const headers: Record = { - "Content-Type": "application/json" - }; - if (authType === "bearer") { - headers["Authorization"] = `Bearer ${apiKey}`; + // Drop hop-by-hop / proxy-only headers. Forwarding Host especially + // breaks Node fetch (TLS/SNI targets the upstream URL while Host + // still says localhost). + const skipHeaders = new Set([ + "p-host", + "host", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "content-length", + "accept-encoding" + ]); + + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (skipHeaders.has(key.toLowerCase()) || value === undefined) { + continue; + } + headers[key] = Array.isArray(value) ? value.join(", ") : value; } + // TODO: temporary hardcoded auth for testing; restore bearer from authType + headers["x-api-key"] = apiKey; // No dedicated per-request TLS agent is wired up (no extra deps for // this v1 gateway) - toggle the process-wide Node TLS check instead. @@ -510,13 +553,33 @@ export async function chatCompletions( process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; } + const body = JSON.stringify(req.body); + + logger.debug("AI gateway upstream request", { + url: targetUrl, + method: "POST", + headers, + body: req.body + }); + let upstreamRes: globalThis.Response; try { upstreamRes = await fetch(targetUrl, { method: "POST", headers, - body: JSON.stringify(req.body) + body }); + } catch (fetchError) { + logger.error({ + message: "AI gateway upstream fetch failed", + url: targetUrl, + error: fetchError, + cause: + fetchError instanceof Error + ? (fetchError as Error & { cause?: unknown }).cause + : undefined + }); + throw fetchError; } finally { if (provider.skipTlsVerification) { if (restoreTlsReject === undefined) { From 346a0bdc98eb0207d16698e3a7ce8c4a8047339d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 5 Aug 2026 10:31:14 -0400 Subject: [PATCH 211/494] Resolve syntax issue --- server/routers/aiProvider/listAiProviders.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/aiProvider/listAiProviders.ts b/server/routers/aiProvider/listAiProviders.ts index 7ec89df51..eb4477bc2 100644 --- a/server/routers/aiProvider/listAiProviders.ts +++ b/server/routers/aiProvider/listAiProviders.ts @@ -131,7 +131,7 @@ export async function listAiProviders( return response(res, { data: { - providers: rows.map(toPublicAiProvider), + providers: rows.map((row) => toPublicAiProvider(row)), pagination: { total: totalCount, pageSize, From e1dd8965dc9f3f8aa1fe617680b2ae72df627bef Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 5 Aug 2026 11:31:12 -0400 Subject: [PATCH 212/494] Remove extra checks --- server/db/sqlite/schema/schema.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 2ae941895..8af639238 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1693,15 +1693,6 @@ export const aiBudgets = sqliteTable( updatedAt: integer("updatedAt").notNull() }, (t) => [ - check( - "ai_budget_one_scope", - sql`( - (CASE WHEN ${t.providerId} IS NOT NULL THEN 1 ELSE 0 END) + - (CASE WHEN ${t.modelId} IS NOT NULL THEN 1 ELSE 0 END) + - (CASE WHEN ${t.resourceId} IS NOT NULL THEN 1 ELSE 0 END) + - (CASE WHEN ${t.siteResourceId} IS NOT NULL THEN 1 ELSE 0 END) - ) = 1` - ), unique("ai_budget_provider_uniq").on(t.providerId), unique("ai_budget_model_uniq").on(t.modelId), unique("ai_budget_resource_uniq").on(t.resourceId), From c673dce4844b414d332734cf109530c05863ecf5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 5 Aug 2026 13:34:45 -0400 Subject: [PATCH 213/494] pin docker base image --- Dockerfile | 8 ++++---- Dockerfile.dev | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9af37f89c..ecd2daa28 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -# FROM node:24-slim AS base -FROM public.ecr.aws/docker/library/node:24-slim AS base +# FROM node:24.18.1-slim AS base +FROM public.ecr.aws/docker/library/node:24.18.1-slim AS base WORKDIR /app @@ -32,8 +32,8 @@ FROM base AS builder RUN npm ci --omit=dev -# FROM node:24-slim AS runner -FROM public.ecr.aws/docker/library/node:24-slim AS runner +# FROM node:24.18.1-slim AS runner +FROM public.ecr.aws/docker/library/node:24.18.1-slim AS runner WORKDIR /app diff --git a/Dockerfile.dev b/Dockerfile.dev index 3e5965fc1..1372fe33a 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:24-alpine +FROM node:24.18.1-alpine WORKDIR /app From 2e9bd501721d8d4fb7a6cc62ad6cae6a38ad2455 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 5 Aug 2026 15:17:50 -0400 Subject: [PATCH 214/494] add provider specific auth modes --- messages/en-US.json | 6 +- server/db/pg/schema/schema.ts | 10 ++- server/db/sqlite/schema/schema.ts | 10 ++- server/lib/aiProviderDefaults.ts | 74 ++++++++++++++++--- server/routers/aiGateway/chatCompletions.ts | 15 +--- server/routers/aiProvider/createAiProvider.ts | 20 ++--- server/routers/aiProvider/types.ts | 19 +---- server/routers/aiProvider/updateAiProvider.ts | 17 ++--- server/routers/aiProvider/validation.ts | 6 +- .../[providerId]/authentication/page.tsx | 29 +++++++- .../[providerId]/network/page.tsx | 9 ++- .../settings/ai-providers/create/page.tsx | 20 +++++ src/lib/aiProviderFormSchema.ts | 21 +++--- 13 files changed, 178 insertions(+), 78 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index fa9202b2f..257c56c42 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1652,7 +1652,7 @@ "aiProviderNetworkSettings": "Network Settings", "aiProviderNetworkSettingsDescription": "Choose how traffic reaches this provider", "aiProviderAuthSettings": "Authentication", - "aiProviderAuthSettingsDescription": "Credentials used for both upstream URL and Pangolin target routing", + "aiProviderAuthSettingsDescription": "Configure how this provider authenticates requests to its upstream URL", "aiProviderType": "Provider Type", "aiProviderTypeSearch": "Search providers...", "aiProviderTypeNotFound": "No provider type found", @@ -1683,6 +1683,10 @@ "aiProviderApiKeyLastChars": "API Key", "aiProviderAuthType": "Auth Type", "aiProviderAuthTypeBearer": "Bearer", + "aiProviderAuthTypeXApiKey": "x-api-key", + "aiProviderAuthTypeXGoogApiKey": "x-goog-api-key", + "aiProviderAuthTypeHec": "Splunk HEC", + "aiProviderAuthTypeCfAigAuthorization": "Cloudflare AI Gateway", "aiProviderAuthTypeDescription": "How the upstream API authenticates requests", "aiProviderRoutingMode": "Routing Mode", "aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to HTTP targets on your sites", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 868b2a121..7883345da 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1644,7 +1644,15 @@ export const aiProviders = pgTable("aiProviders", { upstreamUrl: text("upstreamUrl"), apiKey: text("apiKey"), apiKeyLastChars: varchar("apiKeyLastChars"), - authType: varchar("authType").$type<"bearer">(), + authType: varchar("authType") + .$type< + | "bearer" + | "x-api-key" + | "x-goog-api-key" + | "hec" + | "cf-aig-authorization" + >() + .notNull(), routingMode: varchar("routingMode") .$type<"url" | "target">() .notNull() diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 8af639238..e586c4e02 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1626,7 +1626,15 @@ export const aiProviders = sqliteTable("aiProviders", { upstreamUrl: text("upstreamUrl"), apiKey: text("apiKey"), apiKeyLastChars: text("apiKeyLastChars"), - authType: text("authType").$type<"bearer">(), + authType: text("authType") + .$type< + | "bearer" + | "x-api-key" + | "x-goog-api-key" + | "hec" + | "cf-aig-authorization" + >() + .notNull(), routingMode: text("routingMode") .$type<"url" | "target">() .notNull() diff --git a/server/lib/aiProviderDefaults.ts b/server/lib/aiProviderDefaults.ts index 6cfb816b7..d803e7613 100644 --- a/server/lib/aiProviderDefaults.ts +++ b/server/lib/aiProviderDefaults.ts @@ -9,7 +9,15 @@ export type AiProviderType = | "vercelAiGateway" | "custom"; -export type AiProviderAuthType = "bearer"; +export const AI_PROVIDER_AUTH_TYPES = [ + "bearer", + "x-api-key", + "x-goog-api-key", + "hec", + "cf-aig-authorization" +] as const; + +export type AiProviderAuthType = (typeof AI_PROVIDER_AUTH_TYPES)[number]; export type AiBudgetUnit = "usd" | "tokens"; export type AiProviderRoutingMode = "url" | "target"; @@ -28,11 +36,11 @@ export const AI_PROVIDER_DEFAULTS: Record< }, anthropic: { upstreamUrl: "https://api.anthropic.com", - authType: "bearer" + authType: "x-api-key" }, googleGemini: { upstreamUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", - authType: "bearer" + authType: "x-goog-api-key" }, vertexAi: { upstreamUrl: null, @@ -56,6 +64,13 @@ export const AI_PROVIDER_DEFAULTS: Record< } }; +const CONFLICTING_AUTH_HEADERS = [ + "authorization", + "x-api-key", + "x-goog-api-key", + "cf-aig-authorization" +] as const; + export function providerRequiresUpstreamUrl( type: AiProviderType, routingMode: AiProviderRoutingMode = "url" @@ -69,17 +84,18 @@ export function providerRequiresUpstreamUrl( return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null; } -export function resolveAiProviderConfig(input: { +export function resolveAiProviderCreateFields(input: { type: AiProviderType; - upstreamUrl: string | null; - authType: AiProviderAuthType | null; + upstreamUrl?: string | null; + authType?: AiProviderAuthType | null; routingMode?: AiProviderRoutingMode | null; }): { upstreamUrl: string | null; - authType: AiProviderAuthType | null; + authType: AiProviderAuthType; routingMode: AiProviderRoutingMode; } { - const routingMode = input.routingMode ?? "url"; + const routingMode = + input.type === "custom" ? (input.routingMode ?? "url") : "url"; if (routingMode === "target") { return { @@ -91,8 +107,8 @@ export function resolveAiProviderConfig(input: { if (input.type === "custom") { return { - upstreamUrl: input.upstreamUrl, - authType: input.authType, + upstreamUrl: input.upstreamUrl ?? null, + authType: input.authType ?? "bearer", routingMode }; } @@ -100,7 +116,43 @@ export function resolveAiProviderConfig(input: { const defaults = AI_PROVIDER_DEFAULTS[input.type]; return { upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl, - authType: input.authType ?? defaults.authType, + authType: defaults.authType, routingMode }; } + +/** + * Strip inbound client auth headers, then set the provider auth header + * for the given authType. + */ +export function applyAiProviderAuthHeaders( + headers: Record, + authType: AiProviderAuthType, + apiKey: string +): void { + for (const name of CONFLICTING_AUTH_HEADERS) { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === name) { + delete headers[key]; + } + } + } + + switch (authType) { + case "bearer": + headers["Authorization"] = `Bearer ${apiKey}`; + break; + case "x-api-key": + headers["x-api-key"] = apiKey; + break; + case "x-goog-api-key": + headers["x-goog-api-key"] = apiKey; + break; + case "hec": + headers["Authorization"] = `Splunk ${apiKey}`; + break; + case "cf-aig-authorization": + headers["cf-aig-authorization"] = `Bearer ${apiKey}`; + break; + } +} diff --git a/server/routers/aiGateway/chatCompletions.ts b/server/routers/aiGateway/chatCompletions.ts index 2f1bfc451..e1bb8bbc9 100644 --- a/server/routers/aiGateway/chatCompletions.ts +++ b/server/routers/aiGateway/chatCompletions.ts @@ -19,9 +19,7 @@ import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { AiProviderAuthType, - AiProviderRoutingMode, - AiProviderType, - resolveAiProviderConfig + applyAiProviderAuthHeaders } from "@server/lib/aiProviderDefaults"; import { SESSION_COOKIE_NAME, @@ -499,12 +497,8 @@ export async function chatCompletions( const secret = config.getRawConfig().server.secret!; const apiKey = decrypt(provider.apiKey, secret); - const { upstreamUrl, authType } = resolveAiProviderConfig({ - type: provider.type as AiProviderType, - upstreamUrl: provider.upstreamUrl, - authType: provider.authType as AiProviderAuthType | null, - routingMode: provider.routingMode as AiProviderRoutingMode | null - }); + const upstreamUrl = provider.upstreamUrl; + const authType = provider.authType as AiProviderAuthType; if (!upstreamUrl) { return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ @@ -541,8 +535,7 @@ export async function chatCompletions( } headers[key] = Array.isArray(value) ? value.join(", ") : value; } - // TODO: temporary hardcoded auth for testing; restore bearer from authType - headers["x-api-key"] = apiKey; + applyAiProviderAuthHeaders(headers, authType, apiKey); // No dedicated per-request TLS agent is wired up (no extra deps for // this v1 gateway) - toggle the process-wide Node TLS check instead. diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts index 6fb3f395a..6188601a3 100644 --- a/server/routers/aiProvider/createAiProvider.ts +++ b/server/routers/aiProvider/createAiProvider.ts @@ -9,6 +9,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; +import { resolveAiProviderCreateFields } from "@server/lib/aiProviderDefaults"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import { toPublicAiProvider } from "@server/routers/aiProvider/types"; import { @@ -28,7 +29,7 @@ const bodySchema = z type: aiProviderTypeSchema, upstreamUrl: z.url().optional().nullable(), apiKey: z.string().optional(), - authType: aiAuthTypeSchema.optional().nullable(), + authType: aiAuthTypeSchema.optional(), routingMode: aiRoutingModeSchema.optional(), skipTlsVerification: z.boolean().optional(), enabled: z.boolean().optional() @@ -101,8 +102,12 @@ export async function createAiProvider( const encryptedApiKey = apiKey ? encrypt(apiKey, key) : null; const apiKeyLastChars = apiKey ? apiKey.slice(-4) : null; const now = Date.now(); - const resolvedRoutingMode = - type === "custom" ? (routingMode ?? "url") : "url"; + const resolved = resolveAiProviderCreateFields({ + type, + upstreamUrl, + authType, + routingMode + }); const [provider] = await db .insert(aiProviders) @@ -110,14 +115,11 @@ export async function createAiProvider( orgId, name, type, - upstreamUrl: - resolvedRoutingMode === "target" - ? null - : (upstreamUrl ?? null), + upstreamUrl: resolved.upstreamUrl, apiKey: encryptedApiKey, apiKeyLastChars, - authType: authType ?? null, - routingMode: resolvedRoutingMode, + authType: resolved.authType, + routingMode: resolved.routingMode, skipTlsVerification: skipTlsVerification ?? false, enabled: enabled ?? true, createdAt: now, diff --git a/server/routers/aiProvider/types.ts b/server/routers/aiProvider/types.ts index 3fa966785..7ac492056 100644 --- a/server/routers/aiProvider/types.ts +++ b/server/routers/aiProvider/types.ts @@ -1,11 +1,6 @@ import type { AiModel, AiProvider } from "@server/db"; import type { PaginatedResponse } from "@server/types/Pagination"; -import { - resolveAiProviderConfig, - type AiProviderAuthType, - type AiProviderRoutingMode, - type AiProviderType -} from "@server/lib/aiProviderDefaults"; +import type { AiProviderAuthType } from "@server/lib/aiProviderDefaults"; import { decrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; @@ -13,7 +8,7 @@ export type AiProviderPublic = Omit & { /** Decrypted API key. Only included on get/create/update of a single provider. */ apiKey?: string | null; effectiveUpstreamUrl: string | null; - effectiveAuthType: AiProviderAuthType | null; + effectiveAuthType: AiProviderAuthType; }; export type ListAiProvidersResponse = PaginatedResponse<{ @@ -45,12 +40,6 @@ export function toPublicAiProvider( options?: { includeApiKey?: boolean } ): AiProviderPublic { const { apiKey: encryptedApiKey, ...rest } = provider; - const resolved = resolveAiProviderConfig({ - type: provider.type as AiProviderType, - upstreamUrl: provider.upstreamUrl, - authType: provider.authType as AiProviderAuthType | null, - routingMode: provider.routingMode as AiProviderRoutingMode | null - }); let apiKey: string | null | undefined; if (options?.includeApiKey) { @@ -67,7 +56,7 @@ export function toPublicAiProvider( return { ...rest, ...(options?.includeApiKey ? { apiKey } : {}), - effectiveUpstreamUrl: resolved.upstreamUrl, - effectiveAuthType: resolved.authType + effectiveUpstreamUrl: provider.upstreamUrl, + effectiveAuthType: provider.authType as AiProviderAuthType }; } diff --git a/server/routers/aiProvider/updateAiProvider.ts b/server/routers/aiProvider/updateAiProvider.ts index 35e27fba2..82224c28b 100644 --- a/server/routers/aiProvider/updateAiProvider.ts +++ b/server/routers/aiProvider/updateAiProvider.ts @@ -19,6 +19,7 @@ import { refineProviderUpstreamFields } from "@server/routers/aiProvider/validation"; import type { + AiProviderAuthType, AiProviderRoutingMode, AiProviderType } from "@server/lib/aiProviderDefaults"; @@ -31,7 +32,7 @@ const bodySchema = z.strictObject({ name: z.string().nonempty().optional(), upstreamUrl: z.url().optional().nullable(), apiKey: z.string().optional(), - authType: aiAuthTypeSchema.optional().nullable(), + authType: aiAuthTypeSchema.optional(), routingMode: aiRoutingModeSchema.optional(), skipTlsVerification: z.boolean().optional(), enabled: z.boolean().optional() @@ -116,17 +117,16 @@ export async function updateAiProvider( body.upstreamUrl !== undefined ? body.upstreamUrl : existing.upstreamUrl; - const nextAuthType = + const nextAuthType: AiProviderAuthType = body.authType !== undefined ? body.authType - : (existing.authType ?? - (providerType === "custom" ? "bearer" : null)); + : (existing.authType as AiProviderAuthType); const validation = z .object({ type: aiProviderTypeSchema, upstreamUrl: z.string().nullable().optional(), - authType: aiAuthTypeSchema.nullable().optional(), + authType: aiAuthTypeSchema, routingMode: aiRoutingModeSchema.optional() }) .superRefine((data, ctx) => refineProviderUpstreamFields(data, ctx)) @@ -167,13 +167,6 @@ export async function updateAiProvider( } if (body.authType !== undefined) { updateData.authType = body.authType; - } else if ( - providerType === "custom" && - !existing.authType && - nextAuthType - ) { - // Backfill required authType for custom providers created without one - updateData.authType = nextAuthType; } if (body.apiKey !== undefined) { diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts index a5e2aa962..42476c710 100644 --- a/server/routers/aiProvider/validation.ts +++ b/server/routers/aiProvider/validation.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import { + AI_PROVIDER_AUTH_TYPES, providerRequiresUpstreamUrl, + type AiProviderAuthType, type AiProviderRoutingMode, type AiProviderType } from "@server/lib/aiProviderDefaults"; @@ -17,7 +19,7 @@ export const aiProviderTypeSchema = z.enum([ "custom" ]); -export const aiAuthTypeSchema = z.enum(["bearer"]); +export const aiAuthTypeSchema = z.enum(AI_PROVIDER_AUTH_TYPES); export const aiRoutingModeSchema = z.enum(["url", "target"]); @@ -25,7 +27,7 @@ export function refineProviderUpstreamFields( data: { type: AiProviderType; upstreamUrl?: string | null; - authType?: "bearer" | null; + authType?: AiProviderAuthType | null; routingMode?: AiProviderRoutingMode | null; }, ctx: z.RefinementCtx diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx index 02d1d2142..264e6b252 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx @@ -40,7 +40,10 @@ import { type AiProviderFormValues } from "@app/lib/aiProviderFormSchema"; import { zodResolver } from "@hookform/resolvers/zod"; -import type { AiProviderType } from "@server/lib/aiProviderDefaults"; +import type { + AiProviderAuthType, + AiProviderType +} from "@server/lib/aiProviderDefaults"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; @@ -63,7 +66,7 @@ export default function AiProviderAuthenticationPage() { type: provider.type as AiProviderType, upstreamUrl: provider.upstreamUrl ?? "", apiKey: provider.apiKey ?? "", - authType: (provider.authType as "bearer" | null) ?? "bearer", + authType: (provider.authType as AiProviderAuthType) ?? "bearer", routingMode: (provider.routingMode as "url" | "target") ?? "url", skipTlsVerification: provider.skipTlsVerification, enabled: provider.enabled @@ -91,7 +94,7 @@ export default function AiProviderAuthenticationPage() { type: updated.type as AiProviderType, upstreamUrl: updated.upstreamUrl ?? "", apiKey: updated.apiKey ?? "", - authType: (updated.authType as "bearer" | null) ?? "bearer", + authType: (updated.authType as AiProviderAuthType) ?? "bearer", routingMode: (updated.routingMode as "url" | "target") ?? "url", skipTlsVerification: updated.skipTlsVerification, enabled: updated.enabled @@ -164,6 +167,26 @@ export default function AiProviderAuthenticationPage() { "aiProviderAuthTypeBearer" )} + + {t( + "aiProviderAuthTypeXApiKey" + )} + + + {t( + "aiProviderAuthTypeXGoogApiKey" + )} + + + {t( + "aiProviderAuthTypeHec" + )} + + + {t( + "aiProviderAuthTypeCfAigAuthorization" + )} + diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx index fbe9669cf..270ba3c68 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx @@ -45,7 +45,10 @@ import { } from "@app/lib/aiProviderFormSchema"; import { aiProviderQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; -import type { AiProviderType } from "@server/lib/aiProviderDefaults"; +import type { + AiProviderAuthType, + AiProviderType +} from "@server/lib/aiProviderDefaults"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import { useQuery } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; @@ -72,7 +75,7 @@ export default function AiProviderNetworkPage() { type: provider.type as AiProviderType, upstreamUrl: provider.upstreamUrl ?? "", apiKey: "", - authType: (provider.authType as "bearer" | null) ?? "bearer", + authType: (provider.authType as AiProviderAuthType) ?? "bearer", routingMode: (provider.routingMode as "url" | "target") ?? "url", skipTlsVerification: provider.skipTlsVerification, enabled: provider.enabled @@ -115,7 +118,7 @@ export default function AiProviderNetworkPage() { type: updated.type as AiProviderType, upstreamUrl: updated.upstreamUrl ?? "", apiKey: "", - authType: (updated.authType as "bearer" | null) ?? "bearer", + authType: (updated.authType as AiProviderAuthType) ?? "bearer", routingMode: (updated.routingMode as "url" | "target") ?? "url", skipTlsVerification: updated.skipTlsVerification, enabled: updated.enabled diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index b84462153..5ad4c4ec7 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -527,6 +527,26 @@ export default function CreateAiProviderPage() { "aiProviderAuthTypeBearer" )} + + {t( + "aiProviderAuthTypeXApiKey" + )} + + + {t( + "aiProviderAuthTypeXGoogApiKey" + )} + + + {t( + "aiProviderAuthTypeHec" + )} + + + {t( + "aiProviderAuthTypeCfAigAuthorization" + )} + diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index 26245620c..8eb038fba 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { + AI_PROVIDER_AUTH_TYPES, AI_PROVIDER_DEFAULTS, providerRequiresUpstreamUrl, type AiProviderType @@ -23,7 +24,7 @@ export const aiProviderFormSchema = z type: z.enum(aiProviderTypeValues), upstreamUrl: z.string().optional().nullable(), apiKey: z.string().optional(), - authType: z.enum(["bearer"]).optional().nullable(), + authType: z.enum(AI_PROVIDER_AUTH_TYPES).optional().nullable(), routingMode: z.enum(["url", "target"]).optional(), skipTlsVerification: z.boolean().optional(), enabled: z.boolean().optional() @@ -138,7 +139,7 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) { authType: values.type === "custom" ? (values.authType ?? "bearer") - : (values.authType ?? undefined), + : undefined, skipTlsVerification: values.skipTlsVerification, enabled: values.enabled ?? true }; @@ -159,14 +160,14 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) { name: values.name.trim(), routingMode: values.type === "custom" ? routingMode : "url", upstreamUrl, - authType: - values.type === "custom" - ? (values.authType ?? "bearer") - : (values.authType ?? null), skipTlsVerification: values.skipTlsVerification ?? false, enabled: values.enabled ?? true }; + if (values.type === "custom") { + payload.authType = values.authType ?? "bearer"; + } + if (values.apiKey?.trim()) { payload.apiKey = values.apiKey.trim(); } @@ -184,11 +185,13 @@ export function toAiProviderNetworkPayload(values: AiProviderFormValues) { } export function toAiProviderAuthPayload(values: AiProviderFormValues) { - const full = toAiProviderUpdatePayload(values); - return { - authType: full.authType, + const payload: Record = { ...(values.apiKey !== undefined ? { apiKey: values.apiKey.trim() } : {}) }; + if (values.type === "custom") { + payload.authType = values.authType ?? "bearer"; + } + return payload; } export function toAiProviderConfigurationPayload(values: AiProviderFormValues) { From bcf6b86b841911ae8711b755e2cbe688ebde5956 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 5 Aug 2026 15:39:08 -0400 Subject: [PATCH 215/494] add no auth and passthrough auth --- messages/en-US.json | 11 ++ server/db/pg/schema/schema.ts | 2 + server/db/sqlite/schema/schema.ts | 2 + server/lib/aiProviderDefaults.ts | 30 +++- server/routers/aiGateway/chatCompletions.ts | 25 ++-- server/routers/aiProvider/validation.ts | 8 - .../[providerId]/authentication/page.tsx | 137 +++++++---------- .../settings/ai-providers/create/page.tsx | 138 +++++++---------- src/components/AiProviderAuthTypeSelect.tsx | 139 ++++++++++++++++++ src/lib/aiProviderFormSchema.ts | 36 +++-- 10 files changed, 323 insertions(+), 205 deletions(-) create mode 100644 src/components/AiProviderAuthTypeSelect.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 257c56c42..bf498421a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1682,12 +1682,23 @@ "aiProviderApiKeyDescription": "API key used to authenticate requests to this provider", "aiProviderApiKeyLastChars": "API Key", "aiProviderAuthType": "Auth Type", + "aiProviderAuthTypeSearch": "Search auth types...", + "aiProviderAuthTypeNotFound": "No auth type found", "aiProviderAuthTypeBearer": "Bearer", + "aiProviderAuthTypeBearerDescription": "Authorization: Bearer key. Used by OpenAI and most providers", "aiProviderAuthTypeXApiKey": "x-api-key", + "aiProviderAuthTypeXApiKeyDescription": "x-api-key header. Used by Anthropic", "aiProviderAuthTypeXGoogApiKey": "x-goog-api-key", + "aiProviderAuthTypeXGoogApiKeyDescription": "x-goog-api-key header. Used by Google Gemini", "aiProviderAuthTypeHec": "Splunk HEC", + "aiProviderAuthTypeHecDescription": "Authorization: Splunk key. Used by Splunk HTTP Event Collector", "aiProviderAuthTypeCfAigAuthorization": "Cloudflare AI Gateway", + "aiProviderAuthTypeCfAigAuthorizationDescription": "cf-aig-authorization: Bearer key. Used by Cloudflare AI Gateway", + "aiProviderAuthTypeNone": "No Auth", + "aiProviderAuthTypePassthrough": "Passthrough", "aiProviderAuthTypeDescription": "How the upstream API authenticates requests", + "aiProviderAuthTypePassthroughDescription": "Forward the caller's API key headers to the upstream", + "aiProviderAuthTypeNoneDescription": "Do not send authentication headers to the upstream", "aiProviderRoutingMode": "Routing Mode", "aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to HTTP targets on your sites", "aiProviderRoutingModeUrl": "Upstream URL", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 7883345da..9bf11a21d 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1651,6 +1651,8 @@ export const aiProviders = pgTable("aiProviders", { | "x-goog-api-key" | "hec" | "cf-aig-authorization" + | "none" + | "passthrough" >() .notNull(), routingMode: varchar("routingMode") diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index e586c4e02..35028e815 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1633,6 +1633,8 @@ export const aiProviders = sqliteTable("aiProviders", { | "x-goog-api-key" | "hec" | "cf-aig-authorization" + | "none" + | "passthrough" >() .notNull(), routingMode: text("routingMode") diff --git a/server/lib/aiProviderDefaults.ts b/server/lib/aiProviderDefaults.ts index d803e7613..70b666954 100644 --- a/server/lib/aiProviderDefaults.ts +++ b/server/lib/aiProviderDefaults.ts @@ -14,7 +14,9 @@ export const AI_PROVIDER_AUTH_TYPES = [ "x-api-key", "x-goog-api-key", "hec", - "cf-aig-authorization" + "cf-aig-authorization", + "none", + "passthrough" ] as const; export type AiProviderAuthType = (typeof AI_PROVIDER_AUTH_TYPES)[number]; @@ -71,6 +73,10 @@ const CONFLICTING_AUTH_HEADERS = [ "cf-aig-authorization" ] as const; +export function authTypeRequiresApiKey(authType: AiProviderAuthType): boolean { + return authType !== "none" && authType !== "passthrough"; +} + export function providerRequiresUpstreamUrl( type: AiProviderType, routingMode: AiProviderRoutingMode = "url" @@ -116,20 +122,26 @@ export function resolveAiProviderCreateFields(input: { const defaults = AI_PROVIDER_DEFAULTS[input.type]; return { upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl, - authType: defaults.authType, + authType: input.authType ?? defaults.authType, routingMode }; } /** - * Strip inbound client auth headers, then set the provider auth header - * for the given authType. + * Apply provider auth to upstream headers. + * - Injected modes: strip client auth headers, then set the provider key. + * - none: strip client auth headers, send no auth. + * - passthrough: leave client auth headers as-is. */ export function applyAiProviderAuthHeaders( headers: Record, authType: AiProviderAuthType, - apiKey: string + apiKey: string | null ): void { + if (authType === "passthrough") { + return; + } + for (const name of CONFLICTING_AUTH_HEADERS) { for (const key of Object.keys(headers)) { if (key.toLowerCase() === name) { @@ -138,6 +150,14 @@ export function applyAiProviderAuthHeaders( } } + if (authType === "none") { + return; + } + + if (!apiKey) { + throw new Error(`API key required for authType ${authType}`); + } + switch (authType) { case "bearer": headers["Authorization"] = `Bearer ${apiKey}`; diff --git a/server/routers/aiGateway/chatCompletions.ts b/server/routers/aiGateway/chatCompletions.ts index e1bb8bbc9..d2671aa63 100644 --- a/server/routers/aiGateway/chatCompletions.ts +++ b/server/routers/aiGateway/chatCompletions.ts @@ -19,7 +19,8 @@ import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { AiProviderAuthType, - applyAiProviderAuthHeaders + applyAiProviderAuthHeaders, + authTypeRequiresApiKey } from "@server/lib/aiProviderDefaults"; import { SESSION_COOKIE_NAME, @@ -488,15 +489,6 @@ export async function chatCompletions( const { provider } = selection; - if (!provider.apiKey) { - return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ - error: { message: "AI provider has no API key configured" } - }); - } - - const secret = config.getRawConfig().server.secret!; - const apiKey = decrypt(provider.apiKey, secret); - const upstreamUrl = provider.upstreamUrl; const authType = provider.authType as AiProviderAuthType; @@ -508,6 +500,19 @@ export async function chatCompletions( }); } + let apiKey: string | null = null; + if (authTypeRequiresApiKey(authType)) { + if (!provider.apiKey) { + return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ + error: { + message: "AI provider has no API key configured" + } + }); + } + const secret = config.getRawConfig().server.secret!; + apiKey = decrypt(provider.apiKey, secret); + } + const targetUrl = `${upstreamUrl.replace(/\/$/, "")}`; // Drop hop-by-hop / proxy-only headers. Forwarding Host especially diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts index 42476c710..b4ab104aa 100644 --- a/server/routers/aiProvider/validation.ts +++ b/server/routers/aiProvider/validation.ts @@ -52,12 +52,4 @@ export function refineProviderUpstreamFields( path: ["upstreamUrl"] }); } - - if (data.type === "custom" && !data.authType) { - ctx.addIssue({ - code: "custom", - message: "authType is required for custom providers", - path: ["authType"] - }); - } } diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx index 264e6b252..a8f827e93 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx @@ -12,6 +12,7 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; +import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect"; import { Button } from "@app/components/ui/button"; import { Form, @@ -23,13 +24,6 @@ import { FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; import { useAiProviderContext } from "@app/hooks/useAiProviderContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; @@ -40,9 +34,10 @@ import { type AiProviderFormValues } from "@app/lib/aiProviderFormSchema"; import { zodResolver } from "@hookform/resolvers/zod"; -import type { - AiProviderAuthType, - AiProviderType +import { + authTypeRequiresApiKey, + type AiProviderAuthType, + type AiProviderType } from "@server/lib/aiProviderDefaults"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import type { AxiosResponse } from "axios"; @@ -73,7 +68,10 @@ export default function AiProviderAuthenticationPage() { } }); - const showAuthType = provider.type === "custom"; + const authType = form.watch("authType"); + const showApiKey = authTypeRequiresApiKey( + (authType as AiProviderAuthType | null) ?? "bearer" + ); async function onSubmit(values: AiProviderFormValues) { setSaveLoading(true); @@ -135,88 +133,22 @@ export default function AiProviderAuthenticationPage() { id="ai-provider-auth-form" > - {showAuthType && ( - - ( - - - {t( - "aiProviderAuthType" - )} - - - - {t( - "aiProviderAuthTypeDescription" - )} - - - - )} - /> - - )} - ( - {t("aiProviderApiKey")} + {t( + "aiProviderAuthType" + )} - {t( - "aiProviderApiKeyDescription" + "aiProviderAuthTypeDescription" )} @@ -233,6 +165,43 @@ export default function AiProviderAuthenticationPage() { )} /> + + {showApiKey && ( + + ( + + + {t( + "aiProviderApiKey" + )} + + + + + + {t( + "aiProviderApiKeyDescription" + )} + + + + )} + /> + + )} diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 5ad4c4ec7..1de013a2e 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -19,6 +19,7 @@ import { SettingsSubsectionTitle } from "@app/components/Settings"; import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect"; import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect"; import { StrategySelect } from "@app/components/StrategySelect"; import { SwitchInput } from "@app/components/SwitchInput"; @@ -33,18 +34,12 @@ import { FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { aiProviderCreateFormSchema, + defaultAuthTypeForProvider, emptyUpstreamForType, showsUpstreamUrlField, toAiProviderCreatePayload, @@ -52,6 +47,7 @@ import { type AiProviderFormValues } from "@app/lib/aiProviderFormSchema"; import { zodResolver } from "@hookform/resolvers/zod"; +import { authTypeRequiresApiKey } from "@server/lib/aiProviderDefaults"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; @@ -76,7 +72,7 @@ export default function CreateAiProviderPage() { type: "openai", upstreamUrl: emptyUpstreamForType("openai"), apiKey: "", - authType: "bearer", + authType: defaultAuthTypeForProvider("openai"), routingMode: "url", skipTlsVerification: false, enabled: true @@ -85,12 +81,13 @@ export default function CreateAiProviderPage() { const providerType = form.watch("type"); const routingMode = form.watch("routingMode"); + const authType = form.watch("authType"); const showUpstream = showsUpstreamUrlField(providerType, routingMode); const requireUpstream = upstreamUrlRequired(providerType, routingMode); const showRoutingMode = providerType === "custom"; - const showAuthType = providerType === "custom"; const showTargets = providerType === "custom" && routingMode === "target"; + const showApiKey = authTypeRequiresApiKey(authType ?? "bearer"); async function createTargets( providerId: number, @@ -272,6 +269,12 @@ export default function CreateAiProviderPage() { value ) ); + form.setValue( + "authType", + defaultAuthTypeForProvider( + value + ) + ); if ( value !== "custom" @@ -495,88 +498,22 @@ export default function CreateAiProviderPage() { - {showAuthType && ( - - ( - - - {t( - "aiProviderAuthType" - )} - - - - {t( - "aiProviderAuthTypeDescription" - )} - - - - )} - /> - - )} - ( - {t("aiProviderApiKey")} + {t( + "aiProviderAuthType" + )} - {t( - "aiProviderApiKeyDescription" + "aiProviderAuthTypeDescription" )} @@ -593,6 +530,43 @@ export default function CreateAiProviderPage() { )} /> + + {showApiKey && ( + + ( + + + {t( + "aiProviderApiKey" + )} + + + + + + {t( + "aiProviderApiKeyDescription" + )} + + + + )} + /> + + )} diff --git a/src/components/AiProviderAuthTypeSelect.tsx b/src/components/AiProviderAuthTypeSelect.tsx new file mode 100644 index 000000000..a040c6fa1 --- /dev/null +++ b/src/components/AiProviderAuthTypeSelect.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { + AI_PROVIDER_AUTH_TYPES, + type AiProviderAuthType +} from "@server/lib/aiProviderDefaults"; +import { CheckIcon, ChevronsUpDown } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; + +const authLabelMap = { + bearer: "aiProviderAuthTypeBearer", + "x-api-key": "aiProviderAuthTypeXApiKey", + "x-goog-api-key": "aiProviderAuthTypeXGoogApiKey", + hec: "aiProviderAuthTypeHec", + "cf-aig-authorization": "aiProviderAuthTypeCfAigAuthorization", + none: "aiProviderAuthTypeNone", + passthrough: "aiProviderAuthTypePassthrough" +} as const; + +const authDescriptionMap = { + bearer: "aiProviderAuthTypeBearerDescription", + "x-api-key": "aiProviderAuthTypeXApiKeyDescription", + "x-goog-api-key": "aiProviderAuthTypeXGoogApiKeyDescription", + hec: "aiProviderAuthTypeHecDescription", + "cf-aig-authorization": "aiProviderAuthTypeCfAigAuthorizationDescription", + none: "aiProviderAuthTypeNoneDescription", + passthrough: "aiProviderAuthTypePassthroughDescription" +} as const; + +type AiProviderAuthTypeSelectProps = { + value: AiProviderAuthType; + onChange: (value: AiProviderAuthType) => void; + disabled?: boolean; + className?: string; +}; + +export function AiProviderAuthTypeSelect({ + value, + onChange, + disabled, + className +}: AiProviderAuthTypeSelectProps) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + + const options = useMemo( + () => + AI_PROVIDER_AUTH_TYPES.map((authType) => ({ + authType, + title: t(authLabelMap[authType]), + description: t(authDescriptionMap[authType]) + })), + [t] + ); + + const selected = options.find((option) => option.authType === value); + + return ( + + + + + + + + + + {t("aiProviderAuthTypeNotFound")} + + + {options.map((option) => ( + { + onChange(option.authType); + setOpen(false); + }} + > + +
+ + {option.title} + + + {option.description} + +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index 8eb038fba..67b540305 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -2,7 +2,9 @@ import { z } from "zod"; import { AI_PROVIDER_AUTH_TYPES, AI_PROVIDER_DEFAULTS, + authTypeRequiresApiKey, providerRequiresUpstreamUrl, + type AiProviderAuthType, type AiProviderType } from "@server/lib/aiProviderDefaults"; @@ -70,10 +72,10 @@ export const aiProviderFormSchema = z }); } - if (data.type === "custom" && !data.authType) { + if (!data.authType) { ctx.addIssue({ code: "custom", - message: "authType is required for custom providers", + message: "authType is required", path: ["authType"] }); } @@ -83,7 +85,9 @@ export type AiProviderFormValues = z.infer; export const aiProviderCreateFormSchema = aiProviderFormSchema.superRefine( (data, ctx) => { - if (!data.apiKey?.trim()) { + const authType: AiProviderAuthType = data.authType ?? "bearer"; + + if (authTypeRequiresApiKey(authType) && !data.apiKey?.trim()) { ctx.addIssue({ code: "custom", message: "API key is required", @@ -93,6 +97,15 @@ export const aiProviderCreateFormSchema = aiProviderFormSchema.superRefine( } ); +export function defaultAuthTypeForProvider( + type: AiProviderType +): AiProviderAuthType { + if (type === "custom") { + return "bearer"; + } + return AI_PROVIDER_DEFAULTS[type].authType; +} + export function emptyUpstreamForType(type: AiProviderType): string { if (type === "custom") { return ""; @@ -136,10 +149,7 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) { routingMode: values.type === "custom" ? routingMode : undefined, upstreamUrl, apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined, - authType: - values.type === "custom" - ? (values.authType ?? "bearer") - : undefined, + authType: values.authType ?? "bearer", skipTlsVerification: values.skipTlsVerification, enabled: values.enabled ?? true }; @@ -160,14 +170,11 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) { name: values.name.trim(), routingMode: values.type === "custom" ? routingMode : "url", upstreamUrl, + authType: values.authType ?? "bearer", skipTlsVerification: values.skipTlsVerification ?? false, enabled: values.enabled ?? true }; - if (values.type === "custom") { - payload.authType = values.authType ?? "bearer"; - } - if (values.apiKey?.trim()) { payload.apiKey = values.apiKey.trim(); } @@ -185,13 +192,10 @@ export function toAiProviderNetworkPayload(values: AiProviderFormValues) { } export function toAiProviderAuthPayload(values: AiProviderFormValues) { - const payload: Record = { + return { + authType: values.authType ?? "bearer", ...(values.apiKey !== undefined ? { apiKey: values.apiKey.trim() } : {}) }; - if (values.type === "custom") { - payload.authType = values.authType ?? "bearer"; - } - return payload; } export function toAiProviderConfigurationPayload(values: AiProviderFormValues) { From 790daba79633435c229fbd529f9e35ea19818a97 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 5 Aug 2026 15:43:42 -0400 Subject: [PATCH 216/494] allow no provider on a resource --- messages/en-US.json | 1 - server/routers/resource/createResource.ts | 2 +- .../resource/removeAiProviderFromResource.ts | 20 ++++-------- .../resource/setResourceAiProviders.ts | 4 +-- .../siteResource/createSiteResource.ts | 2 +- .../removeAiProviderFromSiteResource.ts | 20 ++++-------- .../setSiteResourceAiProviders.ts | 4 +-- .../private/[niceId]/providers/page.tsx | 6 ++-- .../public/[niceId]/providers/page.tsx | 6 ++-- .../settings/resources/public/create/page.tsx | 32 +------------------ src/lib/privateResourceForm.ts | 14 -------- 11 files changed, 25 insertions(+), 86 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index bf498421a..5528c0ee1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1739,7 +1739,6 @@ "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", diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 4d546468f..47e70140f 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -394,7 +394,7 @@ async function createHttpResource( const resolved = await resolveProviderAttachments({ orgId, attachments: aiProviderInputs ?? [], - requireAtLeastOne: true + requireAtLeastOne: false }); if (isInferenceFieldsError(resolved)) { return next(createHttpError(HttpCode.BAD_REQUEST, resolved.error)); diff --git a/server/routers/resource/removeAiProviderFromResource.ts b/server/routers/resource/removeAiProviderFromResource.ts index d3a4294ad..e74b0c886 100644 --- a/server/routers/resource/removeAiProviderFromResource.ts +++ b/server/routers/resource/removeAiProviderFromResource.ts @@ -77,8 +77,9 @@ export async function removeAiProviderFromResource( const { providerId } = parsedBody.data; - const parsedParams = - removeAiProviderFromResourceParamsSchema.safeParse(req.params); + const parsedParams = removeAiProviderFromResourceParamsSchema.safeParse( + req.params + ); if (!parsedParams.success) { return next( createHttpError( @@ -129,22 +130,15 @@ export async function removeAiProviderFromResource( modelAccessMode: a.modelAccessMode })); - if (remaining.length === 0) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "At least one AI provider is required for inference-mode resources" - ) - ); - } - const attachments = await resolveProviderAttachments({ orgId: resource.orgId, attachments: remaining, - requireAtLeastOne: true + requireAtLeastOne: false }); if (isInferenceFieldsError(attachments)) { - return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error)); + return next( + createHttpError(HttpCode.BAD_REQUEST, attachments.error) + ); } await setPublicResourceAiProviders(resourceId, attachments); diff --git a/server/routers/resource/setResourceAiProviders.ts b/server/routers/resource/setResourceAiProviders.ts index 0cd0d5d84..88552954b 100644 --- a/server/routers/resource/setResourceAiProviders.ts +++ b/server/routers/resource/setResourceAiProviders.ts @@ -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. Model keys must be unique across attached catalog providers.", + "Replace the AI providers attached to an inference resource. An empty list clears all providers. Model keys must be unique across attached catalog providers.", tags: [OpenAPITags.PublicResource], request: { params: setResourceAiProvidersParamsSchema, @@ -113,7 +113,7 @@ export async function setResourceAiProviders( const attachments = await resolveProviderAttachments({ orgId: resource.orgId, attachments: providers, - requireAtLeastOne: true + requireAtLeastOne: false }); if (isInferenceFieldsError(attachments)) { return next( diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 277c853ab..feeca0938 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -353,7 +353,7 @@ export async function createSiteResource( const resolved = await resolveProviderAttachments({ orgId, attachments: aiProviderInputs ?? [], - requireAtLeastOne: true + requireAtLeastOne: false }); if (isInferenceFieldsError(resolved)) { return next( diff --git a/server/routers/siteResource/removeAiProviderFromSiteResource.ts b/server/routers/siteResource/removeAiProviderFromSiteResource.ts index 65781859c..3d4392820 100644 --- a/server/routers/siteResource/removeAiProviderFromSiteResource.ts +++ b/server/routers/siteResource/removeAiProviderFromSiteResource.ts @@ -63,8 +63,9 @@ export async function removeAiProviderFromSiteResource( next: NextFunction ): Promise { try { - const parsedBody = - removeAiProviderFromSiteResourceBodySchema.safeParse(req.body); + const parsedBody = removeAiProviderFromSiteResourceBodySchema.safeParse( + req.body + ); if (!parsedBody.success) { return next( createHttpError( @@ -128,22 +129,15 @@ export async function removeAiProviderFromSiteResource( modelAccessMode: a.modelAccessMode })); - if (remaining.length === 0) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "At least one AI provider is required for inference-mode resources" - ) - ); - } - const attachments = await resolveProviderAttachments({ orgId: siteResource.orgId, attachments: remaining, - requireAtLeastOne: true + requireAtLeastOne: false }); if (isInferenceFieldsError(attachments)) { - return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error)); + return next( + createHttpError(HttpCode.BAD_REQUEST, attachments.error) + ); } await setSiteResourceAiProviders(siteResourceId, attachments); diff --git a/server/routers/siteResource/setSiteResourceAiProviders.ts b/server/routers/siteResource/setSiteResourceAiProviders.ts index c6e708064..fd685bb20 100644 --- a/server/routers/siteResource/setSiteResourceAiProviders.ts +++ b/server/routers/siteResource/setSiteResourceAiProviders.ts @@ -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. Model keys must be unique across attached catalog providers.", + "Replace the AI providers attached to an inference site resource. An empty list clears all providers. Model keys must be unique across attached catalog providers.", tags: [OpenAPITags.PrivateResource], request: { params: setSiteResourceAiProvidersParamsSchema, @@ -115,7 +115,7 @@ export async function setSiteResourceAiProviders( const attachments = await resolveProviderAttachments({ orgId: siteResource.orgId, attachments: providers, - requireAtLeastOne: true + requireAtLeastOne: false }); if (isInferenceFieldsError(attachments)) { return next( diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/providers/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/providers/page.tsx index ead7ab24b..dcbc4ea29 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/providers/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/providers/page.tsx @@ -58,11 +58,9 @@ export default function PrivateResourceProvidersPage() { const formSchema = useMemo( () => z.object({ - providerIds: z - .array(z.number().int().positive()) - .min(1, t("aiResourceProvidersRequired")) + providerIds: z.array(z.number().int().positive()) }), - [t] + [] ); type FormValues = z.infer; diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/providers/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/providers/page.tsx index 67ef0fa9e..51182c2a8 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/providers/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/providers/page.tsx @@ -58,11 +58,9 @@ export default function PublicResourceProvidersPage() { const formSchema = useMemo( () => z.object({ - providerIds: z - .array(z.number().int().positive()) - .min(1, t("aiResourceProvidersRequired")) + providerIds: z.array(z.number().int().positive()) }), - [t] + [] ); type FormValues = z.infer; diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index c0e0a0f8e..a5088d84e 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -252,7 +252,6 @@ export default function Page() { const [selectedProviders, setSelectedProviders] = useState< SelectedAiProvider[] >([]); - const [showProvidersError, setShowProvidersError] = useState(false); // SSH-specific state const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( @@ -355,9 +354,6 @@ export default function Page() { if (!availableTypes.includes(resourceType)) { setResourceType("http"); } - if (resourceType !== "inference") { - setShowProvidersError(false); - } }, [availableTypes, resourceType]); const baseResourceFormSchema = useMemo( @@ -1440,14 +1436,6 @@ export default function Page() { setSelectedProviders( providers ); - if ( - providers.length > - 0 - ) { - setShowProvidersError( - false - ); - } }} />

@@ -1455,13 +1443,6 @@ export default function Page() { "aiResourceProvidersHelp" )}

- {showProvidersError && ( -

- {t( - "aiResourceProvidersRequired" - )} -

- )}
@@ -1493,16 +1474,6 @@ export default function Page() { const tcpValid = !isHttpResource ? await tcpUdpForm.trigger() : true; - const providersValid = - resourceType !== "inference" || - selectedProviders.length > 0; - - if ( - resourceType === "inference" && - !providersValid - ) { - setShowProvidersError(true); - } if ( resourceType === "ssh" && @@ -1527,8 +1498,7 @@ export default function Page() { baseValid && domainValid && tcpValid && - bgValid && - providersValid + bgValid ) { onSubmit(); } diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index 4c60f1cef..e336a45a1 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -467,13 +467,6 @@ export function createCreateFormSchema(t: TranslateFn) { path: ["alias"] }); } - if (!data.providerIds || data.providerIds.length < 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("aiResourceProvidersRequired"), - path: ["providerIds"] - }); - } } if ( data.mode !== "ssh" && @@ -639,13 +632,6 @@ export function createInferenceFormSchema(t: TranslateFn) { path: ["alias"] }); } - if (!data.providerIds || data.providerIds.length < 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("aiResourceProvidersRequired"), - path: ["providerIds"] - }); - } }); } From 2e8bd7a8c7c253d03bec186f94557b60c54d944d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 5 Aug 2026 15:56:12 -0400 Subject: [PATCH 217/494] improve form error codes --- messages/en-US.json | 5 + .../[providerId]/authentication/page.tsx | 8 +- .../[providerId]/general/page.tsx | 23 ++-- .../[providerId]/network/page.tsx | 8 +- .../settings/ai-providers/create/page.tsx | 8 +- src/lib/aiProviderFormSchema.ts | 128 ++++++++++-------- 6 files changed, 103 insertions(+), 77 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 5528c0ee1..39a3b54e6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1721,6 +1721,11 @@ "aiProviderErrorUpdate": "Failed to update AI provider", "aiProviderErrorDelete": "Failed to delete AI provider", "aiProviderErrorLoad": "Failed to load AI provider", + "aiProviderErrorUpstreamUrlInvalid": "Enter a valid upstream URL", + "aiProviderErrorUpstreamUrlRequired": "Upstream URL is required for this provider", + "aiProviderErrorAuthTypeRequired": "Auth type is required", + "aiProviderErrorApiKeyRequired": "API key is required", + "aiProviderErrorRoutingModeTarget": "Site targets routing is only available for custom providers", "aiProviderCreated": "AI provider created", "aiProviderUpdated": "AI provider updated", "aiProviderDeleted": "AI provider deleted", diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx index a8f827e93..5a7b068d8 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx @@ -29,7 +29,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { - aiProviderFormSchema, + createAiProviderFormSchema, toAiProviderAuthPayload, type AiProviderFormValues } from "@app/lib/aiProviderFormSchema"; @@ -43,7 +43,7 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/ import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; export default function AiProviderAuthenticationPage() { @@ -54,8 +54,10 @@ export default function AiProviderAuthenticationPage() { const t = useTranslations(); const [saveLoading, setSaveLoading] = useState(false); + const formSchema = useMemo(() => createAiProviderFormSchema(t), [t]); + const form = useForm({ - resolver: zodResolver(aiProviderFormSchema), + resolver: zodResolver(formSchema), defaultValues: { name: provider.name, type: provider.type as AiProviderType, diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx index 99db2eda8..1018f8e63 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx @@ -32,17 +32,10 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/ import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -const generalSchema = z.object({ - name: z.string().trim().min(1), - enabled: z.boolean() -}); - -type GeneralFormValues = z.infer; - export default function AiProviderGeneralPage() { const { provider, updateProvider } = useAiProviderContext(); const { env } = useEnvContext(); @@ -51,6 +44,20 @@ export default function AiProviderGeneralPage() { const t = useTranslations(); const [saveLoading, setSaveLoading] = useState(false); + const generalSchema = useMemo( + () => + z.object({ + name: z + .string() + .trim() + .min(1, { message: t("nameRequired") }), + enabled: z.boolean() + }), + [t] + ); + + type GeneralFormValues = z.infer; + const form = useForm({ resolver: zodResolver(generalSchema), defaultValues: { diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx index 270ba3c68..49f251cfa 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx @@ -37,7 +37,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { - aiProviderFormSchema, + createAiProviderFormSchema, showsUpstreamUrlField, toAiProviderNetworkPayload, upstreamUrlRequired, @@ -54,7 +54,7 @@ import { useQuery } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; export default function AiProviderNetworkPage() { @@ -68,8 +68,10 @@ export default function AiProviderNetworkPage() { const [saveLoading, setSaveLoading] = useState(false); const targetsFormRef = useRef(null); + const formSchema = useMemo(() => createAiProviderFormSchema(t), [t]); + const form = useForm({ - resolver: zodResolver(aiProviderFormSchema), + resolver: zodResolver(formSchema), defaultValues: { name: provider.name, type: provider.type as AiProviderType, diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 1de013a2e..1947c3f85 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -38,7 +38,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { - aiProviderCreateFormSchema, + createAiProviderCreateFormSchema, defaultAuthTypeForProvider, emptyUpstreamForType, showsUpstreamUrlField, @@ -52,7 +52,7 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/ import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; export default function CreateAiProviderPage() { @@ -65,8 +65,10 @@ export default function CreateAiProviderPage() { const [loading, setLoading] = useState(false); const targetsRef = useRef([]); + const formSchema = useMemo(() => createAiProviderCreateFormSchema(t), [t]); + const form = useForm({ - resolver: zodResolver(aiProviderCreateFormSchema), + resolver: zodResolver(formSchema), defaultValues: { name: "", type: "openai", diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index 67b540305..f484d1f05 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -8,6 +8,8 @@ import { type AiProviderType } from "@server/lib/aiProviderDefaults"; +type TranslateFn = (key: string) => string; + export const aiProviderTypeValues = [ "openai", "anthropic", @@ -20,82 +22,88 @@ export const aiProviderTypeValues = [ "custom" ] as const satisfies readonly AiProviderType[]; -export const aiProviderFormSchema = z - .object({ - name: z.string().trim().min(1), - type: z.enum(aiProviderTypeValues), - upstreamUrl: z.string().optional().nullable(), - apiKey: z.string().optional(), - authType: z.enum(AI_PROVIDER_AUTH_TYPES).optional().nullable(), - routingMode: z.enum(["url", "target"]).optional(), - skipTlsVerification: z.boolean().optional(), - enabled: z.boolean().optional() - }) - .superRefine((data, ctx) => { - const routingMode = - data.type === "custom" ? (data.routingMode ?? "url") : "url"; +export function createAiProviderFormSchema(t: TranslateFn) { + return z + .object({ + name: z + .string() + .trim() + .min(1, { message: t("nameRequired") }), + type: z.enum(aiProviderTypeValues), + upstreamUrl: z.string().optional().nullable(), + apiKey: z.string().optional(), + authType: z.enum(AI_PROVIDER_AUTH_TYPES).optional().nullable(), + routingMode: z.enum(["url", "target"]).optional(), + skipTlsVerification: z.boolean().optional(), + enabled: z.boolean().optional() + }) + .superRefine((data, ctx) => { + const routingMode = + data.type === "custom" ? (data.routingMode ?? "url") : "url"; - if (data.type !== "custom" && data.routingMode === "target") { - ctx.addIssue({ - code: "custom", - message: - "routingMode target is only allowed for custom providers", - path: ["routingMode"] - }); - } - - const upstreamUrl = - data.upstreamUrl && data.upstreamUrl.trim().length > 0 - ? data.upstreamUrl.trim() - : null; - - if (upstreamUrl) { - try { - new URL(upstreamUrl); - } catch { + if (data.type !== "custom" && data.routingMode === "target") { ctx.addIssue({ code: "custom", - message: "Invalid URL", + message: t("aiProviderErrorRoutingModeTarget"), + path: ["routingMode"] + }); + } + + const upstreamUrl = + data.upstreamUrl && data.upstreamUrl.trim().length > 0 + ? data.upstreamUrl.trim() + : null; + + if (upstreamUrl) { + try { + new URL(upstreamUrl); + } catch { + ctx.addIssue({ + code: "custom", + message: t("aiProviderErrorUpstreamUrlInvalid"), + path: ["upstreamUrl"] + }); + } + } + + if ( + providerRequiresUpstreamUrl(data.type, routingMode) && + !upstreamUrl + ) { + ctx.addIssue({ + code: "custom", + message: t("aiProviderErrorUpstreamUrlRequired"), path: ["upstreamUrl"] }); } - } - if ( - providerRequiresUpstreamUrl(data.type, routingMode) && - !upstreamUrl - ) { - ctx.addIssue({ - code: "custom", - message: `upstreamUrl is required for ${data.type} providers`, - path: ["upstreamUrl"] - }); - } + if (!data.authType) { + ctx.addIssue({ + code: "custom", + message: t("aiProviderErrorAuthTypeRequired"), + path: ["authType"] + }); + } + }); +} - if (!data.authType) { - ctx.addIssue({ - code: "custom", - message: "authType is required", - path: ["authType"] - }); - } - }); - -export type AiProviderFormValues = z.infer; - -export const aiProviderCreateFormSchema = aiProviderFormSchema.superRefine( - (data, ctx) => { +export function createAiProviderCreateFormSchema(t: TranslateFn) { + return createAiProviderFormSchema(t).superRefine((data, ctx) => { const authType: AiProviderAuthType = data.authType ?? "bearer"; if (authTypeRequiresApiKey(authType) && !data.apiKey?.trim()) { ctx.addIssue({ code: "custom", - message: "API key is required", + message: t("aiProviderErrorApiKeyRequired"), path: ["apiKey"] }); } - } -); + }); +} + +export type AiProviderFormValues = z.infer< + ReturnType +>; export function defaultAuthTypeForProvider( type: AiProviderType From c5b0e1e87640bf6ce9a7838e04260af558a667d6 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 5 Aug 2026 13:41:57 -0400 Subject: [PATCH 218/494] add QUERY_LOGGING env to sqlite driver for verbose logging --- server/db/sqlite/driver.ts | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index a7eee52b7..6b115e4ee 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -6,13 +6,48 @@ import fs from "fs"; import { APP_PATH } from "@server/lib/consts"; import { existsSync, mkdirSync } from "fs"; +// Temporary diagnostic trace for the random better-sqlite3 native aborts +// (Statement::~Statement -> RemoveEnvironmentCleanupHook assertion). That +// abort is a hard SIGABRT from native code, so it bypasses uncaughtException/ +// unhandledRejection and can outrun winston's async file transport. This +// writes every statement text synchronously (fsync'd via appendFileSync) so +// the statements immediately preceding a crash survive it. better-sqlite3's +// `verbose` hook fires for BEGIN/SAVEPOINT/RELEASE/COMMIT/ROLLBACK too, since +// those are just prepared statements under the hood - so this also lets us +// see if two "transactions" ever overlap on the shared connection. +// Enable with SQL_TRACE=true; remove once the crash is root-caused. +function sqlTraceVerbose(): + | ((message: unknown, ...args: unknown[]) => void) + | undefined { + if (process.env.SQL_TRACE !== "true") { + return undefined; + } + const traceLogDir = path.join(APP_PATH, "logs"); + if (!existsSync(traceLogDir)) { + mkdirSync(traceLogDir, { recursive: true }); + } + const traceLogPath = path.join(traceLogDir, "sql-trace.log"); + let seq = 0; + return (message: unknown) => { + seq += 1; + const line = `${new Date().toISOString()} pid=${process.pid} #${seq} ${String(message).replace(/\s+/g, " ").trim()}\n`; + try { + fs.appendFileSync(traceLogPath, line); + } catch { + // best-effort diagnostic logging only + } + }; +} + export const location = path.join(APP_PATH, "db", "db.sqlite"); export const exists = checkFileExists(location); bootstrapVolume(); function createDb() { - const sqlite = new Database(location); + const verbose = + process.env.QUERY_LOGGING == "true" ? sqlTraceVerbose() : undefined; + const sqlite = new Database(location, { verbose }); if (process.env.ENABLE_SQLITE_WAL_MODE == "true") { // Enable WAL mode — allows concurrent readers + single writer, preventing From 796bf3714183700a401f80ca15c35e1d75d55183 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 5 Aug 2026 16:24:01 -0400 Subject: [PATCH 219/494] ai gateway override only required sometimes --- .../private/lib/traefik/getTraefikConfig.ts | 67 +++++++++++-------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 67c037550..47d6b69c8 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -1542,6 +1542,12 @@ export async function getTraefikConfig( aiGatewayHost = undefined; } + // The p-host smuggling above is only necessary when the AI gateway + // is overridden to a different host than the resource's own. In the + // default case, leave the Host header untouched so it's visible on + // the other end. + const aiGatewayOverride = config.getRawConfig().server.ai_gateway_override; + // Public inference resources: same TLS/cert-resolver handling as // plain http-mode resources, but the service points at the AI // gateway instead of any real backend targets. @@ -1607,23 +1613,24 @@ export async function getTraefikConfig( } } - const irHeadersMiddlewareName = `${irKey}-headers-middleware`; - config_output.http.middlewares[irHeadersMiddlewareName] = { - headers: { - customRequestHeaders: { - ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": fullDomain - } - } - }; - const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; - const routerMiddlewares = [ - badgerMiddlewareName, - irHeadersMiddlewareName, - ...additionalMiddlewares - ]; + const routerMiddlewares = [badgerMiddlewareName]; + + if (aiGatewayOverride) { + const irHeadersMiddlewareName = `${irKey}-headers-middleware`; + config_output.http.middlewares[irHeadersMiddlewareName] = { + headers: { + customRequestHeaders: { + ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), + "p-host": fullDomain + } + } + }; + routerMiddlewares.push(irHeadersMiddlewareName); + } + + routerMiddlewares.push(...additionalMiddlewares); if (ir.ssl) { config_output.http.routers[routerName + "-redirect"] = { @@ -1705,22 +1712,24 @@ export async function getTraefikConfig( } } - const srHeadersMiddlewareName = `${srKey}-headers-middleware`; - config_output.http.middlewares[srHeadersMiddlewareName] = { - headers: { - customRequestHeaders: { - ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": alias - } - } - }; - const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; - const routerMiddlewares = [ - srHeadersMiddlewareName, - ...additionalMiddlewares - ]; + const routerMiddlewares: string[] = []; + + if (aiGatewayOverride) { + const srHeadersMiddlewareName = `${srKey}-headers-middleware`; + config_output.http.middlewares[srHeadersMiddlewareName] = { + headers: { + customRequestHeaders: { + ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), + "p-host": alias + } + } + }; + routerMiddlewares.push(srHeadersMiddlewareName); + } + + routerMiddlewares.push(...additionalMiddlewares); if (sr.ssl) { config_output.http.routers[routerName + "-redirect"] = { From b775c5b6748c50aee2adecb9db66cfe35ebbab6a Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 5 Aug 2026 16:24:09 -0400 Subject: [PATCH 220/494] include clients in the peer config --- server/routers/gerbil/getConfig.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index 77921c39f..ffc3c6120 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { sites, exitNodes, ExitNode } from "@server/db"; +import { sites, exitNodes, ExitNode, clients } from "@server/db"; import { db } from "@server/db"; import { eq, isNotNull, and } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; @@ -93,7 +93,23 @@ export async function generateGerbilConfig(exitNode: ExitNode) { ) ); - const peers = await Promise.all( + const clientsRes = await db + .select() + .from(clients) + .where( + and( + eq(clients.exitNodeId, exitNode.exitNodeId), + isNotNull(clients.pubKey), + isNotNull(clients.exitNodeSubnet) + ) + ); + + let peers: { + publicKey: string | null; + allowedIps: string[]; + }[] = []; + + const sitePeers = await Promise.all( sitesRes.map(async (site) => { if (site.type === "wireguard") { return { @@ -113,6 +129,15 @@ export async function generateGerbilConfig(exitNode: ExitNode) { }) ); + const clientPeers = clientsRes.map((client) => { + return { + publicKey: client.pubKey, + allowedIps: [client.exitNodeSubnet!] + }; + }); + + peers = [...sitePeers, ...clientPeers]; + const configResponse: GetConfigResponse = { listenPort: exitNode.listenPort || 51820, ipAddress: exitNode.address, From 39e06f2b6dddde344e33510d503800bd12118ec2 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 5 Aug 2026 16:55:48 -0400 Subject: [PATCH 221/494] add api capabilities --- messages/en-US.json | 23 ++ server/aiGatewayServer.ts | 4 +- server/db/pg/schema/schema.ts | 1 + server/db/sqlite/schema/schema.ts | 1 + server/lib/aiCapabilities.ts | 257 ++++++++++++++++++ .../aiGateway/createAiGatewayRouter.ts | 21 ++ server/routers/aiGateway/index.ts | 3 +- .../{chatCompletions.ts => pipeline.ts} | 73 ++--- server/routers/aiProvider/createAiProvider.ts | 12 + server/routers/aiProvider/types.ts | 14 +- server/routers/aiProvider/updateAiProvider.ts | 31 ++- server/routers/aiProvider/validation.ts | 18 ++ .../[providerId]/general/page.tsx | 119 +++++++- .../settings/ai-providers/create/page.tsx | 75 +++++ .../AiProviderCapabilitiesSelect.tsx | 89 ++++++ src/lib/aiProviderFormSchema.ts | 31 +++ 16 files changed, 715 insertions(+), 57 deletions(-) create mode 100644 server/lib/aiCapabilities.ts create mode 100644 server/routers/aiGateway/createAiGatewayRouter.ts rename server/routers/aiGateway/{chatCompletions.ts => pipeline.ts} (88%) create mode 100644 src/components/AiProviderCapabilitiesSelect.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 39a3b54e6..ac6131abc 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1726,6 +1726,29 @@ "aiProviderErrorAuthTypeRequired": "Auth type is required", "aiProviderErrorApiKeyRequired": "API key is required", "aiProviderErrorRoutingModeTarget": "Site targets routing is only available for custom providers", + "aiProviderErrorCapabilitiesRequired": "Select at least one API capability", + "aiProviderCapabilities": "API Capabilities", + "aiProviderCapabilitiesDescription": "Which API formats this provider accepts. Built-in providers use fixed capabilities.", + "aiProviderCapabilitiesCustomDescription": "Select which API formats this custom provider can handle", + "aiProviderCapabilitiesSelect": "Select capabilities", + "aiProviderCapabilitiesEmpty": "No capabilities found", + "aiProviderCapabilitiesSearch": "Search capabilities...", + "aiCapabilityOpenaiChat": "OpenAI Chat Completions", + "aiCapabilityOpenaiChatDescription": "Supports /v1/chat/completions", + "aiCapabilityOpenaiResponses": "OpenAI Responses", + "aiCapabilityOpenaiResponsesDescription": "Supports /v1/responses", + "aiCapabilityAnthropicMessages": "Anthropic Messages", + "aiCapabilityAnthropicMessagesDescription": "Supports /v1/messages", + "aiCapabilityGeminiGenerateContent": "Gemini Generate Content", + "aiCapabilityGeminiGenerateContentDescription": "Supports the direct Gemini API", + "aiCapabilityBedrockModelInvoke": "Bedrock Model Invoke", + "aiCapabilityBedrockModelInvokeDescription": "Supports Amazon Bedrock InvokeModel", + "aiCapabilityGoogleGenerateContent": "Vertex Generate Content", + "aiCapabilityGoogleGenerateContentDescription": "Supports Vertex AI Gemini format", + "aiCapabilityGoogleRawPredict": "Vertex Raw Predict", + "aiCapabilityGoogleRawPredictDescription": "Supports Vertex AI rawPredict for Anthropic models", + "aiCapabilityBedrockConverse": "Bedrock Converse", + "aiCapabilityBedrockConverseDescription": "Supports Amazon Bedrock Converse API", "aiProviderCreated": "AI provider created", "aiProviderUpdated": "AI provider updated", "aiProviderDeleted": "AI provider deleted", diff --git a/server/aiGatewayServer.ts b/server/aiGatewayServer.ts index db8b12324..87d57b86b 100644 --- a/server/aiGatewayServer.ts +++ b/server/aiGatewayServer.ts @@ -7,7 +7,7 @@ import { errorHandlerMiddleware, notFoundMiddleware } from "@server/middlewares"; -import * as aiGateway from "@server/routers/aiGateway"; +import { createAiGatewayRouter } from "@server/routers/aiGateway"; const aiGatewayPort = config.getRawConfig().server.ai_gateway_port; @@ -23,7 +23,7 @@ export function createAiGatewayServer() { aiGatewayServer.use(cors()); aiGatewayServer.use(express.json()); - aiGatewayServer.post("/chat/completions", aiGateway.chatCompletions); + aiGatewayServer.use(createAiGatewayRouter()); aiGatewayServer.use(notFoundMiddleware); aiGatewayServer.use(errorHandlerMiddleware); diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 9bf11a21d..3091ed618 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1659,6 +1659,7 @@ export const aiProviders = pgTable("aiProviders", { .$type<"url" | "target">() .notNull() .default("url"), + capabilities: text("capabilities").notNull().default("[]"), skipTlsVerification: boolean("skipTlsVerification") .notNull() .default(false), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 35028e815..5c115cb2e 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1641,6 +1641,7 @@ export const aiProviders = sqliteTable("aiProviders", { .$type<"url" | "target">() .notNull() .default("url"), + capabilities: text("capabilities").notNull().default("[]"), skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" }) .notNull() .default(false), diff --git a/server/lib/aiCapabilities.ts b/server/lib/aiCapabilities.ts new file mode 100644 index 000000000..b6d2f7984 --- /dev/null +++ b/server/lib/aiCapabilities.ts @@ -0,0 +1,257 @@ +import type { Request } from "express"; +import type { AiProviderType } from "@server/lib/aiProviderDefaults"; + +export const AI_CAPABILITIES = [ + "openai_chat", + "openai_responses", + "anthropic_messages", + "gemini_generate_content", + "bedrock_model_invoke", + "google_generate_content", + "google_raw_predict", + "bedrock_converse" +] as const; + +export type AiCapability = (typeof AI_CAPABILITIES)[number]; + +export type AiCapabilityRoute = { + method: "POST"; + path: string; +}; + +export type AiCapabilityDefinition = { + id: AiCapability; + routes: AiCapabilityRoute[]; + extractModel: (req: Request) => string | undefined; + resolveUpstreamUrl: ( + baseUrl: string, + req: Request, + model: string + ) => string; +}; + +function bodyModel(req: Request): string | undefined { + return typeof req.body?.model === "string" ? req.body.model : undefined; +} + +function paramModel(req: Request): string | undefined { + const model = req.params?.model; + return typeof model === "string" && model.length > 0 ? model : undefined; +} + +/** + * Join base URL with a path, avoiding double slashes and a duplicated trailing + * /v1 when the inbound path already starts with /v1 and the base ends with /v1. + */ +export function joinUpstreamUrl(baseUrl: string, path: string): string { + const base = baseUrl.replace(/\/+$/, ""); + let suffix = path.startsWith("/") ? path : `/${path}`; + + if ( + base.endsWith("/v1") && + (suffix === "/v1" || suffix.startsWith("/v1/")) + ) { + suffix = suffix.slice("/v1".length) || "/"; + } + + if (suffix === "/") { + return base; + } + + return `${base}${suffix}`; +} + +function pathFromRequest(req: Request): string { + // Prefer originalUrl path (includes mounted path) over req.path when available. + const raw = + req.originalUrl?.split("?")[0] || req.url?.split("?")[0] || req.path; + return raw.startsWith("/") ? raw : `/${raw}`; +} + +export const AI_CAPABILITY_DEFS: Record = + { + openai_chat: { + id: "openai_chat", + routes: [ + { method: "POST", path: "/v1/chat/completions" }, + { method: "POST", path: "/chat/completions" } + ], + extractModel: bodyModel, + resolveUpstreamUrl: (base, req) => + joinUpstreamUrl(base, pathFromRequest(req)) + }, + openai_responses: { + id: "openai_responses", + routes: [{ method: "POST", path: "/v1/responses" }], + extractModel: bodyModel, + resolveUpstreamUrl: (base, req) => + joinUpstreamUrl(base, pathFromRequest(req)) + }, + anthropic_messages: { + id: "anthropic_messages", + routes: [{ method: "POST", path: "/v1/messages" }], + extractModel: bodyModel, + resolveUpstreamUrl: (base, req) => + joinUpstreamUrl(base, pathFromRequest(req)) + }, + gemini_generate_content: { + id: "gemini_generate_content", + routes: [ + { + method: "POST", + path: "/v1beta/models/:model\\:generateContent" + }, + { + method: "POST", + path: "/v1beta/models/:model\\:streamGenerateContent" + } + ], + extractModel: paramModel, + resolveUpstreamUrl: (base, req) => + joinUpstreamUrl(base, pathFromRequest(req)) + }, + google_generate_content: { + id: "google_generate_content", + routes: [ + { + method: "POST", + // Vertex publisher model generateContent + path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:generateContent" + }, + { + method: "POST", + path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamGenerateContent" + } + ], + extractModel: paramModel, + resolveUpstreamUrl: (base, req) => + joinUpstreamUrl(base, pathFromRequest(req)) + }, + google_raw_predict: { + id: "google_raw_predict", + routes: [ + { + method: "POST", + path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:rawPredict" + }, + { + method: "POST", + path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamRawPredict" + } + ], + extractModel: paramModel, + resolveUpstreamUrl: (base, req) => + joinUpstreamUrl(base, pathFromRequest(req)) + }, + bedrock_model_invoke: { + id: "bedrock_model_invoke", + routes: [ + { method: "POST", path: "/model/:model/invoke" }, + { + method: "POST", + path: "/model/:model/invoke-with-response-stream" + } + ], + extractModel: paramModel, + resolveUpstreamUrl: (base, req) => + joinUpstreamUrl(base, pathFromRequest(req)) + }, + bedrock_converse: { + id: "bedrock_converse", + routes: [ + { method: "POST", path: "/model/:model/converse" }, + { method: "POST", path: "/model/:model/converse-stream" } + ], + extractModel: paramModel, + resolveUpstreamUrl: (base, req) => + joinUpstreamUrl(base, pathFromRequest(req)) + } + }; + +export const AI_PROVIDER_CAPABILITY_DEFAULTS: Record< + Exclude, + readonly AiCapability[] +> = { + openai: ["openai_chat"], + anthropic: ["anthropic_messages"], + googleGemini: ["openai_chat"], + vertexAi: ["google_generate_content"], + bedrock: ["bedrock_converse"], + microsoftFoundry: ["openai_chat"], + openRouter: ["openai_chat"], + vercelAiGateway: ["openai_chat"] +}; + +export function isAiCapability(value: unknown): value is AiCapability { + return ( + typeof value === "string" && + (AI_CAPABILITIES as readonly string[]).includes(value) + ); +} + +export function parseCapabilities(raw: unknown): AiCapability[] { + if (raw == null) { + return []; + } + + let parsed: unknown = raw; + if (typeof raw === "string") { + const trimmed = raw.trim(); + if (!trimmed) { + return []; + } + try { + parsed = JSON.parse(trimmed); + } catch { + return []; + } + } + + if (!Array.isArray(parsed)) { + return []; + } + + const out: AiCapability[] = []; + const seen = new Set(); + for (const item of parsed) { + if (isAiCapability(item) && !seen.has(item)) { + seen.add(item); + out.push(item); + } + } + return out; +} + +export function serializeCapabilities(capabilities: AiCapability[]): string { + return JSON.stringify(capabilities); +} + +export function providerHasCapability( + capabilities: AiCapability[] | string | null | undefined, + capability: AiCapability +): boolean { + const list = + typeof capabilities === "string" || capabilities == null + ? parseCapabilities(capabilities) + : capabilities; + return list.includes(capability); +} + +export function resolveCapabilitiesForCreate(input: { + type: AiProviderType; + capabilities?: AiCapability[] | null; +}): AiCapability[] { + if (input.type === "custom") { + return parseCapabilities(input.capabilities ?? []); + } + return [...AI_PROVIDER_CAPABILITY_DEFAULTS[input.type]]; +} + +export function defaultsForProviderType( + type: AiProviderType +): readonly AiCapability[] { + if (type === "custom") { + return []; + } + return AI_PROVIDER_CAPABILITY_DEFAULTS[type]; +} diff --git a/server/routers/aiGateway/createAiGatewayRouter.ts b/server/routers/aiGateway/createAiGatewayRouter.ts new file mode 100644 index 000000000..cc62ade28 --- /dev/null +++ b/server/routers/aiGateway/createAiGatewayRouter.ts @@ -0,0 +1,21 @@ +import { Router } from "express"; +import { + AI_CAPABILITY_DEFS, + type AiCapability +} from "@server/lib/aiCapabilities"; +import { handleAiGatewayProxy } from "@server/routers/aiGateway/pipeline"; + +export function createAiGatewayRouter() { + const router = Router(); + + for (const def of Object.values(AI_CAPABILITY_DEFS)) { + const capability = def.id as AiCapability; + for (const route of def.routes) { + router.post(route.path, (req, res) => + handleAiGatewayProxy(req, res, capability) + ); + } + } + + return router; +} diff --git a/server/routers/aiGateway/index.ts b/server/routers/aiGateway/index.ts index 691ebebe8..6eea36d60 100644 --- a/server/routers/aiGateway/index.ts +++ b/server/routers/aiGateway/index.ts @@ -1 +1,2 @@ -export * from "./chatCompletions"; +export { handleAiGatewayProxy } from "./pipeline"; +export { createAiGatewayRouter } from "./createAiGatewayRouter"; diff --git a/server/routers/aiGateway/chatCompletions.ts b/server/routers/aiGateway/pipeline.ts similarity index 88% rename from server/routers/aiGateway/chatCompletions.ts rename to server/routers/aiGateway/pipeline.ts index d2671aa63..cf031c381 100644 --- a/server/routers/aiGateway/chatCompletions.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -22,6 +22,11 @@ import { applyAiProviderAuthHeaders, authTypeRequiresApiKey } from "@server/lib/aiProviderDefaults"; +import { + AI_CAPABILITY_DEFS, + providerHasCapability, + type AiCapability +} from "@server/lib/aiCapabilities"; import { SESSION_COOKIE_NAME, validateSessionToken @@ -45,10 +50,6 @@ const REQUEST_USER_TTL_SEC = 30; type CachedClient = { clientId: number; userId: string | null } | null; -// The set of CIDRs an exit node manages; client exitNodeSubnets are always -// /32s carved out of one of these ranges. Checking against this small, -// cacheable list lets us skip the (much more frequent) per-IP client lookup -// entirely for traffic that could never match a client anyway. async function getExitNodeRanges(): Promise { const cached = localCache.get(EXIT_NODE_RANGES_CACHE_KEY); if (cached) { @@ -96,8 +97,6 @@ type ResolvedTarget = { siteResourceId: number | null; orgId: string | null; attachments: ProviderAttachment[]; - // Model IDs on the resource allowlist that belong to allowlist-mode - // providers. Empty when no attached provider uses allowlist mode. allowlistedModelIds: Set; }; @@ -150,13 +149,9 @@ async function buildRequestUser( async function resolveRequestUser( req: Request, - resourceId: number | null, + _resourceId: number | null, orgId: string | null ): Promise { - // Public resources behind badger: badger passes the resource session - // cookie through to the backend (same mechanism the browser gateway, - // e.g. the SSH page, relies on), so we can validate it exactly like - // verifySessionUserMiddleware does for the dashboard. const sessionToken = req.cookies?.[SESSION_COOKIE_NAME]; if (sessionToken) { const { session, user } = await validateSessionToken(sessionToken); @@ -167,10 +162,6 @@ async function resolveRequestUser( // TODO: MAKE SURE THIS CAN NOT BE SPOOFED AND CAN BE TRUSTED AS AN INTERNAL ADDRESS FROM A NODE - // No session cookie - fall back to identifying the caller by source IP. - // A client's exitNodeSubnet is a /32 handed out from one of our exit - // node's address ranges, so an IP that isn't inside any of those ranges - // can never belong to a client and we can skip the DB entirely. const ip = req.ip; if (!ip) { return null; @@ -193,9 +184,6 @@ async function resolveRequestUser( } async function resolveTarget(host: string): Promise { - // TODO: eventually we need to know if it's a private or public resource - // and not just simply check the fullDomain in case there is a private resource with the same fullDomain - const [resourceRow] = await db .select({ resourceId: resources.resourceId, @@ -378,7 +366,6 @@ async function selectProvider( }; } - // One lookup for the requested model key across all attached providers. const matchingModels = await db .select({ modelId: aiModels.modelId, @@ -407,7 +394,6 @@ async function selectProvider( continue; } - // allowlist: only models explicitly attached to the resource if (allowlistedModelIds.has(model.modelId)) { candidates.push(attachment.provider); } @@ -432,11 +418,14 @@ async function selectProvider( }; } -export async function chatCompletions( +export async function handleAiGatewayProxy( req: Request, - res: Response + res: Response, + capability: AiCapability ): Promise { try { + const def = AI_CAPABILITY_DEFS[capability]; + const host = ( (req.headers["p-host"] as string | undefined) || req.headers.host || @@ -448,7 +437,7 @@ export async function chatCompletions( .json({ error: { message: "Missing Host header" } }); } - logger.info(`AI gateway request for host: ${host}`); + logger.info(`AI gateway ${capability} request for host: ${host}`); const target = await resolveTarget(host); if (!target) { @@ -461,11 +450,6 @@ export async function chatCompletions( const { attachments, allowlistedModelIds, resourceId, orgId } = target; - logger.debug("+++++ gateway target: ", target); - - // Best-effort identity resolution - not yet enforced, but lets us - // start making per-user access decisions (e.g. model/role-based - // restrictions) without another round of plumbing later. const requestUser = await resolveRequestUser(req, resourceId, orgId); if (requestUser) { logger.debug( @@ -473,11 +457,22 @@ export async function chatCompletions( ); } - const requestedModel = - typeof req.body?.model === "string" ? req.body.model : undefined; + const capableAttachments = attachments.filter((a) => + providerHasCapability(a.provider.capabilities, capability) + ); + + if (capableAttachments.length === 0) { + return res.status(HttpCode.FORBIDDEN).json({ + error: { + message: `No AI provider on this resource supports ${capability}` + } + }); + } + + const requestedModel = def.extractModel(req); const selection = await selectProvider( - attachments, + capableAttachments, allowlistedModelIds, requestedModel ); @@ -513,11 +508,12 @@ export async function chatCompletions( apiKey = decrypt(provider.apiKey, secret); } - const targetUrl = `${upstreamUrl.replace(/\/$/, "")}`; + const targetUrl = def.resolveUpstreamUrl( + upstreamUrl, + req, + requestedModel! + ); - // Drop hop-by-hop / proxy-only headers. Forwarding Host especially - // breaks Node fetch (TLS/SNI targets the upstream URL while Host - // still says localhost). const skipHeaders = new Set([ "p-host", "host", @@ -554,6 +550,7 @@ export async function chatCompletions( const body = JSON.stringify(req.body); logger.debug("AI gateway upstream request", { + capability, url: targetUrl, method: "POST", headers, @@ -591,7 +588,11 @@ export async function chatCompletions( const contentType = upstreamRes.headers.get("content-type") || ""; const isStream = req.body?.stream === true || - contentType.includes("text/event-stream"); + contentType.includes("text/event-stream") || + req.path.includes("streamGenerateContent") || + req.path.includes("streamRawPredict") || + req.path.includes("converse-stream") || + req.path.includes("invoke-with-response-stream"); res.status(upstreamRes.status); res.setHeader("Content-Type", contentType || "application/json"); diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts index 6188601a3..2fb61bf88 100644 --- a/server/routers/aiProvider/createAiProvider.ts +++ b/server/routers/aiProvider/createAiProvider.ts @@ -14,10 +14,15 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/ import { toPublicAiProvider } from "@server/routers/aiProvider/types"; import { aiAuthTypeSchema, + aiCapabilitiesSchema, aiProviderTypeSchema, aiRoutingModeSchema, refineProviderUpstreamFields } from "@server/routers/aiProvider/validation"; +import { + resolveCapabilitiesForCreate, + serializeCapabilities +} from "@server/lib/aiCapabilities"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -31,6 +36,7 @@ const bodySchema = z apiKey: z.string().optional(), authType: aiAuthTypeSchema.optional(), routingMode: aiRoutingModeSchema.optional(), + capabilities: aiCapabilitiesSchema.optional(), skipTlsVerification: z.boolean().optional(), enabled: z.boolean().optional() }) @@ -94,6 +100,7 @@ export async function createAiProvider( apiKey, authType, routingMode, + capabilities, skipTlsVerification, enabled } = parsedBody.data; @@ -108,6 +115,10 @@ export async function createAiProvider( authType, routingMode }); + const resolvedCapabilities = resolveCapabilitiesForCreate({ + type, + capabilities + }); const [provider] = await db .insert(aiProviders) @@ -120,6 +131,7 @@ export async function createAiProvider( apiKeyLastChars, authType: resolved.authType, routingMode: resolved.routingMode, + capabilities: serializeCapabilities(resolvedCapabilities), skipTlsVerification: skipTlsVerification ?? false, enabled: enabled ?? true, createdAt: now, diff --git a/server/routers/aiProvider/types.ts b/server/routers/aiProvider/types.ts index 7ac492056..5a1a41249 100644 --- a/server/routers/aiProvider/types.ts +++ b/server/routers/aiProvider/types.ts @@ -1,12 +1,17 @@ import type { AiModel, AiProvider } from "@server/db"; import type { PaginatedResponse } from "@server/types/Pagination"; import type { AiProviderAuthType } from "@server/lib/aiProviderDefaults"; +import { + parseCapabilities, + type AiCapability +} from "@server/lib/aiCapabilities"; import { decrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -export type AiProviderPublic = Omit & { +export type AiProviderPublic = Omit & { /** Decrypted API key. Only included on get/create/update of a single provider. */ apiKey?: string | null; + capabilities: AiCapability[]; effectiveUpstreamUrl: string | null; effectiveAuthType: AiProviderAuthType; }; @@ -39,7 +44,11 @@ export function toPublicAiProvider( provider: AiProvider, options?: { includeApiKey?: boolean } ): AiProviderPublic { - const { apiKey: encryptedApiKey, ...rest } = provider; + const { + apiKey: encryptedApiKey, + capabilities: rawCapabilities, + ...rest + } = provider; let apiKey: string | null | undefined; if (options?.includeApiKey) { @@ -56,6 +65,7 @@ export function toPublicAiProvider( return { ...rest, ...(options?.includeApiKey ? { apiKey } : {}), + capabilities: parseCapabilities(rawCapabilities), effectiveUpstreamUrl: provider.upstreamUrl, effectiveAuthType: provider.authType as AiProviderAuthType }; diff --git a/server/routers/aiProvider/updateAiProvider.ts b/server/routers/aiProvider/updateAiProvider.ts index 82224c28b..9dc28be60 100644 --- a/server/routers/aiProvider/updateAiProvider.ts +++ b/server/routers/aiProvider/updateAiProvider.ts @@ -14,6 +14,7 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/ import { toPublicAiProvider } from "@server/routers/aiProvider/types"; import { aiAuthTypeSchema, + aiCapabilitiesSchema, aiProviderTypeSchema, aiRoutingModeSchema, refineProviderUpstreamFields @@ -23,6 +24,10 @@ import type { AiProviderRoutingMode, AiProviderType } from "@server/lib/aiProviderDefaults"; +import { + parseCapabilities, + serializeCapabilities +} from "@server/lib/aiCapabilities"; const paramsSchema = z.strictObject({ providerId: z.coerce.number().int().positive() @@ -34,6 +39,7 @@ const bodySchema = z.strictObject({ apiKey: z.string().optional(), authType: aiAuthTypeSchema.optional(), routingMode: aiRoutingModeSchema.optional(), + capabilities: aiCapabilitiesSchema.optional(), skipTlsVerification: z.boolean().optional(), enabled: z.boolean().optional() }); @@ -122,19 +128,37 @@ export async function updateAiProvider( ? body.authType : (existing.authType as AiProviderAuthType); + if (body.capabilities !== undefined && providerType !== "custom") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Capabilities can only be updated for custom providers" + ) + ); + } + + const nextCapabilities = + providerType === "custom" + ? body.capabilities !== undefined + ? body.capabilities + : parseCapabilities(existing.capabilities) + : parseCapabilities(existing.capabilities); + const validation = z .object({ type: aiProviderTypeSchema, upstreamUrl: z.string().nullable().optional(), authType: aiAuthTypeSchema, - routingMode: aiRoutingModeSchema.optional() + routingMode: aiRoutingModeSchema.optional(), + capabilities: aiCapabilitiesSchema.optional() }) .superRefine((data, ctx) => refineProviderUpstreamFields(data, ctx)) .safeParse({ type: providerType, upstreamUrl: nextUpstreamUrl, authType: nextAuthType, - routingMode: nextRoutingMode + routingMode: nextRoutingMode, + capabilities: nextCapabilities }); if (!validation.success) { @@ -168,6 +192,9 @@ export async function updateAiProvider( if (body.authType !== undefined) { updateData.authType = body.authType; } + if (providerType === "custom" && body.capabilities !== undefined) { + updateData.capabilities = serializeCapabilities(body.capabilities); + } if (body.apiKey !== undefined) { const key = config.getRawConfig().server.secret!; diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts index b4ab104aa..957a3914f 100644 --- a/server/routers/aiProvider/validation.ts +++ b/server/routers/aiProvider/validation.ts @@ -6,6 +6,7 @@ import { type AiProviderRoutingMode, type AiProviderType } from "@server/lib/aiProviderDefaults"; +import { AI_CAPABILITIES } from "@server/lib/aiCapabilities"; export const aiProviderTypeSchema = z.enum([ "openai", @@ -23,12 +24,17 @@ export const aiAuthTypeSchema = z.enum(AI_PROVIDER_AUTH_TYPES); export const aiRoutingModeSchema = z.enum(["url", "target"]); +export const aiCapabilitySchema = z.enum(AI_CAPABILITIES); + +export const aiCapabilitiesSchema = z.array(aiCapabilitySchema); + export function refineProviderUpstreamFields( data: { type: AiProviderType; upstreamUrl?: string | null; authType?: AiProviderAuthType | null; routingMode?: AiProviderRoutingMode | null; + capabilities?: z.infer | null; }, ctx: z.RefinementCtx ) { @@ -52,4 +58,16 @@ export function refineProviderUpstreamFields( path: ["upstreamUrl"] }); } + + if (data.type === "custom") { + const caps = data.capabilities; + if (!caps || caps.length === 0) { + ctx.addIssue({ + code: "custom", + message: + "At least one capability is required for custom providers", + path: ["capabilities"] + }); + } + } } diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx index 1018f8e63..62ffa6eb6 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx @@ -12,11 +12,16 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; +import { + AiProviderCapabilitiesSelect, + capabilityLabelKey +} from "@app/components/AiProviderCapabilitiesSelect"; import { SwitchInput } from "@app/components/SwitchInput"; import { Button } from "@app/components/ui/button"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -28,6 +33,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { zodResolver } from "@hookform/resolvers/zod"; +import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; @@ -43,17 +49,32 @@ export default function AiProviderGeneralPage() { const router = useRouter(); const t = useTranslations(); const [saveLoading, setSaveLoading] = useState(false); + const isCustom = provider.type === "custom"; const generalSchema = useMemo( () => - z.object({ - name: z - .string() - .trim() - .min(1, { message: t("nameRequired") }), - enabled: z.boolean() - }), - [t] + z + .object({ + name: z + .string() + .trim() + .min(1, { message: t("nameRequired") }), + enabled: z.boolean(), + capabilities: z.array(z.enum(AI_CAPABILITIES)).optional() + }) + .superRefine((data, ctx) => { + if ( + isCustom && + (!data.capabilities || data.capabilities.length === 0) + ) { + ctx.addIssue({ + code: "custom", + message: t("aiProviderErrorCapabilitiesRequired"), + path: ["capabilities"] + }); + } + }), + [t, isCustom] ); type GeneralFormValues = z.infer; @@ -62,24 +83,35 @@ export default function AiProviderGeneralPage() { resolver: zodResolver(generalSchema), defaultValues: { name: provider.name, - enabled: provider.enabled + enabled: provider.enabled, + capabilities: provider.capabilities ?? [] } }); async function onSubmit(values: GeneralFormValues) { setSaveLoading(true); try { - const res = await api.post< - AxiosResponse - >(`/ai-provider/${provider.providerId}`, { + const body: { + name: string; + enabled: boolean; + capabilities?: AiCapability[]; + } = { name: values.name.trim(), enabled: values.enabled - }); + }; + if (isCustom) { + body.capabilities = values.capabilities ?? []; + } + + const res = await api.post< + AxiosResponse + >(`/ai-provider/${provider.providerId}`, body); const updated = res.data.data.provider; updateProvider(updated); form.reset({ name: updated.name, - enabled: updated.enabled + enabled: updated.enabled, + capabilities: updated.capabilities ?? [] }); toast({ title: t("success"), @@ -166,6 +198,65 @@ export default function AiProviderGeneralPage() { )} /> + + + ( + + + {t( + "aiProviderCapabilities" + )} + + + {isCustom ? ( + + ) : ( +
+ {( + provider.capabilities ?? + [] + ).map((cap) => ( + + {t( + capabilityLabelKey( + cap + ) + )} + + ))} +
+ )} +
+ + {isCustom + ? t( + "aiProviderCapabilitiesCustomDescription" + ) + : t( + "aiProviderCapabilitiesDescription" + )} + + +
+ )} + /> +
diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 1947c3f85..34fac7b55 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -20,6 +20,10 @@ import { } from "@app/components/Settings"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect"; +import { + AiProviderCapabilitiesSelect, + capabilityLabelKey +} from "@app/components/AiProviderCapabilitiesSelect"; import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect"; import { StrategySelect } from "@app/components/StrategySelect"; import { SwitchInput } from "@app/components/SwitchInput"; @@ -40,6 +44,7 @@ import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createAiProviderCreateFormSchema, defaultAuthTypeForProvider, + defaultCapabilitiesForProvider, emptyUpstreamForType, showsUpstreamUrlField, toAiProviderCreatePayload, @@ -76,6 +81,7 @@ export default function CreateAiProviderPage() { apiKey: "", authType: defaultAuthTypeForProvider("openai"), routingMode: "url", + capabilities: defaultCapabilitiesForProvider("openai"), skipTlsVerification: false, enabled: true } @@ -84,12 +90,14 @@ export default function CreateAiProviderPage() { const providerType = form.watch("type"); const routingMode = form.watch("routingMode"); const authType = form.watch("authType"); + const capabilities = form.watch("capabilities"); const showUpstream = showsUpstreamUrlField(providerType, routingMode); const requireUpstream = upstreamUrlRequired(providerType, routingMode); const showRoutingMode = providerType === "custom"; const showTargets = providerType === "custom" && routingMode === "target"; const showApiKey = authTypeRequiresApiKey(authType ?? "bearer"); + const showCapabilitiesSelect = providerType === "custom"; async function createTargets( providerId: number, @@ -277,6 +285,12 @@ export default function CreateAiProviderPage() { value ) ); + form.setValue( + "capabilities", + defaultCapabilitiesForProvider( + value + ) + ); if ( value !== "custom" @@ -296,6 +310,67 @@ export default function CreateAiProviderPage() { )} /> + + + ( + + + {t( + "aiProviderCapabilities" + )} + + + {showCapabilitiesSelect ? ( + + ) : ( +
+ {( + capabilities ?? + defaultCapabilitiesForProvider( + providerType + ) + ).map((cap) => ( + + {t( + capabilityLabelKey( + cap + ) + )} + + ))} +
+ )} +
+ + {showCapabilitiesSelect + ? t( + "aiProviderCapabilitiesCustomDescription" + ) + : t( + "aiProviderCapabilitiesDescription" + )} + + +
+ )} + /> +
diff --git a/src/components/AiProviderCapabilitiesSelect.tsx b/src/components/AiProviderCapabilitiesSelect.tsx new file mode 100644 index 000000000..086b6f03e --- /dev/null +++ b/src/components/AiProviderCapabilitiesSelect.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input"; +import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; + +export type CapabilityOption = { + id: string; + text: string; +}; + +export type AiProviderCapabilitiesSelectProps = { + value: AiCapability[]; + onChange: (capabilities: AiCapability[]) => void; + disabled?: boolean; +}; + +const CAPABILITY_LABEL_KEYS: Record = { + openai_chat: "aiCapabilityOpenaiChat", + openai_responses: "aiCapabilityOpenaiResponses", + anthropic_messages: "aiCapabilityAnthropicMessages", + gemini_generate_content: "aiCapabilityGeminiGenerateContent", + bedrock_model_invoke: "aiCapabilityBedrockModelInvoke", + google_generate_content: "aiCapabilityGoogleGenerateContent", + google_raw_predict: "aiCapabilityGoogleRawPredict", + bedrock_converse: "aiCapabilityBedrockConverse" +}; + +export function capabilityLabelKey(capability: AiCapability): string { + return CAPABILITY_LABEL_KEYS[capability]; +} + +export function AiProviderCapabilitiesSelect({ + value, + onChange, + disabled +}: AiProviderCapabilitiesSelectProps) { + const t = useTranslations(); + const [searchQuery, setSearchQuery] = useState(""); + + const options: CapabilityOption[] = useMemo( + () => + AI_CAPABILITIES.map((id) => ({ + id, + text: t(CAPABILITY_LABEL_KEYS[id]) + })), + [t] + ); + + const filtered = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) { + return options; + } + return options.filter( + (o) => + o.text.toLowerCase().includes(q) || + o.id.toLowerCase().includes(q) + ); + }, [options, searchQuery]); + + const selected: CapabilityOption[] = value.map((id) => ({ + id, + text: t(CAPABILITY_LABEL_KEYS[id]) + })); + + return ( + + onChange( + next + .map((item) => item.id) + .filter((id): id is AiCapability => + (AI_CAPABILITIES as readonly string[]).includes(id) + ) + ) + } + onSearch={setSearchQuery} + disabled={disabled} + /> + ); +} diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index f484d1f05..b01728478 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -7,6 +7,11 @@ import { type AiProviderAuthType, type AiProviderType } from "@server/lib/aiProviderDefaults"; +import { + AI_CAPABILITIES, + defaultsForProviderType, + type AiCapability +} from "@server/lib/aiCapabilities"; type TranslateFn = (key: string) => string; @@ -22,6 +27,8 @@ export const aiProviderTypeValues = [ "custom" ] as const satisfies readonly AiProviderType[]; +export const aiCapabilityValues = AI_CAPABILITIES; + export function createAiProviderFormSchema(t: TranslateFn) { return z .object({ @@ -34,6 +41,7 @@ export function createAiProviderFormSchema(t: TranslateFn) { apiKey: z.string().optional(), authType: z.enum(AI_PROVIDER_AUTH_TYPES).optional().nullable(), routingMode: z.enum(["url", "target"]).optional(), + capabilities: z.array(z.enum(AI_CAPABILITIES)).optional(), skipTlsVerification: z.boolean().optional(), enabled: z.boolean().optional() }) @@ -84,6 +92,17 @@ export function createAiProviderFormSchema(t: TranslateFn) { path: ["authType"] }); } + + if ( + data.type === "custom" && + (!data.capabilities || data.capabilities.length === 0) + ) { + ctx.addIssue({ + code: "custom", + message: t("aiProviderErrorCapabilitiesRequired"), + path: ["capabilities"] + }); + } }); } @@ -114,6 +133,12 @@ export function defaultAuthTypeForProvider( return AI_PROVIDER_DEFAULTS[type].authType; } +export function defaultCapabilitiesForProvider( + type: AiProviderType +): AiCapability[] { + return [...defaultsForProviderType(type)]; +} + export function emptyUpstreamForType(type: AiProviderType): string { if (type === "custom") { return ""; @@ -158,6 +183,8 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) { upstreamUrl, apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined, authType: values.authType ?? "bearer", + capabilities: + values.type === "custom" ? (values.capabilities ?? []) : undefined, skipTlsVerification: values.skipTlsVerification, enabled: values.enabled ?? true }; @@ -183,6 +210,10 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) { enabled: values.enabled ?? true }; + if (values.type === "custom" && values.capabilities) { + payload.capabilities = values.capabilities; + } + if (values.apiKey?.trim()) { payload.apiKey = values.apiKey.trim(); } From 3d7e322bf97cd5771c515a8b8ef9815cce7c9daf Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 5 Aug 2026 17:42:58 -0400 Subject: [PATCH 222/494] add headers to provider --- cli/commands/rotateServerSecret.ts | 55 ++++++++++++++++++- messages/en-US.json | 1 + server/db/pg/schema/schema.ts | 1 + server/db/sqlite/schema/schema.ts | 1 + server/lib/aiProviderDefaults.ts | 49 +++++++++++++++++ server/routers/aiGateway/pipeline.ts | 6 ++ server/routers/aiProvider/createAiProvider.ts | 5 ++ server/routers/aiProvider/types.ts | 20 ++++++- server/routers/aiProvider/updateAiProvider.ts | 16 ++++-- server/routers/aiProvider/validation.ts | 43 +++++++++++++++ .../[providerId]/network/page.tsx | 38 ++++++++++++- .../settings/ai-providers/create/page.tsx | 37 ++++++++++++- src/lib/aiProviderFormSchema.ts | 10 +++- 13 files changed, 271 insertions(+), 11 deletions(-) diff --git a/cli/commands/rotateServerSecret.ts b/cli/commands/rotateServerSecret.ts index afac262b2..2edb7592d 100644 --- a/cli/commands/rotateServerSecret.ts +++ b/cli/commands/rotateServerSecret.ts @@ -1,5 +1,5 @@ import { CommandModule } from "yargs"; -import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db"; +import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions, aiProviders } from "@server/db"; import { encrypt, decrypt } from "@server/lib/crypto"; import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { eq } from "drizzle-orm"; @@ -132,12 +132,14 @@ export const rotateServerSecret: CommandModule< const certs = await db.select().from(certificates); const streamingDestinations = await db.select().from(eventStreamingDestinations); const webhookActions = await db.select().from(alertWebhookActions); + const providers = await db.select().from(aiProviders); console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`); console.log(`Found ${licenseKeys.length} license key(s)`); console.log(`Found ${certs.length} certificate(s)`); console.log(`Found ${streamingDestinations.length} event streaming destination(s)`); console.log(`Found ${webhookActions.length} alert webhook action(s)`); + console.log(`Found ${providers.length} AI provider(s)`); // Prepare all decrypted and re-encrypted values console.log("\nDecrypting and re-encrypting values..."); @@ -171,11 +173,18 @@ export const rotateServerSecret: CommandModule< encryptedConfig: string; }; + type AiProviderUpdate = { + providerId: number; + encryptedApiKey: string | null; + encryptedHeaders: string | null; + }; + const idpUpdates: IdpUpdate[] = []; const licenseKeyUpdates: LicenseKeyUpdate[] = []; const certUpdates: CertUpdate[] = []; const streamingDestinationUpdates: StreamingDestinationUpdate[] = []; const webhookActionUpdates: WebhookActionUpdate[] = []; + const aiProviderUpdates: AiProviderUpdate[] = []; // Process idpOidcConfig entries for (const idpConfig of idpConfigs) { @@ -306,6 +315,37 @@ export const rotateServerSecret: CommandModule< } } + // Process aiProviders entries (apiKey + headers) + for (const provider of providers) { + try { + if (!provider.apiKey && !provider.headers) { + continue; + } + + const encryptedApiKey = provider.apiKey + ? encrypt(decrypt(provider.apiKey, oldSecret), newSecret) + : null; + const encryptedHeaders = provider.headers + ? encrypt( + decrypt(provider.headers, oldSecret), + newSecret + ) + : null; + + aiProviderUpdates.push({ + providerId: provider.providerId, + encryptedApiKey, + encryptedHeaders + }); + } catch (error) { + console.error( + `Error processing AI provider ${provider.providerId}:`, + error + ); + throw error; + } + } + // Perform all database updates in a single transaction console.log("\nUpdating database in transaction..."); await db.transaction(async (trx) => { @@ -376,6 +416,17 @@ export const rotateServerSecret: CommandModule< ) ); } + + // Update AI provider entries + for (const update of aiProviderUpdates) { + await trx + .update(aiProviders) + .set({ + apiKey: update.encryptedApiKey, + headers: update.encryptedHeaders + }) + .where(eq(aiProviders.providerId, update.providerId)); + } }); console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`); @@ -383,6 +434,7 @@ export const rotateServerSecret: CommandModule< console.log(`Rotated ${certUpdates.length} certificate(s)`); console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`); console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`); + console.log(`Rotated ${aiProviderUpdates.length} AI provider(s)`); // Update config file with new secret console.log("\nUpdating config file..."); @@ -402,6 +454,7 @@ export const rotateServerSecret: CommandModule< console.log(` - Certificates: ${certUpdates.length}`); console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`); console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`); + console.log(` - AI providers: ${aiProviderUpdates.length}`); console.log( `\n IMPORTANT: Restart the server for the new secret to take effect.` ); diff --git a/messages/en-US.json b/messages/en-US.json index ac6131abc..a10a45421 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1680,6 +1680,7 @@ "aiProviderEffectiveUpstreamUrl": "Effective Upstream URL", "aiProviderApiKey": "API Key", "aiProviderApiKeyDescription": "API key used to authenticate requests to this provider", + "aiProviderCustomHeadersDescription": "Headers sent on every request to this provider. Newline separated: Header-Name: value", "aiProviderApiKeyLastChars": "API Key", "aiProviderAuthType": "Auth Type", "aiProviderAuthTypeSearch": "Search auth types...", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 3091ed618..6bc038660 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1660,6 +1660,7 @@ export const aiProviders = pgTable("aiProviders", { .notNull() .default("url"), capabilities: text("capabilities").notNull().default("[]"), + headers: text("headers"), // JSON array of { name, value } skipTlsVerification: boolean("skipTlsVerification") .notNull() .default(false), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 5c115cb2e..8130af9ea 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1642,6 +1642,7 @@ export const aiProviders = sqliteTable("aiProviders", { .notNull() .default("url"), capabilities: text("capabilities").notNull().default("[]"), + headers: text("headers"), // JSON array of { name, value } skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" }) .notNull() .default(false), diff --git a/server/lib/aiProviderDefaults.ts b/server/lib/aiProviderDefaults.ts index 70b666954..e48c6f877 100644 --- a/server/lib/aiProviderDefaults.ts +++ b/server/lib/aiProviderDefaults.ts @@ -1,3 +1,5 @@ +import { decrypt, encrypt } from "@server/lib/crypto"; + export type AiProviderType = | "openai" | "anthropic" @@ -127,6 +129,53 @@ export function resolveAiProviderCreateFields(input: { }; } +export type AiProviderHeader = { name: string; value: string }; + +export function serializeAiProviderHeaders( + headers: AiProviderHeader[] | null | undefined, + secret: string +): string | null { + if (!headers || headers.length === 0) { + return null; + } + return encrypt(JSON.stringify(headers), secret); +} + +export function parseAiProviderHeaders( + raw: string | null | undefined, + secret: string +): AiProviderHeader[] { + if (!raw) { + return []; + } + try { + const decrypted = decrypt(raw, secret); + const parsed = JSON.parse(decrypted); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter( + (h): h is AiProviderHeader => + h != null && + typeof h === "object" && + typeof h.name === "string" && + typeof h.value === "string" + ); + } catch { + return []; + } +} + +export function applyAiProviderCustomHeaders( + headers: Record, + raw: string | null | undefined, + secret: string +): void { + for (const { name, value } of parseAiProviderHeaders(raw, secret)) { + headers[name] = value; + } +} + /** * Apply provider auth to upstream headers. * - Injected modes: strip client auth headers, then set the provider key. diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index cf031c381..44e2aef66 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -20,6 +20,7 @@ import { decrypt } from "@server/lib/crypto"; import { AiProviderAuthType, applyAiProviderAuthHeaders, + applyAiProviderCustomHeaders, authTypeRequiresApiKey } from "@server/lib/aiProviderDefaults"; import { @@ -536,6 +537,11 @@ export async function handleAiGatewayProxy( } headers[key] = Array.isArray(value) ? value.join(", ") : value; } + applyAiProviderCustomHeaders( + headers, + provider.headers, + config.getRawConfig().server.secret! + ); applyAiProviderAuthHeaders(headers, authType, apiKey); // No dedicated per-request TLS agent is wired up (no extra deps for diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts index 2fb61bf88..995e9eaa0 100644 --- a/server/routers/aiProvider/createAiProvider.ts +++ b/server/routers/aiProvider/createAiProvider.ts @@ -15,10 +15,12 @@ import { toPublicAiProvider } from "@server/routers/aiProvider/types"; import { aiAuthTypeSchema, aiCapabilitiesSchema, + aiProviderHeadersSchema, aiProviderTypeSchema, aiRoutingModeSchema, refineProviderUpstreamFields } from "@server/routers/aiProvider/validation"; +import { serializeAiProviderHeaders } from "@server/lib/aiProviderDefaults"; import { resolveCapabilitiesForCreate, serializeCapabilities @@ -37,6 +39,7 @@ const bodySchema = z authType: aiAuthTypeSchema.optional(), routingMode: aiRoutingModeSchema.optional(), capabilities: aiCapabilitiesSchema.optional(), + headers: aiProviderHeadersSchema, skipTlsVerification: z.boolean().optional(), enabled: z.boolean().optional() }) @@ -101,6 +104,7 @@ export async function createAiProvider( authType, routingMode, capabilities, + headers, skipTlsVerification, enabled } = parsedBody.data; @@ -132,6 +136,7 @@ export async function createAiProvider( authType: resolved.authType, routingMode: resolved.routingMode, capabilities: serializeCapabilities(resolvedCapabilities), + headers: serializeAiProviderHeaders(headers, key), skipTlsVerification: skipTlsVerification ?? false, enabled: enabled ?? true, createdAt: now, diff --git a/server/routers/aiProvider/types.ts b/server/routers/aiProvider/types.ts index 5a1a41249..d2f314b30 100644 --- a/server/routers/aiProvider/types.ts +++ b/server/routers/aiProvider/types.ts @@ -1,6 +1,10 @@ import type { AiModel, AiProvider } from "@server/db"; import type { PaginatedResponse } from "@server/types/Pagination"; -import type { AiProviderAuthType } from "@server/lib/aiProviderDefaults"; +import { + parseAiProviderHeaders, + type AiProviderAuthType, + type AiProviderHeader +} from "@server/lib/aiProviderDefaults"; import { parseCapabilities, type AiCapability @@ -8,10 +12,13 @@ import { import { decrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -export type AiProviderPublic = Omit & { - /** Decrypted API key. Only included on get/create/update of a single provider. */ +export type AiProviderPublic = Omit< + AiProvider, + "apiKey" | "capabilities" | "headers" +> & { apiKey?: string | null; capabilities: AiCapability[]; + headers: AiProviderHeader[] | null; effectiveUpstreamUrl: string | null; effectiveAuthType: AiProviderAuthType; }; @@ -47,6 +54,7 @@ export function toPublicAiProvider( const { apiKey: encryptedApiKey, capabilities: rawCapabilities, + headers: rawHeaders, ...rest } = provider; @@ -62,10 +70,16 @@ export function toPublicAiProvider( } } + const parsedHeaders = parseAiProviderHeaders( + rawHeaders, + config.getRawConfig().server.secret! + ); + return { ...rest, ...(options?.includeApiKey ? { apiKey } : {}), capabilities: parseCapabilities(rawCapabilities), + headers: parsedHeaders.length > 0 ? parsedHeaders : null, effectiveUpstreamUrl: provider.upstreamUrl, effectiveAuthType: provider.authType as AiProviderAuthType }; diff --git a/server/routers/aiProvider/updateAiProvider.ts b/server/routers/aiProvider/updateAiProvider.ts index 9dc28be60..7cbf1145a 100644 --- a/server/routers/aiProvider/updateAiProvider.ts +++ b/server/routers/aiProvider/updateAiProvider.ts @@ -15,14 +15,16 @@ import { toPublicAiProvider } from "@server/routers/aiProvider/types"; import { aiAuthTypeSchema, aiCapabilitiesSchema, + aiProviderHeadersSchema, aiProviderTypeSchema, aiRoutingModeSchema, refineProviderUpstreamFields } from "@server/routers/aiProvider/validation"; -import type { - AiProviderAuthType, - AiProviderRoutingMode, - AiProviderType +import { + serializeAiProviderHeaders, + type AiProviderAuthType, + type AiProviderRoutingMode, + type AiProviderType } from "@server/lib/aiProviderDefaults"; import { parseCapabilities, @@ -40,6 +42,7 @@ const bodySchema = z.strictObject({ authType: aiAuthTypeSchema.optional(), routingMode: aiRoutingModeSchema.optional(), capabilities: aiCapabilitiesSchema.optional(), + headers: aiProviderHeadersSchema, skipTlsVerification: z.boolean().optional(), enabled: z.boolean().optional() }); @@ -202,6 +205,11 @@ export async function updateAiProvider( updateData.apiKeyLastChars = body.apiKey.slice(-4); } + if (body.headers !== undefined) { + const key = config.getRawConfig().server.secret!; + updateData.headers = serializeAiProviderHeaders(body.headers, key); + } + const [provider] = await db .update(aiProviders) .set(updateData) diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts index 957a3914f..a56ccad1c 100644 --- a/server/routers/aiProvider/validation.ts +++ b/server/routers/aiProvider/validation.ts @@ -28,6 +28,49 @@ export const aiCapabilitySchema = z.enum(AI_CAPABILITIES); export const aiCapabilitiesSchema = z.array(aiCapabilitySchema); +const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/; +const validHeaderValue = /^[\t\x20-\x7E]*$/; +const templatePattern = /\{\{[^}]+\}\}/; + +export const aiProviderHeadersSchema = z + .array(z.strictObject({ name: z.string(), value: z.string() })) + .nullable() + .optional() + .superRefine((headers, ctx) => { + if (!headers) { + return; + } + for (const [index, header] of headers.entries()) { + if (!validHeaderName.test(header.name)) { + ctx.addIssue({ + code: "custom", + message: + "Header names may only contain valid HTTP token characters (letters, digits, and !#$%&'*+-.^_`|~).", + path: [index, "name"] + }); + } + if (!validHeaderValue.test(header.value)) { + ctx.addIssue({ + code: "custom", + message: + "Header values may only contain printable ASCII characters and horizontal whitespace.", + path: [index, "value"] + }); + } + if ( + templatePattern.test(header.name) || + templatePattern.test(header.value) + ) { + ctx.addIssue({ + code: "custom", + message: + "Header names and values must not contain template expressions such as {{value}}.", + path: [index] + }); + } + } + }); + export function refineProviderUpstreamFields( data: { type: AiProviderType; diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx index 49f251cfa..8b8814303 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx @@ -21,6 +21,7 @@ import { } from "@app/components/Settings"; import { StrategySelect } from "@app/components/StrategySelect"; import { SwitchInput } from "@app/components/SwitchInput"; +import { HeadersInput } from "@app/components/HeadersInput"; import { Button } from "@app/components/ui/button"; import { Form, @@ -66,6 +67,7 @@ export default function AiProviderNetworkPage() { const router = useRouter(); const t = useTranslations(); const [saveLoading, setSaveLoading] = useState(false); + const [headersValid, setHeadersValid] = useState(true); const targetsFormRef = useRef(null); const formSchema = useMemo(() => createAiProviderFormSchema(t), [t]); @@ -79,6 +81,7 @@ export default function AiProviderNetworkPage() { apiKey: "", authType: (provider.authType as AiProviderAuthType) ?? "bearer", routingMode: (provider.routingMode as "url" | "target") ?? "url", + headers: provider.headers ?? [], skipTlsVerification: provider.skipTlsVerification, enabled: provider.enabled } @@ -122,6 +125,7 @@ export default function AiProviderNetworkPage() { apiKey: "", authType: (updated.authType as AiProviderAuthType) ?? "bearer", routingMode: (updated.routingMode as "url" | "target") ?? "url", + headers: updated.headers ?? [], skipTlsVerification: updated.skipTlsVerification, enabled: updated.enabled }); @@ -310,6 +314,38 @@ export default function AiProviderNetworkPage() { /> )} + + + ( + + + {t("customHeaders")} + + + + + + {t( + "aiProviderCustomHeadersDescription" + )} + + + + )} + /> + @@ -346,7 +382,7 @@ export default function AiProviderNetworkPage() {
)} diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 457702c38..0d9541e54 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -591,6 +591,7 @@ export default function CreateAiProviderPage() { )} embedded hideSaveButton + disableAdvancedMode />
)} diff --git a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx index bc2f985e5..1440e6c02 100644 --- a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx +++ b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx @@ -104,6 +104,8 @@ type ProxyResourceTargetsFormProps = { embedded?: boolean; /** Hide the built-in save button (use ref.save from parent) */ hideSaveButton?: boolean; + /** Hide the advanced mode toggle and always use non-advanced mode (e.g. AI providers) */ + disableAdvancedMode?: boolean; }; export const ProxyResourceTargetsForm = forwardRef< @@ -121,7 +123,8 @@ export const ProxyResourceTargetsForm = forwardRef< allowedMethods = ["http", "https", "h2c"], emptyMessage, embedded = false, - hideSaveButton = false + hideSaveButton = false, + disableAdvancedMode = false }, ref ) { @@ -221,6 +224,9 @@ export const ProxyResourceTargetsForm = forwardRef< ); const [isAdvancedMode, setIsAdvancedMode] = useState(() => { + if (disableAdvancedMode) { + return false; + } if (typeof window !== "undefined") { const saved = localStorage.getItem("proxy-advanced-mode"); return saved === "true"; @@ -709,13 +715,14 @@ export const ProxyResourceTargetsForm = forwardRef< }, [sites]); useEffect(() => { + if (disableAdvancedMode) return; if (typeof window !== "undefined") { localStorage.setItem( "proxy-advanced-mode", isAdvancedMode.toString() ); } - }, [isAdvancedMode]); + }, [isAdvancedMode, disableAdvancedMode]); const [, formAction, isSubmitting] = useActionState( async () => { @@ -940,19 +947,21 @@ export const ProxyResourceTargetsForm = forwardRef<
{addTargetButton} -
- - -
+ {!disableAdvancedMode && ( +
+ + +
+ )}
)} From 82b86263dc0584f577b7024dc891967b57608df5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 6 Aug 2026 11:39:06 -0400 Subject: [PATCH 227/494] allow chars in 2fa input form closes #3532 --- src/components/ContactSalesBanner.tsx | 24 +++++++++++++----------- src/components/MfaInputForm.tsx | 8 +++++--- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/components/ContactSalesBanner.tsx b/src/components/ContactSalesBanner.tsx index e5cb87d83..38b9d21ed 100644 --- a/src/components/ContactSalesBanner.tsx +++ b/src/components/ContactSalesBanner.tsx @@ -24,19 +24,21 @@ export function ContactSalesBanner() { {" " + t("contactSalesOr") + " "} - - {t("contactSalesContactUs")} - - - . + + + {t("contactSalesContactUs")} + + + . +
); -} \ No newline at end of file +} diff --git a/src/components/MfaInputForm.tsx b/src/components/MfaInputForm.tsx index e80c730fc..6a2fdc55a 100644 --- a/src/components/MfaInputForm.tsx +++ b/src/components/MfaInputForm.tsx @@ -13,7 +13,7 @@ import { import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { useTranslations } from "next-intl"; -import { REGEXP_ONLY_DIGITS } from "input-otp"; +import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; const MFA_OTP_INPUT_ID = "mfa-otp-code"; @@ -82,9 +82,11 @@ export default function MfaInputForm({ maxLength={6} {...field} autoComplete="one-time-code" - inputMode="numeric" + inputMode="text" autoFocus - pattern={REGEXP_ONLY_DIGITS} + pattern={ + REGEXP_ONLY_DIGITS_AND_CHARS + } onChange={(value: string) => { field.onChange(value); if (value.length === 6) { From fb896d6c0f0a9262c87c58d9bfefb053515723f5 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Aug 2026 12:03:40 -0400 Subject: [PATCH 228/494] Add domain to the resource selection --- messages/en-US.json | 2 + .../siteResource/createSiteResource.ts | 20 +- .../siteResource/updateSiteResource.ts | 18 +- .../private/[niceId]/general/page.tsx | 30 +- .../private/[niceId]/inference/page.tsx | 325 +++++++++++++++++- .../resources/private/[niceId]/layout.tsx | 45 +-- .../private/[niceId]/providers/page.tsx | 230 ------------- .../public/[niceId]/inference/page.tsx | 237 ++++++++++++- .../resources/public/[niceId]/layout.tsx | 15 +- .../public/[niceId]/providers/page.tsx | 230 ------------- src/lib/privateResourceForm.ts | 8 +- 11 files changed, 590 insertions(+), 570 deletions(-) delete mode 100644 src/app/[orgId]/settings/resources/private/[niceId]/providers/page.tsx delete mode 100644 src/app/[orgId]/settings/resources/public/[niceId]/providers/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index a10a45421..f5de37878 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1771,6 +1771,8 @@ "aiResourceProvidersUpdated": "Providers updated", "aiResourceProvidersErrorUpdate": "Failed to update providers", "aiResourceAliasRequired": "Alias is required for inference resources", + "aiResourceDomainConfiguration": "Domain configuration", + "aiResourceDomainConfigurationDescription": "Choose the domain clients will use to reach this inference resource.", "sidebarApiKeys": "API Keys", "sidebarProvisioning": "Provisioning", "sidebarSettings": "Settings", diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index feeca0938..815d7adb8 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -168,7 +168,7 @@ const createSiteResourceSchema = z ) .refine( (data) => { - // destination is only optional for ssh mode with native authDaemonMode + // destination is only optional for ssh mode with native authDaemonMode or inference if ( (data.mode === "ssh" && data.authDaemonMode === "native") || data.mode == "inference" @@ -182,7 +182,7 @@ const createSiteResourceSchema = z }, { message: - "Destination is required unless mode is ssh with authDaemonMode native" + "Destination is required unless mode is ssh with authDaemonMode native or inference" } ) .refine( @@ -509,7 +509,15 @@ export async function createSiteResource( const existingResource = await db .select() .from(siteResources) - .where(eq(siteResources.fullDomain, fullDomain)); + .where( + and( + eq(siteResources.fullDomain, fullDomain), + ne( + siteResources.requiresExitNodeConnection, + mode == "inference" + ) + ) + ); // exclude looking at the ones on exit nodes if this is an inference resource if (existingResource.length > 0) { return next( @@ -529,11 +537,7 @@ export async function createSiteResource( .where( and( eq(siteResources.orgId, orgId), - eq(siteResources.alias, alias.trim()), - ne( - siteResources.requiresExitNodeConnection, - mode == "inference" - ) // exclude looking at the ones on exit nodes if this is an inference resource + eq(siteResources.alias, alias.trim()) ) ) .limit(1); diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 457c401d4..754c82a65 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -172,7 +172,7 @@ const updateSiteResourceSchema = z }, { message: - "Destination is required unless mode is ssh with authDaemonMode native" + "Destination is required unless mode is ssh with authDaemonMode native or inference" } ) .refine( @@ -481,7 +481,15 @@ export async function updateSiteResource( const [existingDomain] = await db .select() .from(siteResources) - .where(eq(siteResources.fullDomain, fullDomain)); + .where( + and( + eq(siteResources.fullDomain, fullDomain), + ne( + siteResources.requiresExitNodeConnection, + mode == "inference" + ) + ) + ); // exclude looking at the ones on exit nodes if this is an inference resource if ( existingDomain && @@ -511,11 +519,7 @@ export async function updateSiteResource( and( eq(siteResources.orgId, existingSiteResource.orgId), eq(siteResources.alias, alias.trim()), - ne(siteResources.siteResourceId, siteResourceId), // exclude self - ne( - siteResources.requiresExitNodeConnection, - mode == "inference" - ) // exclude looking at the ones on exit nodes if this is an inference resource + ne(siteResources.siteResourceId, siteResourceId) // exclude self ) ) .limit(1); diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx index b0af75ec0..8dbdb3809 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx @@ -24,9 +24,7 @@ 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"; @@ -37,12 +35,8 @@ 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, { requireAlias: isInference }), - [t, isInference] - ); + const formSchema = useMemo(() => createGeneralFormSchema(t), [t]); type FormValues = z.infer; const form = useForm({ @@ -50,8 +44,7 @@ export default function PrivateResourceGeneralPage() { defaultValues: { name: siteResource.name, niceId: siteResource.niceId, - enabled: siteResource.enabled, - alias: siteResource.alias ?? null + enabled: siteResource.enabled } }); @@ -63,13 +56,7 @@ export default function PrivateResourceGeneralPage() { await save({ name: data.name, niceId: data.niceId, - enabled: data.enabled, - ...(isInference - ? { - mode: "inference" as const, - alias: data.alias - } - : {}) + enabled: data.enabled }); }, null); @@ -165,17 +152,6 @@ export default function PrivateResourceGeneralPage() { )} /> - {isInference && ( - - - - )} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx index 34534fdd4..78f13370c 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx @@ -1,15 +1,318 @@ -import type { Metadata } from "next"; -import { redirect } from "next/navigation"; +"use client"; -export const metadata: Metadata = { - title: "Private Resource" -}; +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSubsectionDescription, + SettingsSubsectionHeader, + SettingsSubsectionTitle +} from "@app/components/Settings"; +import { + AiProvidersSelector, + type SelectedAiProvider +} from "@app/components/AiProvidersSelector"; +import DomainPicker from "@app/components/DomainPicker"; +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 { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; +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 async function PrivateResourceInferencePage(props: { - params: Promise<{ niceId: string; orgId: string }>; -}) { - const params = await props.params; - redirect( - `/${params.orgId}/settings/resources/private/${params.niceId}/providers` +export default function PrivateResourceInferencePage() { + const t = useTranslations(); + const router = useRouter(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const queryClient = useQueryClient(); + const { siteResource } = useSiteResourceContext(); + const { save } = useSaveSiteResource(); + + 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()), + httpConfigSubdomain: z.string().nullish(), + httpConfigDomainId: z.string().nullish(), + httpConfigFullDomain: z.string().nullish() + }), + [] + ); + type FormValues = z.infer; + + const [selectedProviders, setSelectedProviders] = useState< + SelectedAiProvider[] + >([]); + + const attachedQuery = useQuery({ + ...resourceQueries.siteResourceAiProviders({ + siteResourceId: siteResource.id + }), + enabled: siteResource.mode === "inference" + }); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerIds: [], + httpConfigSubdomain: siteResource.subdomain ?? null, + httpConfigDomainId: siteResource.domainId ?? null, + httpConfigFullDomain: siteResource.fullDomain ?? null + } + }); + + const httpConfigSubdomain = form.watch("httpConfigSubdomain"); + const httpConfigDomainId = form.watch("httpConfigDomainId"); + const httpConfigFullDomain = form.watch("httpConfigFullDomain"); + + useEffect(() => { + if (!attachedQuery.data) return; + const providers = attachedQuery.data.map((provider) => ({ + id: String(provider.providerId), + text: provider.name + })); + setSelectedProviders(providers); + form.setValue( + "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 save({ + mode: "inference", + httpConfigSubdomain: data.httpConfigSubdomain, + httpConfigDomainId: data.httpConfigDomainId, + httpConfigFullDomain: data.httpConfigFullDomain + }); + + 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 ( + + + + + {t("aiResourceProviders")} + + + {t("aiResourceProvidersDescription")} + + + + + +
+ + + + ( + + + {t( + "aiResourceProviders" + )} + + + { + setSelectedProviders( + providers + ); + form.setValue( + "providerIds", + providers.map( + (p) => + parseInt( + p.id, + 10 + ) + ), + { + shouldValidate: true + } + ); + }} + /> + + + {t( + "aiResourceProvidersHelp" + )} + + + + )} + /> + + + + + + {t( + "aiResourceDomainConfiguration" + )} + + + {t( + "aiResourceDomainConfigurationDescription" + )} + + + + + { + if (res === null) { + form.setValue( + "httpConfigSubdomain", + null + ); + form.setValue( + "httpConfigDomainId", + null + ); + form.setValue( + "httpConfigFullDomain", + null + ); + return; + } + form.setValue( + "httpConfigSubdomain", + res.subdomain ?? null + ); + form.setValue( + "httpConfigDomainId", + res.domainId + ); + form.setValue( + "httpConfigFullDomain", + res.fullDomain + ); + }} + /> + + +
+ +
+
+ + + + +
+
); } diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx index 57064e545..f07b92673 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx @@ -55,37 +55,20 @@ export default async function PrivateResourceLayout( | "sshSettings" | "inferenceSettings"; - 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` - } - ]; + 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` + } + ]; return ( <> diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/providers/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/providers/page.tsx deleted file mode 100644 index dcbc4ea29..000000000 --- a/src/app/[orgId]/settings/resources/private/[niceId]/providers/page.tsx +++ /dev/null @@ -1,230 +0,0 @@ -"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()) - }), - [] - ); - type FormValues = z.infer; - - const [selectedProviders, setSelectedProviders] = useState< - SelectedAiProvider[] - >([]); - - const attachedQuery = useQuery({ - ...resourceQueries.siteResourceAiProviders({ - siteResourceId: siteResource.id - }), - enabled: siteResource.mode === "inference" - }); - - const form = useForm({ - 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 ( - - - - - {t("aiResourceProviders")} - - - {t("aiResourceProvidersDescription")} - - - - - -
- - - - ( - - - {t( - "aiResourceProviders" - )} - - - { - setSelectedProviders( - providers - ); - form.setValue( - "providerIds", - providers.map( - (p) => - parseInt( - p.id, - 10 - ) - ), - { - shouldValidate: true - } - ); - }} - /> - - - {t( - "aiResourceProvidersHelp" - )} - - - - )} - /> - - -
- -
-
- - - - -
-
- ); -} diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx index 8ea8d2dd5..8741159b4 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx @@ -1,15 +1,230 @@ -import type { Metadata } from "next"; -import { redirect } from "next/navigation"; +"use client"; -export const metadata: Metadata = { - title: "Public Resource" -}; +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 { useResourceContext } from "@app/hooks/useResourceContext"; +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 async function PublicResourceInferencePage(props: { - params: Promise<{ niceId: string; orgId: string }>; -}) { - const params = await props.params; - redirect( - `/${params.orgId}/settings/resources/public/${params.niceId}/providers` +export default function PublicResourceInferencePage() { + const t = useTranslations(); + const router = useRouter(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const queryClient = useQueryClient(); + const { resource } = useResourceContext(); + + useEffect(() => { + if (resource.mode !== "inference") { + router.replace( + `/${resource.orgId}/settings/resources/public/${resource.niceId}/general` + ); + } + }, [router, resource.mode, resource.niceId, resource.orgId]); + + const formSchema = useMemo( + () => + z.object({ + providerIds: z.array(z.number().int().positive()) + }), + [] + ); + type FormValues = z.infer; + + const [selectedProviders, setSelectedProviders] = useState< + SelectedAiProvider[] + >([]); + + const attachedQuery = useQuery({ + ...resourceQueries.resourceAiProviders({ + resourceId: resource.resourceId + }), + enabled: resource.mode === "inference" + }); + + const form = useForm({ + 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(`/resource/${resource.resourceId}/ai-providers`, { + providers: data.providerIds.map((providerId) => ({ + providerId, + modelAccessMode: "catalog" + })) + }); + + await queryClient.invalidateQueries( + resourceQueries.resourceAiProviders({ + resourceId: resource.resourceId + }) + ); + + toast({ + title: t("success"), + description: t("aiResourceProvidersUpdated") + }); + } catch (error) { + toast({ + variant: "destructive", + title: t("aiResourceProvidersErrorUpdate"), + description: formatAxiosError( + error, + t("aiResourceProvidersErrorUpdate") + ) + }); + } + }, null); + + if (resource.mode !== "inference") { + return null; + } + + return ( + + + + + {t("aiResourceProviders")} + + + {t("aiResourceProvidersDescription")} + + + + + +
+ + + + ( + + + {t( + "aiResourceProviders" + )} + + + { + setSelectedProviders( + providers + ); + form.setValue( + "providerIds", + providers.map( + (p) => + parseInt( + p.id, + 10 + ) + ), + { + shouldValidate: true + } + ); + }} + /> + + + {t( + "aiResourceProvidersHelp" + )} + + + + )} + /> + + +
+ +
+
+ + + + +
+
); } diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index 302650bf2..7eb664b5a 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -82,20 +82,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { redirect(`/${params.orgId}/settings/resources`); } - const isInference = resource.mode === "inference"; - - const navItems = isInference - ? [ - { - title: t("general"), - href: `/{orgId}/settings/resources/public/{niceId}/general` - }, - { - title: t("aiResourceProviders"), - href: `/{orgId}/settings/resources/public/{niceId}/providers` - } - ] - : [ + const navItems = [ { title: t("general"), href: `/{orgId}/settings/resources/public/{niceId}/general` diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/providers/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/providers/page.tsx deleted file mode 100644 index 51182c2a8..000000000 --- a/src/app/[orgId]/settings/resources/public/[niceId]/providers/page.tsx +++ /dev/null @@ -1,230 +0,0 @@ -"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 { useResourceContext } from "@app/hooks/useResourceContext"; -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 PublicResourceProvidersPage() { - const t = useTranslations(); - const router = useRouter(); - const { env } = useEnvContext(); - const api = createApiClient({ env }); - const queryClient = useQueryClient(); - const { resource } = useResourceContext(); - - useEffect(() => { - if (resource.mode !== "inference") { - router.replace( - `/${resource.orgId}/settings/resources/public/${resource.niceId}/general` - ); - } - }, [router, resource.mode, resource.niceId, resource.orgId]); - - const formSchema = useMemo( - () => - z.object({ - providerIds: z.array(z.number().int().positive()) - }), - [] - ); - type FormValues = z.infer; - - const [selectedProviders, setSelectedProviders] = useState< - SelectedAiProvider[] - >([]); - - const attachedQuery = useQuery({ - ...resourceQueries.resourceAiProviders({ - resourceId: resource.resourceId - }), - enabled: resource.mode === "inference" - }); - - const form = useForm({ - 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(`/resource/${resource.resourceId}/ai-providers`, { - providers: data.providerIds.map((providerId) => ({ - providerId, - modelAccessMode: "catalog" - })) - }); - - await queryClient.invalidateQueries( - resourceQueries.resourceAiProviders({ - resourceId: resource.resourceId - }) - ); - - toast({ - title: t("success"), - description: t("aiResourceProvidersUpdated") - }); - } catch (error) { - toast({ - variant: "destructive", - title: t("aiResourceProvidersErrorUpdate"), - description: formatAxiosError( - error, - t("aiResourceProvidersErrorUpdate") - ) - }); - } - }, null); - - if (resource.mode !== "inference") { - return null; - } - - return ( - - - - - {t("aiResourceProviders")} - - - {t("aiResourceProvidersDescription")} - - - - - -
- - - - ( - - - {t( - "aiResourceProviders" - )} - - - { - setSelectedProviders( - providers - ); - form.setValue( - "providerIds", - providers.map( - (p) => - parseInt( - p.id, - 10 - ) - ), - { - shouldValidate: true - } - ); - }} - /> - - - {t( - "aiResourceProvidersHelp" - )} - - - - )} - /> - - -
- -
-
- - - - -
-
- ); -} diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index e336a45a1..3eb83cce3 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -305,7 +305,13 @@ export function buildUpdateSiteResourcePayload( typeof data.alias === "string" && data.alias.trim() ? data.alias - : null + : null, + domainId: data.httpConfigDomainId + ? data.httpConfigDomainId + : undefined, + subdomain: data.httpConfigSubdomain + ? data.httpConfigSubdomain + : undefined }), ...((data.mode === "host" || data.mode === "cidr") && { tcpPortRangeString: data.tcpPortRangeString, From b4d2974e19472306ff79b0dd473a91d80c4125b5 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Aug 2026 12:20:56 -0400 Subject: [PATCH 229/494] Properly configure ssl and domain when creating and editing --- .../private/[niceId]/inference/page.tsx | 37 ++++++++- .../resources/private/create/page.tsx | 82 ++++++++++--------- src/components/PrivateResourcesTable.tsx | 10 ++- src/components/PublicResourcesTable.tsx | 5 +- src/lib/privateResourceForm.ts | 26 ++---- 5 files changed, 95 insertions(+), 65 deletions(-) diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx index 78f13370c..48304d350 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx @@ -20,6 +20,7 @@ import { type SelectedAiProvider } from "@app/components/AiProvidersSelector"; import DomainPicker from "@app/components/DomainPicker"; +import { SwitchInput } from "@app/components/SwitchInput"; import { Button } from "@app/components/ui/button"; import { Form, @@ -67,7 +68,8 @@ export default function PrivateResourceInferencePage() { providerIds: z.array(z.number().int().positive()), httpConfigSubdomain: z.string().nullish(), httpConfigDomainId: z.string().nullish(), - httpConfigFullDomain: z.string().nullish() + httpConfigFullDomain: z.string().nullish(), + ssl: z.boolean().optional() }), [] ); @@ -90,7 +92,8 @@ export default function PrivateResourceInferencePage() { providerIds: [], httpConfigSubdomain: siteResource.subdomain ?? null, httpConfigDomainId: siteResource.domainId ?? null, - httpConfigFullDomain: siteResource.fullDomain ?? null + httpConfigFullDomain: siteResource.fullDomain ?? null, + ssl: siteResource.ssl ?? false } }); @@ -121,7 +124,8 @@ export default function PrivateResourceInferencePage() { mode: "inference", httpConfigSubdomain: data.httpConfigSubdomain, httpConfigDomainId: data.httpConfigDomainId, - httpConfigFullDomain: data.httpConfigFullDomain + httpConfigFullDomain: data.httpConfigFullDomain, + ssl: data.ssl }); await api.post(`/site-resource/${siteResource.id}/ai-providers`, { @@ -296,6 +300,33 @@ export default function PrivateResourceInferencePage() { }} /> + + ( + + + + + + )} + /> + diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index 9420da17b..da3c968b6 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -368,56 +368,58 @@ export default function CreatePrivateResourcePage() { /> - {mode === "http" && ( - - - { - 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 ); 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 === "inference" || (mode === "ssh" && !isNativeSsh)) && ( - {t("sshServer")} + {t("sshSettings")} {t("sshServerDescription")} diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 87eddb921..cb7d2645c 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -332,7 +332,7 @@ export default function PrivateResourcesTable({ }, { accessorKey: "mode", - friendlyName: t("editInternalResourceDialogMode"), + friendlyName: t("type"), header: () => ( ), diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index 48d7b4486..af89b878b 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -280,7 +280,7 @@ export default function PublicResourcesTable({ }, { accessorKey: "protocol", - friendlyName: t("protocol"), + friendlyName: t("type"), enableHiding: true, header: () => ( ), @@ -747,7 +747,6 @@ export default function PublicResourcesTable({ enableColumnVisibility columnVisibility={{ niceId: false, - protocol: false, labels: true }} stickyLeftColumn="name" diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index 3eb83cce3..f186ffb9d 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -216,16 +216,17 @@ export function buildCreateSiteResourcePayload( }) }), ...(data.mode === "inference" && { - alias: - data.alias && - typeof data.alias === "string" && - data.alias.trim() - ? data.alias - : undefined, aiProviders: (data.providerIds ?? []).map((providerId) => ({ providerId, modelAccessMode: "catalog" as const - })) + })), + ssl: data.ssl ?? false, + domainId: data.httpConfigDomainId + ? data.httpConfigDomainId + : undefined, + subdomain: data.httpConfigSubdomain + ? data.httpConfigSubdomain + : undefined }), ...((data.mode === "host" || data.mode === "cidr") && { tcpPortRangeString: data.tcpPortRangeString, @@ -306,6 +307,7 @@ export function buildUpdateSiteResourcePayload( data.alias.trim() ? data.alias : null, + ssl: data.ssl ?? false, domainId: data.httpConfigDomainId ? data.httpConfigDomainId : undefined, @@ -464,16 +466,6 @@ export function createCreateFormSchema(t: TranslateFn) { 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.mode !== "ssh" && data.mode !== "inference" && From 4048fa274a63815d117fd046581f3b5ba5dd0cb3 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 6 Aug 2026 12:32:19 -0400 Subject: [PATCH 230/494] fix non admins cant see private resources details in launcher --- server/routers/role/createRole.ts | 4 +- .../siteResource/listAllSiteResourcesByOrg.ts | 47 +++++++++++++++++- .../routers/siteResource/listSiteResources.ts | 49 +++++++++++++++++-- server/setup/migrationsPg.ts | 4 +- server/setup/migrationsSqlite.ts | 4 +- server/setup/scriptsPg/1.21.1.ts | 37 ++++++++++++++ server/setup/scriptsSqlite/1.21.1.ts | 43 ++++++++++++++++ 7 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 server/setup/scriptsPg/1.21.1.ts create mode 100644 server/setup/scriptsSqlite/1.21.1.ts diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts index 5ad6cd435..6d1ecb503 100644 --- a/server/routers/role/createRole.ts +++ b/server/routers/role/createRole.ts @@ -34,7 +34,9 @@ const createRoleSchema = z.strictObject({ export const defaultRoleAllowedActions: ActionsEnum[] = [ ActionsEnum.getOrg, ActionsEnum.getResource, - ActionsEnum.listResources + ActionsEnum.listResources, + ActionsEnum.getSiteResource, + ActionsEnum.listSiteResources ]; export type CreateRoleBody = z.infer; diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 50acbeb4f..50bbca6b4 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -3,11 +3,13 @@ import { DB_TYPE, Label, SiteResource, + roleSiteResources, siteNetworks, siteResourceLabels, siteResources, sites, - labels + labels, + userSiteResources } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; @@ -323,7 +325,48 @@ export async function listAllSiteResourcesByOrg( labels: labelFilter } = parsedQuery.data; - const conditions = [and(eq(siteResources.orgId, orgId))]; + let accessibleSiteResourceIds: number[]; + if (req.user) { + const accessibleSiteResources = await db + .select({ + siteResourceId: sql`COALESCE(${userSiteResources.siteResourceId}, ${roleSiteResources.siteResourceId})` + }) + .from(userSiteResources) + .fullJoin( + roleSiteResources, + eq( + userSiteResources.siteResourceId, + roleSiteResources.siteResourceId + ) + ) + .where( + or( + eq(userSiteResources.userId, req.user.userId), + inArray( + roleSiteResources.roleId, + req.userOrgRoleIds ?? [] + ) + ) + ); + accessibleSiteResourceIds = accessibleSiteResources.map( + (row) => row.siteResourceId + ); + } else { + const allOrgSiteResources = await db + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .where(eq(siteResources.orgId, orgId)); + accessibleSiteResourceIds = allOrgSiteResources.map( + (row) => row.siteResourceId + ); + } + + const conditions = [ + and( + eq(siteResources.orgId, orgId), + inArray(siteResources.siteResourceId, accessibleSiteResourceIds) + ) + ]; if (siteId != null) { // Keep inner joins here: filtering by a specific site implies the diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index da0ba9a49..7b8686a53 100644 --- a/server/routers/siteResource/listSiteResources.ts +++ b/server/routers/siteResource/listSiteResources.ts @@ -1,11 +1,17 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, networks, siteNetworks } from "@server/db"; +import { + db, + networks, + roleSiteResources, + siteNetworks, + userSiteResources +} from "@server/db"; import { siteResources, sites, SiteResource } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { and, asc, desc, eq } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -159,10 +165,47 @@ export async function listSiteResources( return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } + let accessibleSiteResourceIds: number[]; + if (req.user) { + const accessibleSiteResources = await db + .select({ + siteResourceId: sql`COALESCE(${userSiteResources.siteResourceId}, ${roleSiteResources.siteResourceId})` + }) + .from(userSiteResources) + .fullJoin( + roleSiteResources, + eq( + userSiteResources.siteResourceId, + roleSiteResources.siteResourceId + ) + ) + .where( + or( + eq(userSiteResources.userId, req.user.userId), + inArray( + roleSiteResources.roleId, + req.userOrgRoleIds ?? [] + ) + ) + ); + accessibleSiteResourceIds = accessibleSiteResources.map( + (row) => row.siteResourceId + ); + } else { + const allOrgSiteResources = await db + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .where(eq(siteResources.orgId, orgId)); + accessibleSiteResourceIds = allOrgSiteResources.map( + (row) => row.siteResourceId + ); + } + // Get site resources by joining networks to siteResources via siteNetworks const conditions = [ eq(siteNetworks.siteId, siteId), - eq(siteResources.orgId, orgId) + eq(siteResources.orgId, orgId), + inArray(siteResources.siteResourceId, accessibleSiteResourceIds) ]; if (typeof status !== "undefined") { diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 4a6d4861c..59811cc55 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -28,6 +28,7 @@ import m19 from "./scriptsPg/1.18.4"; import m20 from "./scriptsPg/1.19.0"; import m21 from "./scriptsPg/1.20.0"; import m22 from "./scriptsPg/1.21.0"; +import m23 from "./scriptsPg/1.21.1"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -55,7 +56,8 @@ const migrations = [ { version: "1.18.4", run: m19 }, { version: "1.19.0", run: m20 }, { version: "1.20.0", run: m21 }, - { version: "1.21.0", run: m22 } + { version: "1.21.0", run: m22 }, + { version: "1.21.1", run: m23 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index c6b4debef..54808ad3a 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -47,6 +47,7 @@ import m41 from "./scriptsSqlite/1.19.0"; import m42 from "./scriptsSqlite/1.19.1"; import m43 from "./scriptsSqlite/1.20.0"; import m44 from "./scriptsSqlite/1.21.0"; +import m45 from "./scriptsSqlite/1.21.1"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -91,7 +92,8 @@ const migrations = [ { version: "1.19.0", run: m41 }, { version: "1.19.1", run: m42 }, { version: "1.20.0", run: m43 }, - { version: "1.21.0", run: m44 } + { version: "1.21.0", run: m44 }, + { version: "1.21.1", run: m45 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.21.1.ts b/server/setup/scriptsPg/1.21.1.ts new file mode 100644 index 000000000..d4c375bd8 --- /dev/null +++ b/server/setup/scriptsPg/1.21.1.ts @@ -0,0 +1,37 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.21.1"; + +const actionsToGrant = ["getSiteResource", "listSiteResources"] as const; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql`BEGIN`); + + for (const actionId of actionsToGrant) { + await db.execute(sql` + INSERT INTO "roleActions" ("roleId", "actionId", "orgId") + SELECT r."roleId", ${actionId}, r."orgId" + FROM "roles" r + WHERE COALESCE(r."isAdmin", false) = false + AND NOT EXISTS ( + SELECT 1 FROM "roleActions" ra + WHERE ra."roleId" = r."roleId" + AND ra."actionId" = ${actionId} + AND ra."orgId" = r."orgId" + ); + `); + } + + await db.execute(sql`COMMIT`); + console.log(`Finished setup script ${version}`); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } +} diff --git a/server/setup/scriptsSqlite/1.21.1.ts b/server/setup/scriptsSqlite/1.21.1.ts new file mode 100644 index 000000000..405749dfd --- /dev/null +++ b/server/setup/scriptsSqlite/1.21.1.ts @@ -0,0 +1,43 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.21.1"; + +const actionsToGrant = ["getSiteResource", "listSiteResources"] as const; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.transaction(() => { + const insertRoleAction = db.prepare(` + INSERT INTO 'roleActions' ("roleId", "actionId", "orgId") + SELECT r."roleId", ?, r."orgId" + FROM 'roles' r + WHERE COALESCE(r."isAdmin", 0) = 0 + AND NOT EXISTS ( + SELECT 1 FROM 'roleActions' ra + WHERE ra."roleId" = r."roleId" + AND ra."actionId" = ? + AND ra."orgId" = r."orgId" + ); + `); + + for (const actionId of actionsToGrant) { + insertRoleAction.run(actionId, actionId); + } + })(); + + console.log(`Finished setup script ${version}`); + } catch (e) { + console.log("Unable to migrate database"); + console.log(e); + throw e; + } finally { + db.close(); + } +} From 6cca5e047228573fb1e9b4798d409443851fd1ed Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Aug 2026 13:45:51 -0400 Subject: [PATCH 231/494] Use fulldoman instead of the alias for the site resources --- server/lib/rebuildClientAssociations.ts | 18 +++++++------- server/lib/traefik/getTraefikConfig.ts | 14 +++++------ .../private/lib/traefik/getTraefikConfig.ts | 24 +++++++++---------- server/routers/olm/buildConfiguration.ts | 2 +- 4 files changed, 30 insertions(+), 28 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index fe1cdea6f..045ca3927 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -1117,7 +1117,8 @@ async function syncClientExitNodeConnections( const requiresExitNodeRows = await trx .select({ clientId: clientSiteResourcesAssociationsCache.clientId, - alias: siteResources.alias + alias: siteResources.alias, + fullDomain: siteResources.fullDomain }) .from(clientSiteResourcesAssociationsCache) .innerJoin( @@ -1145,14 +1146,16 @@ async function syncClientExitNodeConnections( // Aliases for every exit-node-backed resource this client can reach, so // the live connect push carries the same alias list the register/reconnect // path (buildSiteConfigurationForOlmClient) would compute. - const exitNodeAliasesByClientId = new Map(); + const exitNodeAliasesByClientId = new Map(); for (const row of requiresExitNodeRows) { - if (row.alias == null) continue; + if (row.alias == null && row.fullDomain == null) continue; const existing = exitNodeAliasesByClientId.get(row.clientId); if (existing) { - existing.push(row.alias); + existing.push(row.fullDomain || row.alias); // accept both for now in case we have other resource types that dont use the full domain } else { - exitNodeAliasesByClientId.set(row.clientId, [row.alias]); + exitNodeAliasesByClientId.set(row.clientId, [ + row.fullDomain || row.alias + ]); } } @@ -1231,9 +1234,8 @@ async function syncClientExitNodeConnections( publicKey: exitNode.publicKey, serverIP: exitNode.address.split("/")[0], tunnelIP: client.exitNodeSubnet.split("/")[0], - aliases: exitNodeAliasesByClientId.get( - client.clientId - ) ?? [] + aliases: + exitNodeAliasesByClientId.get(client.clientId) ?? [] } }, options: { diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 4df621684..a6535c766 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -860,7 +860,7 @@ export async function getTraefikConfig( const siteResourcesInference = await db .selectDistinct({ siteResourceId: siteResources.siteResourceId, - alias: siteResources.alias, + fullDomain: siteResources.fullDomain, ssl: siteResources.ssl, enabled: siteResources.enabled }) @@ -869,23 +869,23 @@ export async function getTraefikConfig( and( eq(siteResources.mode, "inference"), eq(siteResources.enabled, true), - isNotNull(siteResources.alias) + isNotNull(siteResources.fullDomain) ) ); for (const sr of siteResourcesInference) { - if (!sr.enabled || !sr.alias) continue; + if (!sr.enabled || !sr.fullDomain) continue; if (!config_output.http.routers) config_output.http.routers = {}; if (!config_output.http.services) config_output.http.services = {}; - const alias = sr.alias; + const fullDomain = sr.fullDomain; const srKey = `inference-sr${sr.siteResourceId}`; const routerName = `${srKey}-router`; const serviceName = `${srKey}-service`; - const rule = `Host(\`${alias}\`) && ClientIP(${exitNode.address})`; // restrict to coming from the exit node ip range that the client is connected to + const rule = `Host(\`${fullDomain}\`) && ClientIP(${exitNode.address})`; // restrict to coming from the exit node ip range that the client is connected to - const domainParts = alias.split("."); + const domainParts = fullDomain.split("."); const wildCard = domainParts.length <= 2 ? `*.${domainParts.join(".")}` @@ -911,7 +911,7 @@ export async function getTraefikConfig( headers: { customRequestHeaders: { ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": alias + "p-host": fullDomain } } }; diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 5cd5549c8..b00dbd7c8 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -407,7 +407,7 @@ export async function getTraefikConfig( and( eq(siteResources.enabled, true), isNotNull(siteResources.fullDomain), - eq(siteResources.mode, "http"), + eq(siteResources.mode, "http"), // important so we dont double get the inference siteResources below eq(siteResources.ssl, true), eq(sites.exitNodeId, exitNodeId), inArray(sites.type, siteTypes) @@ -452,7 +452,7 @@ export async function getTraefikConfig( const siteResourcesInference = await db .selectDistinct({ siteResourceId: siteResources.siteResourceId, - alias: siteResources.alias, + fullDomain: siteResources.fullDomain, ssl: siteResources.ssl, enabled: siteResources.enabled }) @@ -461,7 +461,7 @@ export async function getTraefikConfig( and( eq(siteResources.mode, "inference"), eq(siteResources.enabled, true), - isNotNull(siteResources.alias) + isNotNull(siteResources.fullDomain) ) ); @@ -493,8 +493,8 @@ export async function getTraefikConfig( } } for (const sr of siteResourcesInference) { - if (sr.enabled && sr.ssl && sr.alias) { - domains.add(sr.alias); + if (sr.enabled && sr.ssl && sr.fullDomain) { + domains.add(sr.fullDomain); } } // get the valid certs for these domains @@ -1687,20 +1687,20 @@ export async function getTraefikConfig( // siteResources today (see plan doc), so gating here is // reachability-only for now. for (const sr of siteResourcesInference) { - if (!sr.enabled || !sr.alias) continue; + if (!sr.enabled || !sr.fullDomain) continue; if (!config_output.http.routers) config_output.http.routers = {}; if (!config_output.http.services) config_output.http.services = {}; - const alias = sr.alias; + const fullDomain = sr.fullDomain; const srKey = `inference-sr${sr.siteResourceId}`; const routerName = `${srKey}-router`; const serviceName = `${srKey}-service`; - const rule = `Host(\`${alias}\`) && ClientIP(${exitNode.address})`; // restrict to coming from the exit node ip range that the client is connected to + const rule = `Host(\`${fullDomain}\`) && ClientIP(${exitNode.address})`; // restrict to coming from the exit node ip range that the client is connected to let tls: any = {}; if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { - const domainParts = alias.split("."); + const domainParts = fullDomain.split("."); const wildCard = domainParts.length <= 2 ? `*.${domainParts.join(".")}` @@ -1719,11 +1719,11 @@ export async function getTraefikConfig( }; } else { const matchingCert = validCerts.find( - (cert) => cert.queriedDomain === alias + (cert) => cert.queriedDomain === fullDomain ); if (!matchingCert) { logger.debug( - `No matching certificate found for inference siteResource alias: ${alias}` + `No matching certificate found for inference siteResource fullDomain: ${fullDomain}` ); continue; } @@ -1739,7 +1739,7 @@ export async function getTraefikConfig( headers: { customRequestHeaders: { ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": alias + "p-host": fullDomain } } }; diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index af309f89a..1eb2cfa91 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -98,7 +98,7 @@ export async function buildSiteConfigurationForOlmClient( } exitNodeAliases = siteResourcesForExitNode - .map((sr) => sr.alias) + .map((sr) => sr.fullDomain || sr.alias) // take either in case we introduce different resource types that don't have a fullDomain .filter((a) => a != null); if (sitesData.length == 0) { From c5d68675c9e637d72f3fc73f5cb19afbcb0d300d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 6 Aug 2026 14:27:08 -0400 Subject: [PATCH 232/494] support patterns in model key --- messages/en-US.json | 6 +- server/lib/aiInferenceResource.ts | 36 +++++++----- server/lib/aiModelKeyMatch.ts | 85 ++++++++++++++++++++++++++++ server/routers/aiGateway/pipeline.ts | 71 ++++++++++++++++------- 4 files changed, 162 insertions(+), 36 deletions(-) create mode 100644 server/lib/aiModelKeyMatch.ts diff --git a/messages/en-US.json b/messages/en-US.json index f5de37878..e994c823c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1759,13 +1759,13 @@ "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", + "aiProviderModelsDescription": "Define model names available on this provider. Requests must match one of these keys. Use * and ? as wildcards (for example gpt-4* or claude-?).", + "aiProviderModelsPlaceholder": "Model name or pattern (e.g. gpt-4*)", "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.", + "aiResourceProvidersHelp": "Models must be defined on each provider. Exact names and patterns that conflict (identical keys, or an exact key matching another provider's pattern) are not allowed across selected providers.", "aiResourceProvidersSelect": "Select providers", "aiResourceProvidersEmpty": "No AI providers found", "aiResourceProvidersUpdated": "Providers updated", diff --git a/server/lib/aiInferenceResource.ts b/server/lib/aiInferenceResource.ts index 6e0f490d8..d01b30dd9 100644 --- a/server/lib/aiInferenceResource.ts +++ b/server/lib/aiInferenceResource.ts @@ -10,6 +10,7 @@ import { type Transaction } from "@server/db"; import { z } from "zod"; +import { modelKeysConflict } from "@server/lib/aiModelKeyMatch"; type DbOrTrx = Transaction | typeof db; @@ -55,10 +56,13 @@ function normalizeAttachments( } /** - * Ensure enabled catalog modelKeys are unique across attached providers. + * Ensure enabled catalog modelKeys do not conflict 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). + * + * Conflicts: identical keys, or an exact key that matches another provider's + * pattern. Full glob intersections are left to runtime ambiguity errors. */ export async function assertNoOverlappingModelKeys( attachments: ResourceAiProviderAttachment[], @@ -85,25 +89,31 @@ export async function assertNoOverlappingModelKeys( ) ); - const keyToProviders = new Map(); - for (const model of models) { - const existing = keyToProviders.get(model.modelKey) ?? []; - if (!existing.includes(model.providerId)) { - existing.push(model.providerId); + const conflictPairs: string[] = []; + for (let i = 0; i < models.length; i++) { + for (let j = i + 1; j < models.length; j++) { + const left = models[i]; + const right = models[j]; + if (left.providerId === right.providerId) { + continue; + } + if (!modelKeysConflict(left.modelKey, right.modelKey)) { + continue; + } + const pair = [left.modelKey, right.modelKey].sort().join(" vs "); + if (!conflictPairs.includes(pair)) { + conflictPairs.push(pair); + } } - keyToProviders.set(model.modelKey, existing); } - const overlaps = [...keyToProviders.entries()].filter( - ([, providerIds]) => providerIds.length > 1 - ); - if (overlaps.length === 0) { + if (conflictPairs.length === 0) { return null; } - const keys = overlaps.map(([key]) => key).sort(); + conflictPairs.sort(); return { - error: `Model keys must be unique across providers on a resource. Overlapping keys: ${keys.join(", ")}` + error: `Model keys must be unique across providers on a resource. Overlapping keys: ${conflictPairs.join(", ")}` }; } diff --git a/server/lib/aiModelKeyMatch.ts b/server/lib/aiModelKeyMatch.ts new file mode 100644 index 000000000..fa27be56f --- /dev/null +++ b/server/lib/aiModelKeyMatch.ts @@ -0,0 +1,85 @@ +const modelKeyRegexCache = new Map(); + +export function isModelKeyPattern(key: string): boolean { + return key.includes("*") || key.includes("?"); +} + +function getModelKeyRegex(pattern: string): RegExp { + let regex = modelKeyRegexCache.get(pattern); + if (!regex) { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + regex = new RegExp( + `^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$` + ); + modelKeyRegexCache.set(pattern, regex); + } + return regex; +} + +export function modelKeyMatches( + pattern: string, + requestedModel: string +): boolean { + return getModelKeyRegex(pattern).test(requestedModel); +} + +function wildcardCharCount(key: string): number { + let count = 0; + for (const char of key) { + if (char === "*" || char === "?") { + count += 1; + } + } + return count; +} + +function literalLength(key: string): number { + return key.replace(/[*?]/g, "").length; +} + +/** + * Sort comparator: more specific patterns sort before less specific ones + * (negative when `a` is more specific than `b`). + * + * 1. Exact keys beat patterns + * 2. Fewer wildcard characters win + * 3. Longer literal length wins + */ +export function compareModelKeySpecificity(a: string, b: string): number { + const aIsPattern = isModelKeyPattern(a); + const bIsPattern = isModelKeyPattern(b); + + if (aIsPattern !== bIsPattern) { + return aIsPattern ? 1 : -1; + } + + const wildcardDiff = wildcardCharCount(a) - wildcardCharCount(b); + if (wildcardDiff !== 0) { + return wildcardDiff; + } + + return literalLength(b) - literalLength(a); +} + +/** + * Attach-time conflict check. Detects identical keys and exact-vs-pattern + * matches. Does not attempt full glob intersection. + */ +export function modelKeysConflict(a: string, b: string): boolean { + if (a === b) { + return true; + } + + const aIsPattern = isModelKeyPattern(a); + const bIsPattern = isModelKeyPattern(b); + + if (aIsPattern === bIsPattern) { + return false; + } + + if (aIsPattern) { + return modelKeyMatches(a, b); + } + + return modelKeyMatches(b, a); +} diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index bdc4b0eb5..247e55b4c 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -39,6 +39,10 @@ import { localCache } from "@server/lib/cache"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import type { ModelAccessMode } from "@server/lib/aiInferenceResource"; +import { + compareModelKeySpecificity, + modelKeyMatches +} from "@server/lib/aiModelKeyMatch"; import { aiGatewayUpstreamFetch } from "@server/lib/aiGatewayUpstreamFetch"; // Short-lived local caches so a burst of requests from the same IP/user @@ -369,55 +373,82 @@ async function selectProvider( }; } - const matchingModels = await db + const providerModels = await db .select({ modelId: aiModels.modelId, providerId: aiModels.providerId, + modelKey: aiModels.modelKey, enabled: aiModels.enabled }) .from(aiModels) - .where( - and( - inArray(aiModels.providerId, providerIds), - eq(aiModels.modelKey, requestedModel) - ) - ); + .where(inArray(aiModels.providerId, providerIds)); + + type ModelCandidate = { + provider: AiProvider; + modelKey: string; + }; + + const candidates: ModelCandidate[] = []; + for (const model of providerModels) { + if (!model.enabled) { + continue; + } + + if (!modelKeyMatches(model.modelKey, requestedModel)) { + continue; + } - const candidates: AiProvider[] = []; - for (const model of matchingModels) { const attachment = providerById.get(model.providerId); if (!attachment) { continue; } if (attachment.modelAccessMode === "catalog") { - if (model.enabled) { - candidates.push(attachment.provider); - } + candidates.push({ + provider: attachment.provider, + modelKey: model.modelKey + }); continue; } if (allowlistedModelIds.has(model.modelId)) { - candidates.push(attachment.provider); + candidates.push({ + provider: attachment.provider, + modelKey: model.modelKey + }); } } - if (candidates.length === 1) { - return { ok: true, provider: candidates[0] }; - } - - if (candidates.length > 1) { + if (candidates.length === 0) { return { ok: false, status: HttpCode.FORBIDDEN, - message: `Model "${requestedModel}" is ambiguous across multiple AI providers on this resource` + message: `Model "${requestedModel}" is not permitted on this resource` }; } + candidates.sort((a, b) => + compareModelKeySpecificity(a.modelKey, b.modelKey) + ); + + const bestSpecificity = candidates[0].modelKey; + const topCandidates = candidates.filter( + (c) => compareModelKeySpecificity(c.modelKey, bestSpecificity) === 0 + ); + + const uniqueProviders = new Map(); + for (const candidate of topCandidates) { + uniqueProviders.set(candidate.provider.providerId, candidate.provider); + } + + if (uniqueProviders.size === 1) { + return { ok: true, provider: [...uniqueProviders.values()][0] }; + } + return { ok: false, status: HttpCode.FORBIDDEN, - message: `Model "${requestedModel}" is not permitted on this resource` + message: `Model "${requestedModel}" is ambiguous across multiple AI providers on this resource` }; } From f72252552f0c61616f7d55719325f5fdebfe9f81 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Aug 2026 14:28:05 -0400 Subject: [PATCH 233/494] Fix saving --- .../ai-providers/[providerId]/authentication/page.tsx | 6 ++++-- .../settings/ai-providers/[providerId]/network/page.tsx | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx index 5a7b068d8..f9e44cb09 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx @@ -66,7 +66,8 @@ export default function AiProviderAuthenticationPage() { authType: (provider.authType as AiProviderAuthType) ?? "bearer", routingMode: (provider.routingMode as "url" | "target") ?? "url", skipTlsVerification: provider.skipTlsVerification, - enabled: provider.enabled + enabled: provider.enabled, + capabilities: provider.capabilities ?? [] } }); @@ -97,7 +98,8 @@ export default function AiProviderAuthenticationPage() { authType: (updated.authType as AiProviderAuthType) ?? "bearer", routingMode: (updated.routingMode as "url" | "target") ?? "url", skipTlsVerification: updated.skipTlsVerification, - enabled: updated.enabled + enabled: updated.enabled, + capabilities: updated.capabilities ?? [] }); toast({ title: t("success"), diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx index 8d9f02744..1aa25e9e5 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx @@ -83,7 +83,8 @@ export default function AiProviderNetworkPage() { routingMode: (provider.routingMode as "url" | "target") ?? "url", headers: provider.headers ?? [], skipTlsVerification: provider.skipTlsVerification, - enabled: provider.enabled + enabled: provider.enabled, + capabilities: provider.capabilities ?? [] } }); @@ -127,7 +128,8 @@ export default function AiProviderNetworkPage() { routingMode: (updated.routingMode as "url" | "target") ?? "url", headers: updated.headers ?? [], skipTlsVerification: updated.skipTlsVerification, - enabled: updated.enabled + enabled: updated.enabled, + capabilities: updated.capabilities ?? [] }); if (values.routingMode === "target" && targetsFormRef.current) { From a4d77a4fd336d2aa4068bc16b6ed72d3a7471660 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Aug 2026 14:45:25 -0400 Subject: [PATCH 234/494] Fix info box ui --- server/routers/aiGateway/pipeline.ts | 2 +- .../resources/private/[niceId]/layout.tsx | 2 +- .../resources/public/[niceId]/layout.tsx | 2 +- ...eInfoBox.tsx => PrivateResourceInfoBox.tsx} | 18 +++++++++++------- .../LauncherResourcePanel.tsx | 4 ++-- src/lib/launcherResourceAccess.ts | 5 ++++- 6 files changed, 20 insertions(+), 13 deletions(-) rename src/components/{SiteResourceInfoBox.tsx => PrivateResourceInfoBox.tsx} (94%) diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 247e55b4c..32690b1e1 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -461,7 +461,7 @@ export async function handleAiGatewayProxy( const def = AI_CAPABILITY_DEFS[capability]; const host = ( - (req.headers["p-host"] as string | undefined) || + (req.headers["p-host"] as string | undefined) || // p-host is only used sometimes when overriding the host header for some middleware proxy req.headers.host || "" ).split(":")[0]; diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx index f07b92673..294e8f81a 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx @@ -4,7 +4,7 @@ import { fetchSiteResourceByNiceId } from "@app/lib/fetchSiteResourceByNiceId"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import OrgProvider from "@app/providers/OrgProvider"; import SiteResourceProvider from "@app/providers/SiteResourceProvider"; -import SiteResourceInfoBox from "@app/components/SiteResourceInfoBox"; +import SiteResourceInfoBox from "@app/components/PrivateResourceInfoBox"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index 7eb664b5a..43a3a62c0 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -105,7 +105,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { } ); - if (!env.flags.disableEnterpriseFeatures) { + if (!env.flags.disableEnterpriseFeatures && resource.mode !== "inference") { navItems.push({ title: t("maintenanceMode"), href: `/{orgId}/settings/resources/public/{niceId}/maintenance` diff --git a/src/components/SiteResourceInfoBox.tsx b/src/components/PrivateResourceInfoBox.tsx similarity index 94% rename from src/components/SiteResourceInfoBox.tsx rename to src/components/PrivateResourceInfoBox.tsx index e735ff7ae..fda9a7596 100644 --- a/src/components/SiteResourceInfoBox.tsx +++ b/src/components/PrivateResourceInfoBox.tsx @@ -80,7 +80,7 @@ function AccessMethodContent({ ); } -export function SiteResourceInfoSections({ +export function PrivateResourceInfoSections({ siteResource, access, variant, @@ -109,12 +109,16 @@ export function SiteResourceInfoSections({ udpPortRangeString: siteResource.udpPortRangeString ?? "*" }); const showAlias = - siteResource.mode !== "cidr" && siteResource.mode !== "http"; - const showDestination = !( - siteResource.mode === "ssh" && siteResource.authDaemonMode === "native" - ); + siteResource.mode !== "cidr" && + siteResource.mode !== "http" && + siteResource.mode !== "inference"; + const showDestination = + !( + siteResource.mode === "ssh" && + siteResource.authDaemonMode === "native" + ) && siteResource.mode !== "inference"; const showCertificate = !!( - siteResource.mode === "http" && + (siteResource.mode === "http" || siteResource.mode === "inference") && siteResource.ssl && siteResource.domainId && siteResource.fullDomain && @@ -258,7 +262,7 @@ export default function SiteResourceInfoBox({ }); return ( - - Date: Thu, 6 Aug 2026 14:55:07 -0400 Subject: [PATCH 235/494] Add back port restrictions --- server/routers/siteResource/createSiteResource.ts | 8 +++++--- server/routers/siteResource/updateSiteResource.ts | 10 +++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 815d7adb8..31f631ce2 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -605,7 +605,7 @@ export async function createSiteResource( } let tcpPortRangeStringAdjusted = tcpPortRangeString; - if (mode === "http") { + if (mode === "http" || mode === "inference") { tcpPortRangeStringAdjusted = "443,80"; } else if (mode === "ssh") { tcpPortRangeStringAdjusted = destinationPort @@ -628,12 +628,14 @@ export async function createSiteResource( aliasAddress, tcpPortRangeString: tcpPortRangeStringAdjusted, udpPortRangeString: - mode == "http" || mode == "ssh" + mode == "http" || mode == "ssh" || mode == "inference" ? "" : udpPortRangeString, disableIcmp: disableIcmp || - (mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false + (mode == "http" || mode == "ssh" || mode == "inference" + ? true + : false), // default to true for http resources, otherwise false domainId, subdomain: finalSubdomain, fullDomain, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 754c82a65..3ec8f7f65 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -558,7 +558,7 @@ export async function updateSiteResource( : {}; let tcpPortRangeStringAdjusted = tcpPortRangeString; - if (mode === "http") { + if (mode === "http" || mode === "inference") { tcpPortRangeStringAdjusted = "443,80"; } else if (mode === "ssh") { tcpPortRangeStringAdjusted = destinationPort @@ -585,13 +585,17 @@ export async function updateSiteResource( : undefined, tcpPortRangeString: tcpPortRangeStringAdjusted, udpPortRangeString: - mode == "http" || mode == "ssh" + mode == "http" || mode == "ssh" || mode == "inference" ? "" : udpPortRangeString, disableIcmp: mode !== undefined ? disableIcmp || - (mode == "http" || mode == "ssh" ? true : false) + (mode == "http" || + mode == "ssh" || + mode == "inference" + ? true + : false) : disableIcmp, domainId, subdomain: finalSubdomain, From e9f9cf54f49587a02d6c8934d212a4f06d7c71d6 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Aug 2026 15:18:11 -0400 Subject: [PATCH 236/494] Include the sheme in the routing header --- server/routers/aiGateway/targetRouting.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/server/routers/aiGateway/targetRouting.ts b/server/routers/aiGateway/targetRouting.ts index ad9f08f79..fe2598222 100644 --- a/server/routers/aiGateway/targetRouting.ts +++ b/server/routers/aiGateway/targetRouting.ts @@ -10,7 +10,7 @@ import HttpCode from "@server/types/HttpCode"; // almost immediately without needing explicit cache invalidation. const PROVIDER_TARGETS_TTL_SEC = 7; -// Header gerbil reads to know which host:port (reachable over the +// Header gerbil reads to know which scheme://host:port (reachable over the // WireGuard network) to rewrite an incoming /router/* request to. Must // match gerbil's `pangolinDestHeader` constant. const PANGOLIN_DEST_HEADER = "p-dest-header"; @@ -32,8 +32,9 @@ const SKIP_HEADERS = new Set([ type ResolvedProviderTarget = { targetId: number; - // ":", passed to gerbil as the - // destination to proxy the request to over the WireGuard tunnel. + // "://:", passed to + // gerbil as the destination to proxy the request to over the WireGuard + // tunnel. destination: string; // The target's site's exit node HTTP API base URL (gerbil's /router/*). gerbilBaseUrl: string; @@ -47,6 +48,7 @@ async function fetchProviderTargets( targetId: targets.targetId, internalPort: targets.internalPort, port: targets.port, + method: targets.method, exitNodeSubnet: sites.exitNodeSubnet, reachableAt: exitNodes.reachableAt }) @@ -66,9 +68,10 @@ async function fetchProviderTargets( } const host = row.exitNodeSubnet.split("/")[0]; const port = row.internalPort ?? row.port; + const scheme = row.method?.toLowerCase() ?? "https"; resolved.push({ targetId: row.targetId, - destination: `${host}:${port}`, + destination: `${scheme}://${host}:${port}`, gerbilBaseUrl: row.reachableAt }); } From 6c28c5f3833ea641c4ab6e4ff88b2037e29dbf6e Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Aug 2026 15:48:13 -0400 Subject: [PATCH 237/494] Basic target routing --- server/routers/aiGateway/targetRouting.ts | 55 +++++++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/server/routers/aiGateway/targetRouting.ts b/server/routers/aiGateway/targetRouting.ts index fe2598222..2e415b819 100644 --- a/server/routers/aiGateway/targetRouting.ts +++ b/server/routers/aiGateway/targetRouting.ts @@ -1,7 +1,15 @@ import { Request, Response } from "express"; import { and, eq } from "drizzle-orm"; import { AiProvider, db, exitNodes, sites, targets } from "@server/db"; +import config from "@server/lib/config"; +import { decrypt } from "@server/lib/crypto"; import { localCache } from "@server/lib/cache"; +import { + AiProviderAuthType, + applyAiProviderAuthHeaders, + applyAiProviderCustomHeaders, + authTypeRequiresApiKey +} from "@server/lib/aiProviderDefaults"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; @@ -15,6 +23,12 @@ const PROVIDER_TARGETS_TTL_SEC = 7; // match gerbil's `pangolinDestHeader` constant. const PANGOLIN_DEST_HEADER = "p-dest-header"; +// Header gerbil reads for the Host header value to send to the destination, +// when it should differ from PANGOLIN_DEST_HEADER (the target's configured +// ip rather than the WireGuard routing address). Must match gerbil's +// `pangolinHostHeader` constant. +const PANGOLIN_HOST_HEADER = "p-dest-host-header"; + const SKIP_HEADERS = new Set([ "p-host", "host", @@ -36,6 +50,10 @@ type ResolvedProviderTarget = { // gerbil as the destination to proxy the request to over the WireGuard // tunnel. destination: string; + // The target's configured ip, passed to gerbil as the Host header to + // send to the destination (which may differ from the WireGuard routing + // address above, e.g. for vhost-based targets). + hostHeader: string; // The target's site's exit node HTTP API base URL (gerbil's /router/*). gerbilBaseUrl: string; }; @@ -46,6 +64,7 @@ async function fetchProviderTargets( const rows = await db .select({ targetId: targets.targetId, + ip: targets.ip, internalPort: targets.internalPort, port: targets.port, method: targets.method, @@ -72,6 +91,7 @@ async function fetchProviderTargets( resolved.push({ targetId: row.targetId, destination: `${scheme}://${host}:${port}`, + hostHeader: row.ip, gerbilBaseUrl: row.reachableAt }); } @@ -117,9 +137,10 @@ function pathFromRequest(req: Request): string { * Proxies an AI gateway request to one of a "custom" / "target" routing-mode * provider's site targets, via that site's gerbil sidecar. Gerbil's * /router/* endpoint forwards the request (untouched body, same path minus - * the /router prefix, and all headers besides PANGOLIN_DEST_HEADER) over the - * WireGuard tunnel to the destination named in that header. Always writes a - * response to `res`, including on failure. + * the /router prefix, and all headers besides PANGOLIN_DEST_HEADER and + * PANGOLIN_HOST_HEADER) over the WireGuard tunnel to the destination named + * in PANGOLIN_DEST_HEADER, sending PANGOLIN_HOST_HEADER as the Host header. + * Always writes a response to `res`, including on failure. */ export async function proxyAiGatewayToSiteTarget( req: Request, @@ -147,7 +168,30 @@ export async function proxyAiGatewayToSiteTarget( } headers[key] = Array.isArray(value) ? value.join(", ") : value; } + + const authType = provider.authType as AiProviderAuthType; + let apiKey: string | null = null; + if (authTypeRequiresApiKey(authType)) { + if (!provider.apiKey) { + res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ + error: { + message: "AI provider has no API key configured" + } + }); + return; + } + const secret = config.getRawConfig().server.secret!; + apiKey = decrypt(provider.apiKey, secret); + } + applyAiProviderCustomHeaders( + headers, + provider.headers, + config.getRawConfig().server.secret! + ); + applyAiProviderAuthHeaders(headers, authType, apiKey); + headers[PANGOLIN_DEST_HEADER] = target.destination; + headers[PANGOLIN_HOST_HEADER] = target.hostHeader; const body = JSON.stringify(req.body); @@ -155,7 +199,10 @@ export async function proxyAiGatewayToSiteTarget( providerId: provider.providerId, targetId: target.targetId, destination: target.destination, - url: gerbilUrl + hostHeader: target.hostHeader, + url: gerbilUrl, + headers, + body: req.body }); let upstreamRes: globalThis.Response; From 22f2990f56ee1f9a751a12f03f949d56dfee2668 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Aug 2026 17:15:11 -0400 Subject: [PATCH 238/494] support streaming and closes properly on site targets --- server/lib/aiCapabilities.ts | 7 ++-- server/lib/aiGatewayUpstreamFetch.ts | 14 +++++++ server/routers/aiGateway/pipeline.ts | 39 +++++++++++++++--- server/routers/aiGateway/targetRouting.ts | 50 +++++++++++++++++++---- 4 files changed, 92 insertions(+), 18 deletions(-) diff --git a/server/lib/aiCapabilities.ts b/server/lib/aiCapabilities.ts index b6d2f7984..e899f83c3 100644 --- a/server/lib/aiCapabilities.ts +++ b/server/lib/aiCapabilities.ts @@ -62,9 +62,10 @@ export function joinUpstreamUrl(baseUrl: string, path: string): string { } function pathFromRequest(req: Request): string { - // Prefer originalUrl path (includes mounted path) over req.path when available. - const raw = - req.originalUrl?.split("?")[0] || req.url?.split("?")[0] || req.path; + // Prefer originalUrl (includes mounted path) over req.url when available. + // Query string is preserved - some providers use it to select the + // streaming response format (e.g. Gemini's `?alt=sse`). + const raw = req.originalUrl || req.url || req.path; return raw.startsWith("/") ? raw : `/${raw}`; } diff --git a/server/lib/aiGatewayUpstreamFetch.ts b/server/lib/aiGatewayUpstreamFetch.ts index e41ec23e4..7691f1f95 100644 --- a/server/lib/aiGatewayUpstreamFetch.ts +++ b/server/lib/aiGatewayUpstreamFetch.ts @@ -7,6 +7,7 @@ type UpstreamFetchInit = { headers: Record; body?: string; skipTlsVerification?: boolean; + signal?: AbortSignal; }; const insecureHttpsAgent = new https.Agent({ @@ -25,6 +26,11 @@ export function aiGatewayUpstreamFetch( isHttps && init.skipTlsVerification ? insecureHttpsAgent : undefined; return new Promise((resolve, reject) => { + if (init.signal?.aborted) { + reject(init.signal.reason ?? new Error("Request aborted")); + return; + } + const req = lib.request( url, { @@ -60,6 +66,14 @@ export function aiGatewayUpstreamFetch( req.on("error", reject); + if (init.signal) { + const onAbort = () => req.destroy(init.signal!.reason); + init.signal.addEventListener("abort", onAbort, { once: true }); + req.on("close", () => + init.signal!.removeEventListener("abort", onAbort) + ); + } + if (init.body !== undefined) { req.write(init.body); } diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 32690b1e1..ff76a5317 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -592,15 +592,33 @@ export async function handleAiGatewayProxy( skipTlsVerification: provider.skipTlsVerification }); + // Cancel the upstream request (and, transitively, anything it fans + // out to) if the client goes away before we're done - otherwise a + // client-cancelled streaming chat completion keeps running upstream + // to completion, wasting the connection and any per-token billing. + const abortController = new AbortController(); + const onClientClose = () => { + if (!res.writableEnded) { + abortController.abort(); + } + }; + res.on("close", onClientClose); + let upstreamRes: globalThis.Response; try { upstreamRes = await aiGatewayUpstreamFetch(targetUrl, { method: "POST", headers, body, - skipTlsVerification: provider.skipTlsVerification + skipTlsVerification: provider.skipTlsVerification, + signal: abortController.signal }); } catch (fetchError) { + res.off("close", onClientClose); + if (abortController.signal.aborted) { + // Client already disconnected; nothing left to respond to. + return; + } logger.error({ message: "AI gateway upstream fetch failed", url: targetUrl, @@ -628,14 +646,23 @@ export async function handleAiGatewayProxy( if (isStream && upstreamRes.body) { res.flushHeaders(); const reader = upstreamRes.body.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - res.write(value); + try { + while (!abortController.signal.aborted) { + const { done, value } = await reader.read(); + if (done) break; + res.write(value); + } + } finally { + await reader.cancel().catch(() => {}); + res.off("close", onClientClose); } - return res.end(); + if (!res.writableEnded) { + res.end(); + } + return; } + res.off("close", onClientClose); const text = await upstreamRes.text(); return res.send(text); } catch (error) { diff --git a/server/routers/aiGateway/targetRouting.ts b/server/routers/aiGateway/targetRouting.ts index 2e415b819..b0b2456d4 100644 --- a/server/routers/aiGateway/targetRouting.ts +++ b/server/routers/aiGateway/targetRouting.ts @@ -128,8 +128,10 @@ function pickTarget( } function pathFromRequest(req: Request): string { - const raw = - req.originalUrl?.split("?")[0] || req.url?.split("?")[0] || req.path; + // Query string is preserved - some providers use it to select the + // streaming response format (e.g. Gemini's `?alt=sse`), and gerbil's + // /router/* forwards it through untouched. + const raw = req.originalUrl || req.url || req.path; return raw.startsWith("/") ? raw : `/${raw}`; } @@ -205,14 +207,32 @@ export async function proxyAiGatewayToSiteTarget( body: req.body }); + // Cancel the request to gerbil (which cascades to gerbil cancelling its + // proxied request to the actual site target, since gerbil's reverse + // proxy derives the outbound request's context from the inbound one) if + // the client goes away before we're done. + const abortController = new AbortController(); + const onClientClose = () => { + if (!res.writableEnded) { + abortController.abort(); + } + }; + res.on("close", onClientClose); + let upstreamRes: globalThis.Response; try { upstreamRes = await fetch(gerbilUrl, { method: "POST", headers, - body + body, + signal: abortController.signal }); } catch (fetchError) { + res.off("close", onClientClose); + if (abortController.signal.aborted) { + // Client already disconnected; nothing left to respond to. + return; + } logger.error({ message: "AI gateway target proxy request failed", url: gerbilUrl, @@ -232,7 +252,11 @@ export async function proxyAiGatewayToSiteTarget( const contentType = upstreamRes.headers.get("content-type") || ""; const isStream = req.body?.stream === true || - contentType.includes("text/event-stream"); + contentType.includes("text/event-stream") || + pathFromRequest(req).includes("streamGenerateContent") || + pathFromRequest(req).includes("streamRawPredict") || + pathFromRequest(req).includes("converse-stream") || + pathFromRequest(req).includes("invoke-with-response-stream"); res.status(upstreamRes.status); res.setHeader("Content-Type", contentType || "application/json"); @@ -240,15 +264,23 @@ export async function proxyAiGatewayToSiteTarget( if (isStream && upstreamRes.body) { res.flushHeaders(); const reader = upstreamRes.body.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - res.write(value); + try { + while (!abortController.signal.aborted) { + const { done, value } = await reader.read(); + if (done) break; + res.write(value); + } + } finally { + await reader.cancel().catch(() => {}); + res.off("close", onClientClose); + } + if (!res.writableEnded) { + res.end(); } - res.end(); return; } + res.off("close", onClientClose); const text = await upstreamRes.text(); res.send(text); } From 184e1425a41b57a3375221addf104ccaba7d407c Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Aug 2026 17:22:01 -0400 Subject: [PATCH 239/494] Private connection working with traefik --- server/private/lib/traefik/getTraefikConfig.ts | 2 +- server/routers/aiGateway/pipeline.ts | 2 +- src/components/PrivateResourcesTable.tsx | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index b00dbd7c8..f51dad24e 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -1696,7 +1696,7 @@ export async function getTraefikConfig( const srKey = `inference-sr${sr.siteResourceId}`; const routerName = `${srKey}-router`; const serviceName = `${srKey}-service`; - const rule = `Host(\`${fullDomain}\`) && ClientIP(${exitNode.address})`; // restrict to coming from the exit node ip range that the client is connected to + const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to let tls: any = {}; if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index ff76a5317..08ea6d1bc 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -273,7 +273,7 @@ async function resolveTarget(host: string): Promise { .from(siteResources) .where( and( - eq(siteResources.alias, host), + eq(siteResources.fullDomain, host), eq(siteResources.mode, "inference"), eq(siteResources.enabled, true) ) diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index cb7d2645c..bc77fbc38 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -421,7 +421,10 @@ export default function PrivateResourcesTable({ /> ); } - if (resourceRow.mode === "http") { + if ( + resourceRow.mode === "http" || + resourceRow.mode === "inference" + ) { const domainId = resourceRow.domainId; const fullDomain = resourceRow.fullDomain; const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`; From 07f628b9287b4ea80e7506d9ff8be1023154c2c3 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Aug 2026 17:53:51 -0400 Subject: [PATCH 240/494] add the pangolin header information --- server/routers/aiGateway/pipeline.ts | 30 ++++++++++++++++++++++- server/routers/aiGateway/targetRouting.ts | 8 +++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 08ea6d1bc..04d318004 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -119,6 +119,28 @@ export type RequestUser = { role: string | null; }; +// Identity headers forwarded to the upstream inference endpoint when the +// requesting user is known. Omitted entirely (not sent empty) when we +// couldn't resolve a user for the request. +export function applyRequestUserHeaders( + headers: Record, + requestUser: RequestUser | null +): void { + if (!requestUser) { + return; + } + headers["Remote-User"] = requestUser.username; + if (requestUser.email) { + headers["Remote-Email"] = requestUser.email; + } + if (requestUser.name) { + headers["Remote-Name"] = requestUser.name; + } + if (requestUser.role) { + headers["Remote-Role"] = requestUser.role; + } +} + async function buildRequestUser( userId: string, orgId: string | null @@ -519,7 +541,12 @@ export async function handleAiGatewayProxy( const { provider } = selection; if (provider.type === "custom" && provider.routingMode === "target") { - return await proxyAiGatewayToSiteTarget(req, res, provider); + return await proxyAiGatewayToSiteTarget( + req, + res, + provider, + requestUser + ); } const upstreamUrl = provider.upstreamUrl; @@ -580,6 +607,7 @@ export async function handleAiGatewayProxy( config.getRawConfig().server.secret! ); applyAiProviderAuthHeaders(headers, authType, apiKey); + applyRequestUserHeaders(headers, requestUser); const body = JSON.stringify(req.body); diff --git a/server/routers/aiGateway/targetRouting.ts b/server/routers/aiGateway/targetRouting.ts index b0b2456d4..b091bf8f5 100644 --- a/server/routers/aiGateway/targetRouting.ts +++ b/server/routers/aiGateway/targetRouting.ts @@ -12,6 +12,10 @@ import { } from "@server/lib/aiProviderDefaults"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; +import { + applyRequestUserHeaders, + type RequestUser +} from "@server/routers/aiGateway/pipeline"; // Short TTL: long enough to spare the DB on a burst of requests, short // enough that target/site changes (added, removed, exit node moved) show up @@ -147,7 +151,8 @@ function pathFromRequest(req: Request): string { export async function proxyAiGatewayToSiteTarget( req: Request, res: Response, - provider: AiProvider + provider: AiProvider, + requestUser: RequestUser | null ): Promise { const providerTargets = await getProviderTargets(provider.providerId); if (providerTargets.length === 0) { @@ -191,6 +196,7 @@ export async function proxyAiGatewayToSiteTarget( config.getRawConfig().server.secret! ); applyAiProviderAuthHeaders(headers, authType, apiKey); + applyRequestUserHeaders(headers, requestUser); headers[PANGOLIN_DEST_HEADER] = target.destination; headers[PANGOLIN_HOST_HEADER] = target.hostHeader; From e91c344e64e0a4653e8adfff48befaed6935dace Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 7 Aug 2026 10:21:15 -0400 Subject: [PATCH 241/494] Update link to be correct --- .../remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx index e0d03f771..42ccc4242 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx @@ -181,7 +181,7 @@ export default function NetworkingPage() { {t("remoteExitNodeNetworkingDescription")} Date: Fri, 7 Aug 2026 10:59:35 -0400 Subject: [PATCH 242/494] support allow list and blocklist --- messages/en-US.json | 13 +- server/db/pg/schema/schema.ts | 28 +- server/db/sqlite/schema/schema.ts | 28 +- server/lib/aiCapabilities.ts | 35 +- server/lib/aiInferenceResource.ts | 576 +++++++++++------- server/lib/aiModelKeyMatch.ts | 39 ++ server/lib/aiProviderDefaults.ts | 2 +- server/routers/aiGateway/pipeline.ts | 203 +++--- server/routers/aiProvider/createAiModel.ts | 7 +- server/routers/aiProvider/updateAiModel.ts | 7 +- .../routers/resource/addAiModelToResource.ts | 23 +- .../resource/addAiProviderToResource.ts | 19 +- server/routers/resource/createResource.ts | 9 +- .../routers/resource/listResourceAiModels.ts | 6 +- .../resource/listResourceAiProviders.ts | 3 +- .../resource/removeAiModelFromResource.ts | 6 +- .../resource/removeAiProviderFromResource.ts | 5 +- .../routers/resource/setResourceAiModels.ts | 38 +- .../resource/setResourceAiProviders.ts | 5 +- .../siteResource/addAiModelToSiteResource.ts | 26 +- .../addAiProviderToSiteResource.ts | 19 +- .../siteResource/createSiteResource.ts | 9 +- .../siteResource/listSiteResourceAiModels.ts | 6 +- .../listSiteResourceAiProviders.ts | 3 +- .../removeAiModelFromSiteResource.ts | 6 +- .../removeAiProviderFromSiteResource.ts | 5 +- .../siteResource/setSiteResourceAiModels.ts | 31 +- .../setSiteResourceAiProviders.ts | 5 +- .../ai-providers/[providerId]/models/page.tsx | 177 ++++-- .../private/[niceId]/inference/page.tsx | 9 +- .../public/[niceId]/inference/page.tsx | 3 +- .../settings/resources/public/create/page.tsx | 3 +- src/lib/privateResourceForm.ts | 3 +- src/lib/queries.ts | 4 +- 34 files changed, 891 insertions(+), 470 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e994c823c..8cc780829 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1667,7 +1667,7 @@ "aiProviderTypeCustom": "Custom", "aiProviderTypeOpenaiDescription": "OpenAI API with default upstream URL", "aiProviderTypeAnthropicDescription": "Anthropic API with default upstream URL", - "aiProviderTypeGoogleGeminiDescription": "Google Gemini OpenAI-compatible endpoint", + "aiProviderTypeGoogleGeminiDescription": "Google Gemini generateContent API", "aiProviderTypeVertexAiDescription": "Google Vertex AI; upstream URL required", "aiProviderTypeBedrockDescription": "Amazon Bedrock Runtime", "aiProviderTypeMicrosoftFoundryDescription": "Microsoft Foundry; upstream URL required", @@ -1759,13 +1759,20 @@ "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 match one of these keys. Use * and ? as wildcards (for example gpt-4* or claude-?).", + "aiProviderModelsDescription": "Define allow and block patterns for this provider. Requests must match an allow pattern and must not match a block pattern. Use * and ? as wildcards (for example gpt-4* or claude-?). An empty allow list denies all models.", "aiProviderModelsPlaceholder": "Model name or pattern (e.g. gpt-4*)", + "aiProviderModelsAllow": "Allow List", + "aiProviderModelsAllowDescription": "Models that may be used through this provider. Empty means deny all.", + "aiProviderModelsAllowPlaceholder": "Allowed model or pattern (e.g. gpt-4*)", + "aiProviderModelsBlock": "Block List", + "aiProviderModelsBlockDescription": "Models to deny even if they match an allow pattern.", + "aiProviderModelsBlockPlaceholder": "Blocked model or pattern (e.g. gpt-4o-mini)", + "aiProviderModelsOverlapError": "These patterns cannot be on both lists: {keys}", "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. Exact names and patterns that conflict (identical keys, or an exact key matching another provider's pattern) are not allowed across selected providers.", + "aiResourceProvidersHelp": "Each attached provider uses its own allow and block lists. Allow patterns that conflict across attached providers are not allowed.", "aiResourceProvidersSelect": "Select providers", "aiResourceProvidersEmpty": "No AI providers found", "aiResourceProvidersUpdated": "Providers updated", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 6bc038660..90dd5abd9 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -231,10 +231,10 @@ export const resourceAiProviders = pgTable( providerId: integer("providerId") .notNull() .references(() => aiProviders.providerId, { onDelete: "cascade" }), - modelAccessMode: varchar("modelAccessMode") - .$type<"catalog" | "allowlist">() + accessMode: varchar("accessMode") + .$type<"inherit" | "select">() .notNull() - .default("catalog") + .default("inherit") }, (t) => [primaryKey({ columns: [t.resourceId, t.providerId] })] ); @@ -247,7 +247,11 @@ export const resourceAiModels = pgTable( .references(() => resources.resourceId, { onDelete: "cascade" }), modelId: integer("modelId") .notNull() - .references(() => aiModels.modelId, { onDelete: "cascade" }) + .references(() => aiModels.modelId, { onDelete: "cascade" }), + listType: varchar("listType") + .$type<"allow" | "block">() + .notNull() + .default("allow") }, (t) => [primaryKey({ columns: [t.resourceId, t.modelId] })] ); @@ -522,10 +526,10 @@ export const siteResourceAiProviders = pgTable( providerId: integer("providerId") .notNull() .references(() => aiProviders.providerId, { onDelete: "cascade" }), - modelAccessMode: varchar("modelAccessMode") - .$type<"catalog" | "allowlist">() + accessMode: varchar("accessMode") + .$type<"inherit" | "select">() .notNull() - .default("catalog") + .default("inherit") }, (t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })] ); @@ -540,7 +544,11 @@ export const siteResourceAiModels = pgTable( }), modelId: integer("modelId") .notNull() - .references(() => aiModels.modelId, { onDelete: "cascade" }) + .references(() => aiModels.modelId, { onDelete: "cascade" }), + listType: varchar("listType") + .$type<"allow" | "block">() + .notNull() + .default("allow") }, (t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })] ); @@ -1678,6 +1686,10 @@ export const aiModels = pgTable( .references(() => aiProviders.providerId, { onDelete: "cascade" }), modelKey: varchar("modelKey").notNull(), name: varchar("name").notNull(), + listType: varchar("listType") + .$type<"allow" | "block">() + .notNull() + .default("allow"), enabled: boolean("enabled").notNull().default(true), createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }).notNull() diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 8130af9ea..dab445282 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -228,10 +228,10 @@ export const resourceAiProviders = sqliteTable( providerId: integer("providerId") .notNull() .references(() => aiProviders.providerId, { onDelete: "cascade" }), - modelAccessMode: text("modelAccessMode") - .$type<"catalog" | "allowlist">() + accessMode: text("accessMode") + .$type<"inherit" | "select">() .notNull() - .default("catalog") + .default("inherit") }, (t) => [primaryKey({ columns: [t.resourceId, t.providerId] })] ); @@ -244,7 +244,11 @@ export const resourceAiModels = sqliteTable( .references(() => resources.resourceId, { onDelete: "cascade" }), modelId: integer("modelId") .notNull() - .references(() => aiModels.modelId, { onDelete: "cascade" }) + .references(() => aiModels.modelId, { onDelete: "cascade" }), + listType: text("listType") + .$type<"allow" | "block">() + .notNull() + .default("allow") }, (t) => [primaryKey({ columns: [t.resourceId, t.modelId] })] ); @@ -507,10 +511,10 @@ export const siteResourceAiProviders = sqliteTable( providerId: integer("providerId") .notNull() .references(() => aiProviders.providerId, { onDelete: "cascade" }), - modelAccessMode: text("modelAccessMode") - .$type<"catalog" | "allowlist">() + accessMode: text("accessMode") + .$type<"inherit" | "select">() .notNull() - .default("catalog") + .default("inherit") }, (t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })] ); @@ -525,7 +529,11 @@ export const siteResourceAiModels = sqliteTable( }), modelId: integer("modelId") .notNull() - .references(() => aiModels.modelId, { onDelete: "cascade" }) + .references(() => aiModels.modelId, { onDelete: "cascade" }), + listType: text("listType") + .$type<"allow" | "block">() + .notNull() + .default("allow") }, (t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })] ); @@ -1660,6 +1668,10 @@ export const aiModels = sqliteTable( .references(() => aiProviders.providerId, { onDelete: "cascade" }), modelKey: text("modelKey").notNull(), name: text("name").notNull(), + listType: text("listType") + .$type<"allow" | "block">() + .notNull() + .default("allow"), enabled: integer("enabled", { mode: "boolean" }) .notNull() .default(true), diff --git a/server/lib/aiCapabilities.ts b/server/lib/aiCapabilities.ts index e899f83c3..f7b370691 100644 --- a/server/lib/aiCapabilities.ts +++ b/server/lib/aiCapabilities.ts @@ -40,18 +40,37 @@ function paramModel(req: Request): string | undefined { } /** - * Join base URL with a path, avoiding double slashes and a duplicated trailing - * /v1 when the inbound path already starts with /v1 and the base ends with /v1. + * Join a provider base URL with an inbound request path. */ export function joinUpstreamUrl(baseUrl: string, path: string): string { const base = baseUrl.replace(/\/+$/, ""); let suffix = path.startsWith("/") ? path : `/${path}`; - if ( - base.endsWith("/v1") && - (suffix === "/v1" || suffix.startsWith("/v1/")) - ) { - suffix = suffix.slice("/v1".length) || "/"; + let basePathname = "/"; + try { + basePathname = new URL(base).pathname.replace(/\/+$/, "") || "/"; + } catch { + // Fall through with "/" non-absolute bases are not expected in + // production, but keep joining usable for malformed input. + } + + if (basePathname !== "/") { + const baseSegs = basePathname.split("/").filter(Boolean); + const pathSegs = suffix.split("/").filter(Boolean); + const max = Math.min(baseSegs.length, pathSegs.length); + let overlap = 0; + for (let n = max; n >= 1; n--) { + const baseSuffix = baseSegs.slice(-n); + const pathPrefix = pathSegs.slice(0, n); + if (baseSuffix.every((seg, i) => seg === pathPrefix[i])) { + overlap = n; + break; + } + } + if (overlap > 0) { + const remaining = pathSegs.slice(overlap); + suffix = remaining.length > 0 ? `/${remaining.join("/")}` : "/"; + } } if (suffix === "/") { @@ -175,7 +194,7 @@ export const AI_PROVIDER_CAPABILITY_DEFAULTS: Record< > = { openai: ["openai_chat"], anthropic: ["anthropic_messages"], - googleGemini: ["openai_chat"], + googleGemini: ["gemini_generate_content"], vertexAi: ["google_generate_content"], bedrock: ["bedrock_converse"], microsoftFoundry: ["openai_chat"], diff --git a/server/lib/aiInferenceResource.ts b/server/lib/aiInferenceResource.ts index d01b30dd9..51333473a 100644 --- a/server/lib/aiInferenceResource.ts +++ b/server/lib/aiInferenceResource.ts @@ -14,13 +14,17 @@ import { modelKeysConflict } from "@server/lib/aiModelKeyMatch"; type DbOrTrx = Transaction | typeof db; -export const modelAccessModeSchema = z.enum(["catalog", "allowlist"]); +export const modelListTypeSchema = z.enum(["allow", "block"]); -export type ModelAccessMode = z.infer; +export type ModelListType = z.infer; + +export const accessModeSchema = z.enum(["inherit", "select"]); + +export type AccessMode = z.infer; export const resourceAiProviderAttachmentSchema = z.strictObject({ providerId: z.number().int().positive(), - modelAccessMode: modelAccessModeSchema.optional() + accessMode: accessModeSchema.optional().default("inherit") }); export type ResourceAiProviderInput = z.infer< @@ -29,9 +33,16 @@ export type ResourceAiProviderInput = z.infer< export type ResourceAiProviderAttachment = { providerId: number; - modelAccessMode: ModelAccessMode; + accessMode: AccessMode; }; +export const resourceAiModelEntrySchema = z.strictObject({ + modelId: z.number().int().positive(), + listType: modelListTypeSchema +}); + +export type ResourceAiModelEntry = z.infer; + export type InferenceFieldsError = { error: string; }; @@ -42,58 +53,153 @@ export function isInferenceFieldsError( return "error" in value; } +/** + * Resolve which allow/block patterns apply for an attachment. + * inherit → provider lists; select → resource-selected lists (replace). + */ +export function resolveEffectiveLists(input: { + accessMode: AccessMode; + providerAllows: string[]; + providerBlocks: string[]; + resourceAllows: string[]; + resourceBlocks: string[]; +}): { allows: string[]; blocks: string[] } { + if (input.accessMode === "select") { + return { + allows: input.resourceAllows, + blocks: input.resourceBlocks + }; + } + return { + allows: input.providerAllows, + blocks: input.providerBlocks + }; +} + function normalizeAttachments( inputs: ResourceAiProviderInput[] ): ResourceAiProviderAttachment[] { - const byProvider = new Map(); + const byProviderId = new Map(); for (const input of inputs) { - byProvider.set(input.providerId, input.modelAccessMode ?? "catalog"); + byProviderId.set(input.providerId, input.accessMode ?? "inherit"); } - return [...byProvider.entries()].map(([providerId, modelAccessMode]) => ({ + return [...byProviderId.entries()].map(([providerId, accessMode]) => ({ providerId, - modelAccessMode + accessMode })); } +type EffectiveAllowRow = { + providerId: number; + modelKey: string; +}; + /** - * Ensure enabled catalog modelKeys do not conflict 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). - * - * Conflicts: identical keys, or an exact key that matches another provider's - * pattern. Full glob intersections are left to runtime ambiguity errors. + * Ensure effective allow modelKeys do not conflict across attached providers. + * inherit uses provider allows; select uses resource-selected allows (or the + * optional override map). Block patterns are ignored for overlap checks. */ export async function assertNoOverlappingModelKeys( attachments: ResourceAiProviderAttachment[], - trx: DbOrTrx = db + options: { + trx?: DbOrTrx; + resourceId?: number; + siteResourceId?: number; + selectedAllowsByProvider?: Map; + } = {} ): Promise { - const catalogProviderIds = attachments - .filter((a) => a.modelAccessMode === "catalog") - .map((a) => a.providerId); + const trx = options.trx ?? db; - if (catalogProviderIds.length < 2) { + if (attachments.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 inheritProviderIds = attachments + .filter((a) => a.accessMode === "inherit") + .map((a) => a.providerId); + const selectProviderIds = attachments + .filter((a) => a.accessMode === "select") + .map((a) => a.providerId); + + const effectiveAllows: EffectiveAllowRow[] = []; + + if (inheritProviderIds.length > 0) { + const providerAllows = await trx + .select({ + providerId: aiModels.providerId, + modelKey: aiModels.modelKey + }) + .from(aiModels) + .where( + and( + inArray(aiModels.providerId, inheritProviderIds), + eq(aiModels.enabled, true), + eq(aiModels.listType, "allow") + ) + ); + effectiveAllows.push(...providerAllows); + } + + if (selectProviderIds.length > 0) { + if (options.selectedAllowsByProvider) { + for (const providerId of selectProviderIds) { + const keys = + options.selectedAllowsByProvider.get(providerId) ?? []; + for (const modelKey of keys) { + effectiveAllows.push({ providerId, modelKey }); + } + } + } else if (options.resourceId !== undefined) { + const rows = await trx + .select({ + providerId: aiModels.providerId, + modelKey: aiModels.modelKey + }) + .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) + ) + ); + effectiveAllows.push(...rows); + } else if (options.siteResourceId !== undefined) { + const rows = await trx + .select({ + providerId: aiModels.providerId, + modelKey: aiModels.modelKey + }) + .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) + ) + ); + effectiveAllows.push(...rows); + } + } const conflictPairs: string[] = []; - for (let i = 0; i < models.length; i++) { - for (let j = i + 1; j < models.length; j++) { - const left = models[i]; - const right = models[j]; + for (let i = 0; i < effectiveAllows.length; i++) { + for (let j = i + 1; j < effectiveAllows.length; j++) { + const left = effectiveAllows[i]; + const right = effectiveAllows[j]; if (left.providerId === right.providerId) { continue; } @@ -124,6 +230,8 @@ export async function resolveProviderAttachments(input: { orgId: string; attachments: ResourceAiProviderInput[]; requireAtLeastOne: boolean; + resourceId?: number; + siteResourceId?: number; }): Promise { const attachments = normalizeAttachments(input.attachments); @@ -165,7 +273,10 @@ export async function resolveProviderAttachments(input: { }; } - const overlapError = await assertNoOverlappingModelKeys(attachments); + const overlapError = await assertNoOverlappingModelKeys(attachments, { + resourceId: input.resourceId, + siteResourceId: input.siteResourceId + }); if (overlapError) { return overlapError; } @@ -188,6 +299,11 @@ export async function assertInferenceModeAllowsProviderFields(input: { return null; } +/** + * Attach providers to a resource. Inherit attachments use the provider lists + * as-is (resource model rows for those providers are pruned). Select + * attachments keep resource-selected allow/block subsets. + */ export async function setPublicResourceAiProviders( resourceId: number, attachments: ResourceAiProviderAttachment[], @@ -202,12 +318,16 @@ export async function setPublicResourceAiProviders( attachments.map((a) => ({ resourceId, providerId: a.providerId, - modelAccessMode: a.modelAccessMode + accessMode: a.accessMode })) ); } - await prunePublicResourceAllowlistToAllowlistProviders(resourceId, trx); + await prunePublicResourceModelsToSelectProviders( + resourceId, + attachments, + trx + ); } export async function setSiteResourceAiProviders( @@ -224,12 +344,103 @@ export async function setSiteResourceAiProviders( attachments.map((a) => ({ siteResourceId, providerId: a.providerId, - modelAccessMode: a.modelAccessMode + accessMode: a.accessMode })) ); } - await pruneSiteResourceAllowlistToAllowlistProviders(siteResourceId, trx); + await pruneSiteResourceModelsToSelectProviders( + siteResourceId, + attachments, + trx + ); +} + +/** + * Keep resource model rows only for providers in select mode. + */ +async function prunePublicResourceModelsToSelectProviders( + resourceId: number, + attachments: ResourceAiProviderAttachment[], + trx: DbOrTrx +): Promise { + const selectProviderIds = attachments + .filter((a) => a.accessMode === "select") + .map((a) => a.providerId); + + if (selectProviderIds.length === 0) { + await trx + .delete(resourceAiModels) + .where(eq(resourceAiModels.resourceId, resourceId)); + return; + } + + const existing = await trx + .select({ + modelId: resourceAiModels.modelId, + providerId: aiModels.providerId + }) + .from(resourceAiModels) + .innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId)) + .where(eq(resourceAiModels.resourceId, resourceId)); + + const allowed = new Set(selectProviderIds); + const toRemove = existing + .filter((row) => !allowed.has(row.providerId)) + .map((row) => row.modelId); + + if (toRemove.length > 0) { + await trx + .delete(resourceAiModels) + .where( + and( + eq(resourceAiModels.resourceId, resourceId), + inArray(resourceAiModels.modelId, toRemove) + ) + ); + } +} + +async function pruneSiteResourceModelsToSelectProviders( + siteResourceId: number, + attachments: ResourceAiProviderAttachment[], + trx: DbOrTrx +): Promise { + const selectProviderIds = attachments + .filter((a) => a.accessMode === "select") + .map((a) => a.providerId); + + if (selectProviderIds.length === 0) { + await trx + .delete(siteResourceAiModels) + .where(eq(siteResourceAiModels.siteResourceId, siteResourceId)); + return; + } + + const existing = await trx + .select({ + modelId: siteResourceAiModels.modelId, + providerId: aiModels.providerId + }) + .from(siteResourceAiModels) + .innerJoin(aiModels, eq(siteResourceAiModels.modelId, aiModels.modelId)) + .where(eq(siteResourceAiModels.siteResourceId, siteResourceId)); + + const allowed = new Set(selectProviderIds); + const toRemove = existing + .filter((row) => !allowed.has(row.providerId)) + .map((row) => row.modelId); + + if (toRemove.length > 0) { + await trx + .delete(siteResourceAiModels) + .where( + and( + eq(siteResourceAiModels.siteResourceId, siteResourceId), + inArray(siteResourceAiModels.modelId, toRemove) + ) + ); + } } export async function clearPublicResourceAiConfig( @@ -256,120 +467,14 @@ export async function clearSiteResourceAiConfig( .where(eq(siteResourceAiProviders.siteResourceId, siteResourceId)); } -async function prunePublicResourceAllowlistToAllowlistProviders( - resourceId: number, - trx: DbOrTrx = db -): Promise { - const allowlistProviders = await trx - .select({ providerId: resourceAiProviders.providerId }) - .from(resourceAiProviders) - .where( - and( - eq(resourceAiProviders.resourceId, resourceId), - eq(resourceAiProviders.modelAccessMode, "allowlist") - ) - ); - - if (allowlistProviders.length === 0) { - await trx - .delete(resourceAiModels) - .where(eq(resourceAiModels.resourceId, resourceId)); - return; - } - - const validModels = await trx - .select({ modelId: aiModels.modelId }) - .from(aiModels) - .where( - inArray( - aiModels.providerId, - allowlistProviders.map((p) => p.providerId) - ) - ); - const validIds = validModels.map((m) => m.modelId); - - const existing = await trx - .select({ modelId: resourceAiModels.modelId }) - .from(resourceAiModels) - .where(eq(resourceAiModels.resourceId, resourceId)); - - const toRemove = existing - .map((e) => e.modelId) - .filter((id) => !validIds.includes(id)); - - if (toRemove.length > 0) { - await trx - .delete(resourceAiModels) - .where( - and( - eq(resourceAiModels.resourceId, resourceId), - inArray(resourceAiModels.modelId, toRemove) - ) - ); - } -} - -async function pruneSiteResourceAllowlistToAllowlistProviders( - siteResourceId: number, - trx: DbOrTrx = db -): Promise { - const allowlistProviders = await trx - .select({ providerId: siteResourceAiProviders.providerId }) - .from(siteResourceAiProviders) - .where( - and( - eq(siteResourceAiProviders.siteResourceId, siteResourceId), - eq(siteResourceAiProviders.modelAccessMode, "allowlist") - ) - ); - - if (allowlistProviders.length === 0) { - await trx - .delete(siteResourceAiModels) - .where(eq(siteResourceAiModels.siteResourceId, siteResourceId)); - return; - } - - const validModels = await trx - .select({ modelId: aiModels.modelId }) - .from(aiModels) - .where( - inArray( - aiModels.providerId, - allowlistProviders.map((p) => p.providerId) - ) - ); - const validIds = validModels.map((m) => m.modelId); - - const existing = await trx - .select({ modelId: siteResourceAiModels.modelId }) - .from(siteResourceAiModels) - .where(eq(siteResourceAiModels.siteResourceId, siteResourceId)); - - const toRemove = existing - .map((e) => e.modelId) - .filter((id) => !validIds.includes(id)); - - if (toRemove.length > 0) { - await trx - .delete(siteResourceAiModels) - .where( - and( - eq(siteResourceAiModels.siteResourceId, siteResourceId), - inArray(siteResourceAiModels.modelId, toRemove) - ) - ); - } -} - export async function listPublicResourceAiProviders(resourceId: number) { return db .select({ providerId: resourceAiProviders.providerId, - modelAccessMode: resourceAiProviders.modelAccessMode, name: aiProviders.name, type: aiProviders.type, - enabled: aiProviders.enabled + enabled: aiProviders.enabled, + accessMode: resourceAiProviders.accessMode }) .from(resourceAiProviders) .innerJoin( @@ -383,10 +488,10 @@ export async function listSiteResourceAiProviders(siteResourceId: number) { return db .select({ providerId: siteResourceAiProviders.providerId, - modelAccessMode: siteResourceAiProviders.modelAccessMode, name: aiProviders.name, type: aiProviders.type, - enabled: aiProviders.enabled + enabled: aiProviders.enabled, + accessMode: siteResourceAiProviders.accessMode }) .from(siteResourceAiProviders) .innerJoin( @@ -397,15 +502,15 @@ export async function listSiteResourceAiProviders(siteResourceId: number) { } /** - * Allowlist APIs require an inference resource with at least one - * attached provider in allowlist mode. + * Model list APIs require an inference resource with at least one select-mode + * attached provider. */ -export async function assertPublicAllowlistApiEligible(resource: { +export async function assertPublicModelListApiEligible(resource: { resourceId: number; mode: string; }): Promise { if (resource.mode !== "inference") { - return "AI model allowlists are only supported on inference-mode resources"; + return "AI model lists are only supported on inference-mode resources"; } const [row] = await db @@ -414,23 +519,23 @@ export async function assertPublicAllowlistApiEligible(resource: { .where( and( eq(resourceAiProviders.resourceId, resource.resourceId), - eq(resourceAiProviders.modelAccessMode, "allowlist") + eq(resourceAiProviders.accessMode, "select") ) ) .limit(1); if (!row) { - return "Attach at least one AI provider with modelAccessMode=allowlist before managing allowed models"; + return "Set at least one attached AI provider to select mode before managing model lists"; } return null; } -export async function assertSiteAllowlistApiEligible(siteResource: { +export async function assertSiteModelListApiEligible(siteResource: { siteResourceId: number; mode: string; }): Promise { if (siteResource.mode !== "inference") { - return "AI model allowlists are only supported on inference-mode resources"; + return "AI model lists are only supported on inference-mode resources"; } const [row] = await db @@ -442,33 +547,36 @@ export async function assertSiteAllowlistApiEligible(siteResource: { siteResourceAiProviders.siteResourceId, siteResource.siteResourceId ), - eq(siteResourceAiProviders.modelAccessMode, "allowlist") + eq(siteResourceAiProviders.accessMode, "select") ) ) .limit(1); if (!row) { - return "Attach at least one AI provider with modelAccessMode=allowlist before managing allowed models"; + return "Set at least one attached AI provider to select mode before managing model lists"; } return null; } /** - * Models must belong to providers attached to this resource in allowlist mode, - * and those providers must belong to the resource's org. + * Resource model entries must belong to select-mode attached providers, and + * listType must match the provider catalog entry (allow→allow, block→block). */ -export async function assertModelsBelongToPublicAllowlistProviders(input: { +export async function assertPublicResourceModelEntriesValid(input: { orgId: string; resourceId: number; - modelIds: number[]; + models: ResourceAiModelEntry[]; }): Promise { - const uniqueIds = [...new Set(input.modelIds)]; - if (uniqueIds.length === 0) { + const uniqueModels = dedupeModelEntries(input.models); + if (uniqueModels.length === 0) { return null; } - const allowlistProviders = await db - .select({ providerId: resourceAiProviders.providerId }) + const attachments = await db + .select({ + providerId: resourceAiProviders.providerId, + accessMode: resourceAiProviders.accessMode + }) .from(resourceAiProviders) .innerJoin( aiProviders, @@ -477,49 +585,33 @@ export async function assertModelsBelongToPublicAllowlistProviders(input: { .where( and( eq(resourceAiProviders.resourceId, input.resourceId), - eq(resourceAiProviders.modelAccessMode, "allowlist"), eq(aiProviders.orgId, input.orgId) ) ); - if (allowlistProviders.length === 0) { - return "No allowlist AI providers are attached to this resource"; - } - - const validModels = await db - .select({ modelId: aiModels.modelId }) - .from(aiModels) - .innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId)) - .where( - and( - inArray(aiModels.modelId, uniqueIds), - inArray( - aiModels.providerId, - allowlistProviders.map((p) => p.providerId) - ), - eq(aiProviders.orgId, input.orgId) - ) - ); - - if (validModels.length !== uniqueIds.length) { - return "One or more model IDs do not exist or do not belong to an allowlist provider on this resource"; - } - - return null; + return assertModelEntriesValid({ + orgId: input.orgId, + modelEntries: uniqueModels, + attachments, + resourceLabel: "resource" + }); } -export async function assertModelsBelongToSiteAllowlistProviders(input: { +export async function assertSiteResourceModelEntriesValid(input: { orgId: string; siteResourceId: number; - modelIds: number[]; + models: ResourceAiModelEntry[]; }): Promise { - const uniqueIds = [...new Set(input.modelIds)]; - if (uniqueIds.length === 0) { + const uniqueModels = dedupeModelEntries(input.models); + if (uniqueModels.length === 0) { return null; } - const allowlistProviders = await db - .select({ providerId: siteResourceAiProviders.providerId }) + const attachments = await db + .select({ + providerId: siteResourceAiProviders.providerId, + accessMode: siteResourceAiProviders.accessMode + }) .from(siteResourceAiProviders) .innerJoin( aiProviders, @@ -531,32 +623,92 @@ export async function assertModelsBelongToSiteAllowlistProviders(input: { siteResourceAiProviders.siteResourceId, input.siteResourceId ), - eq(siteResourceAiProviders.modelAccessMode, "allowlist"), eq(aiProviders.orgId, input.orgId) ) ); - if (allowlistProviders.length === 0) { - return "No allowlist AI providers are attached to this site resource"; + return assertModelEntriesValid({ + orgId: input.orgId, + modelEntries: uniqueModels, + attachments, + resourceLabel: "site resource" + }); +} + +function dedupeModelEntries( + models: ResourceAiModelEntry[] +): ResourceAiModelEntry[] { + const byModelId = new Map( + models.map((m) => [m.modelId, m.listType] as const) + ); + return [...byModelId.entries()].map(([modelId, listType]) => ({ + modelId, + listType + })); +} + +async function assertModelEntriesValid(input: { + orgId: string; + modelEntries: ResourceAiModelEntry[]; + attachments: ResourceAiProviderAttachment[]; + resourceLabel: string; +}): Promise { + const selectProviderIds = input.attachments + .filter((a) => a.accessMode === "select") + .map((a) => a.providerId); + + if (selectProviderIds.length === 0) { + return "Set at least one attached AI provider to select mode before managing model lists"; } - const validModels = await db - .select({ modelId: aiModels.modelId }) + const modelIds = input.modelEntries.map((m) => m.modelId); + const catalogRows = await db + .select({ + modelId: aiModels.modelId, + modelKey: aiModels.modelKey, + listType: aiModels.listType, + providerId: aiModels.providerId, + enabled: aiModels.enabled + }) .from(aiModels) .innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId)) .where( and( - inArray(aiModels.modelId, uniqueIds), - inArray( - aiModels.providerId, - allowlistProviders.map((p) => p.providerId) - ), + inArray(aiModels.modelId, modelIds), + inArray(aiModels.providerId, selectProviderIds), eq(aiProviders.orgId, input.orgId) ) ); - if (validModels.length !== uniqueIds.length) { - return "One or more model IDs do not exist or do not belong to an allowlist provider on this site resource"; + if (catalogRows.length !== modelIds.length) { + return `One or more model IDs do not exist or do not belong to a select-mode provider on this ${input.resourceLabel}`; + } + + const catalogById = new Map(catalogRows.map((row) => [row.modelId, row])); + const selectedAllowsByProvider = new Map(); + for (const entry of input.modelEntries) { + const catalog = catalogById.get(entry.modelId); + if (!catalog) { + return `One or more model IDs do not exist or do not belong to a select-mode provider on this ${input.resourceLabel}`; + } + if (catalog.listType !== entry.listType) { + return `Model ${entry.modelId} must use listType "${catalog.listType}" to match the provider catalog entry`; + } + if (!catalog.enabled) { + return `Model ${entry.modelId} is disabled on its provider`; + } + if (entry.listType === "allow") { + const keys = selectedAllowsByProvider.get(catalog.providerId) ?? []; + keys.push(catalog.modelKey); + selectedAllowsByProvider.set(catalog.providerId, keys); + } + } + + const overlapError = await assertNoOverlappingModelKeys(input.attachments, { + selectedAllowsByProvider + }); + if (overlapError) { + return overlapError.error; } return null; diff --git a/server/lib/aiModelKeyMatch.ts b/server/lib/aiModelKeyMatch.ts index fa27be56f..38184c3b0 100644 --- a/server/lib/aiModelKeyMatch.ts +++ b/server/lib/aiModelKeyMatch.ts @@ -83,3 +83,42 @@ export function modelKeysConflict(a: string, b: string): boolean { return modelKeyMatches(b, a); } + +/** + * Provider-layer policy: empty allowlist denies all. Blocklist only applies + * after an allow match. + */ +export function isAllowedByLists( + requested: string, + allows: string[], + blocks: string[] +): boolean { + if (allows.length === 0) { + return false; + } + if (!allows.some((pattern) => modelKeyMatches(pattern, requested))) { + return false; + } + if (blocks.some((pattern) => modelKeyMatches(pattern, requested))) { + return false; + } + return true; +} + +/** + * Among allow patterns that match `requested`, return the most specific one, + * or null if none match. + */ +export function mostSpecificMatchingAllow( + requested: string, + allows: string[] +): string | null { + const matching = allows.filter((pattern) => + modelKeyMatches(pattern, requested) + ); + if (matching.length === 0) { + return null; + } + matching.sort(compareModelKeySpecificity); + return matching[0]; +} diff --git a/server/lib/aiProviderDefaults.ts b/server/lib/aiProviderDefaults.ts index e48c6f877..f24330ec8 100644 --- a/server/lib/aiProviderDefaults.ts +++ b/server/lib/aiProviderDefaults.ts @@ -43,7 +43,7 @@ export const AI_PROVIDER_DEFAULTS: Record< authType: "x-api-key" }, googleGemini: { - upstreamUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", + upstreamUrl: "https://generativelanguage.googleapis.com", authType: "x-goog-api-key" }, vertexAi: { diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 04d318004..d881b27ca 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -38,10 +38,15 @@ import { isIpInCidr } from "@server/lib/ip"; import { localCache } from "@server/lib/cache"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; -import type { ModelAccessMode } from "@server/lib/aiInferenceResource"; +import { + resolveEffectiveLists, + type AccessMode, + type ModelListType +} from "@server/lib/aiInferenceResource"; import { compareModelKeySpecificity, - modelKeyMatches + isAllowedByLists, + mostSpecificMatchingAllow } from "@server/lib/aiModelKeyMatch"; import { aiGatewayUpstreamFetch } from "@server/lib/aiGatewayUpstreamFetch"; @@ -96,7 +101,19 @@ async function findClientByIp(ip: string): Promise { type ProviderAttachment = { provider: AiProvider; - modelAccessMode: ModelAccessMode; + accessMode: AccessMode; +}; + +type ResourceModelPattern = { + providerId: number; + modelKey: string; + listType: ModelListType; + enabled: boolean; +}; + +type ProviderPatternLists = { + allows: string[]; + blocks: string[]; }; type ResolvedTarget = { @@ -104,7 +121,7 @@ type ResolvedTarget = { siteResourceId: number | null; orgId: string | null; attachments: ProviderAttachment[]; - allowlistedModelIds: Set; + resourceListsByProvider: Map; }; type ProviderSelection = @@ -231,8 +248,8 @@ async function resolveTarget(host: string): Promise { if (resourceRow) { const attachmentRows = await db .select({ - modelAccessMode: resourceAiProviders.modelAccessMode, - provider: aiProviders + provider: aiProviders, + accessMode: resourceAiProviders.accessMode }) .from(resourceAiProviders) .innerJoin( @@ -252,38 +269,26 @@ async function resolveTarget(host: string): Promise { const attachments: ProviderAttachment[] = attachmentRows.map((a) => ({ provider: a.provider, - modelAccessMode: a.modelAccessMode as ModelAccessMode + accessMode: a.accessMode })); - const allowlistProviderIds = attachments - .filter((a) => a.modelAccessMode === "allowlist") - .map((a) => a.provider.providerId); - const allowlistedModelIds = new Set(); - if (allowlistProviderIds.length > 0) { - const restrictions = await db - .select({ modelId: resourceAiModels.modelId }) - .from(resourceAiModels) - .innerJoin( - aiModels, - eq(resourceAiModels.modelId, aiModels.modelId) - ) - .where( - and( - eq(resourceAiModels.resourceId, resourceRow.resourceId), - inArray(aiModels.providerId, allowlistProviderIds) - ) - ); - for (const row of restrictions) { - allowlistedModelIds.add(row.modelId); - } - } + const resourcePatterns = await db + .select({ + providerId: aiModels.providerId, + modelKey: aiModels.modelKey, + listType: resourceAiModels.listType, + enabled: aiModels.enabled + }) + .from(resourceAiModels) + .innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId)) + .where(eq(resourceAiModels.resourceId, resourceRow.resourceId)); return { resourceId: resourceRow.resourceId, siteResourceId: null, orgId: resourceRow.orgId, attachments, - allowlistedModelIds + resourceListsByProvider: groupPatternsByProvider(resourcePatterns) }; } @@ -305,8 +310,8 @@ async function resolveTarget(host: string): Promise { if (siteResourceRow) { const attachmentRows = await db .select({ - modelAccessMode: siteResourceAiProviders.modelAccessMode, - provider: aiProviders + provider: aiProviders, + accessMode: siteResourceAiProviders.accessMode }) .from(siteResourceAiProviders) .innerJoin( @@ -329,50 +334,65 @@ async function resolveTarget(host: string): Promise { const attachments: ProviderAttachment[] = attachmentRows.map((a) => ({ provider: a.provider, - modelAccessMode: a.modelAccessMode as ModelAccessMode + accessMode: a.accessMode })); - const allowlistProviderIds = attachments - .filter((a) => a.modelAccessMode === "allowlist") - .map((a) => a.provider.providerId); - const allowlistedModelIds = new Set(); - if (allowlistProviderIds.length > 0) { - const restrictions = await db - .select({ modelId: siteResourceAiModels.modelId }) - .from(siteResourceAiModels) - .innerJoin( - aiModels, - eq(siteResourceAiModels.modelId, aiModels.modelId) + const resourcePatterns = await db + .select({ + providerId: aiModels.providerId, + modelKey: aiModels.modelKey, + listType: siteResourceAiModels.listType, + enabled: aiModels.enabled + }) + .from(siteResourceAiModels) + .innerJoin( + aiModels, + eq(siteResourceAiModels.modelId, aiModels.modelId) + ) + .where( + eq( + siteResourceAiModels.siteResourceId, + siteResourceRow.siteResourceId ) - .where( - and( - eq( - siteResourceAiModels.siteResourceId, - siteResourceRow.siteResourceId - ), - inArray(aiModels.providerId, allowlistProviderIds) - ) - ); - for (const row of restrictions) { - allowlistedModelIds.add(row.modelId); - } - } + ); return { resourceId: null, siteResourceId: siteResourceRow.siteResourceId, orgId: siteResourceRow.orgId, attachments, - allowlistedModelIds + resourceListsByProvider: groupPatternsByProvider(resourcePatterns) }; } return null; } +function groupPatternsByProvider( + patterns: ResourceModelPattern[] +): Map { + const byProvider = new Map(); + for (const pattern of patterns) { + if (!pattern.enabled) { + continue; + } + let lists = byProvider.get(pattern.providerId); + if (!lists) { + lists = { allows: [], blocks: [] }; + byProvider.set(pattern.providerId, lists); + } + if (pattern.listType === "allow") { + lists.allows.push(pattern.modelKey); + } else { + lists.blocks.push(pattern.modelKey); + } + } + return byProvider; +} + async function selectProvider( attachments: ProviderAttachment[], - allowlistedModelIds: Set, + resourceListsByProvider: Map, requestedModel: string | undefined ): Promise { if (!requestedModel) { @@ -383,10 +403,10 @@ async function selectProvider( }; } - const providerById = new Map( + const attachmentByProviderId = new Map( attachments.map((a) => [a.provider.providerId, a]) ); - const providerIds = [...providerById.keys()]; + const providerIds = [...attachmentByProviderId.keys()]; if (providerIds.length === 0) { return { ok: false, @@ -397,48 +417,54 @@ async function selectProvider( const providerModels = await db .select({ - modelId: aiModels.modelId, providerId: aiModels.providerId, modelKey: aiModels.modelKey, + listType: aiModels.listType, enabled: aiModels.enabled }) .from(aiModels) .where(inArray(aiModels.providerId, providerIds)); + const allowsByProvider = new Map(); + const blocksByProvider = new Map(); + for (const model of providerModels) { + if (!model.enabled) { + continue; + } + const targetMap = + model.listType === "allow" ? allowsByProvider : blocksByProvider; + const existing = targetMap.get(model.providerId) ?? []; + existing.push(model.modelKey); + targetMap.set(model.providerId, existing); + } + type ModelCandidate = { provider: AiProvider; modelKey: string; }; const candidates: ModelCandidate[] = []; - for (const model of providerModels) { - if (!model.enabled) { + for (const [providerId, attachment] of attachmentByProviderId) { + const resourceLists = resourceListsByProvider.get(providerId); + const { allows, blocks } = resolveEffectiveLists({ + accessMode: attachment.accessMode, + providerAllows: allowsByProvider.get(providerId) ?? [], + providerBlocks: blocksByProvider.get(providerId) ?? [], + resourceAllows: resourceLists?.allows ?? [], + resourceBlocks: resourceLists?.blocks ?? [] + }); + + if (!isAllowedByLists(requestedModel, allows, blocks)) { continue; } - - if (!modelKeyMatches(model.modelKey, requestedModel)) { + const matchingAllow = mostSpecificMatchingAllow(requestedModel, allows); + if (!matchingAllow) { continue; } - - const attachment = providerById.get(model.providerId); - if (!attachment) { - continue; - } - - if (attachment.modelAccessMode === "catalog") { - candidates.push({ - provider: attachment.provider, - modelKey: model.modelKey - }); - continue; - } - - if (allowlistedModelIds.has(model.modelId)) { - candidates.push({ - provider: attachment.provider, - modelKey: model.modelKey - }); - } + candidates.push({ + provider: attachment.provider, + modelKey: matchingAllow + }); } if (candidates.length === 0) { @@ -504,7 +530,8 @@ export async function handleAiGatewayProxy( }); } - const { attachments, allowlistedModelIds, resourceId, orgId } = target; + const { attachments, resourceListsByProvider, resourceId, orgId } = + target; const requestUser = await resolveRequestUser(req, resourceId, orgId); if (requestUser) { @@ -529,7 +556,7 @@ export async function handleAiGatewayProxy( const selection = await selectProvider( capableAttachments, - allowlistedModelIds, + resourceListsByProvider, requestedModel ); if (!selection.ok) { diff --git a/server/routers/aiProvider/createAiModel.ts b/server/routers/aiProvider/createAiModel.ts index dfe2a62e1..4a2734bbd 100644 --- a/server/routers/aiProvider/createAiModel.ts +++ b/server/routers/aiProvider/createAiModel.ts @@ -9,6 +9,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types"; +import { modelListTypeSchema } from "@server/lib/aiInferenceResource"; const paramsSchema = z.strictObject({ providerId: z.coerce.number().int().positive() @@ -17,7 +18,8 @@ const paramsSchema = z.strictObject({ const bodySchema = z.strictObject({ modelKey: z.string().nonempty(), name: z.string().nonempty(), - enabled: z.boolean().optional() + enabled: z.boolean().optional(), + listType: modelListTypeSchema.optional().default("allow") }); registry.registerPath({ @@ -69,7 +71,7 @@ export async function createAiModel( } const { providerId } = parsedParams.data; - const { modelKey, name, enabled } = parsedBody.data; + const { modelKey, name, enabled, listType } = parsedBody.data; const [provider] = req.aiProvider && req.aiProvider.providerId === providerId @@ -116,6 +118,7 @@ export async function createAiModel( providerId, modelKey, name, + listType, enabled: enabled ?? true, createdAt: now, updatedAt: now diff --git a/server/routers/aiProvider/updateAiModel.ts b/server/routers/aiProvider/updateAiModel.ts index fe9ccf273..c0d1514d2 100644 --- a/server/routers/aiProvider/updateAiModel.ts +++ b/server/routers/aiProvider/updateAiModel.ts @@ -9,6 +9,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq, ne } from "drizzle-orm"; import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types"; +import { modelListTypeSchema } from "@server/lib/aiInferenceResource"; const paramsSchema = z.strictObject({ modelId: z.coerce.number().int().positive() @@ -17,7 +18,8 @@ const paramsSchema = z.strictObject({ const bodySchema = z.strictObject({ modelKey: z.string().nonempty().optional(), name: z.string().nonempty().optional(), - enabled: z.boolean().optional() + enabled: z.boolean().optional(), + listType: modelListTypeSchema.optional() }); registry.registerPath({ @@ -128,6 +130,9 @@ export async function updateAiModel( if (body.enabled !== undefined) { updateData.enabled = body.enabled; } + if (body.listType !== undefined) { + updateData.listType = body.listType; + } const [model] = await db .update(aiModels) diff --git a/server/routers/resource/addAiModelToResource.ts b/server/routers/resource/addAiModelToResource.ts index 3121c9ffa..4777bfe4d 100644 --- a/server/routers/resource/addAiModelToResource.ts +++ b/server/routers/resource/addAiModelToResource.ts @@ -9,11 +9,14 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { - assertPublicAllowlistApiEligible, - assertModelsBelongToPublicAllowlistProviders + assertPublicModelListApiEligible, + assertPublicResourceModelEntriesValid, + modelListTypeSchema } from "@server/lib/aiInferenceResource"; + const addAiModelToResourceBodySchema = z.strictObject({ - modelId: z.int().positive() + modelId: z.number().int().positive(), + listType: modelListTypeSchema.optional().default("allow") }); const addAiModelToResourceParamsSchema = z.strictObject({ @@ -24,7 +27,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/ai-models/add", description: - "Add a single catalog model to an inference resource allowlist. Requires at least one attached AI provider in allowlist mode. The model must belong to a provider attached in allowlist mode.", + "Add a single model to an inference resource allow/block selection. Requires at least one attached AI provider in select mode. The model must belong to a select-mode provider and its listType must match the provider catalog entry. listType defaults to allow.", tags: [OpenAPITags.PublicResource], request: { params: addAiModelToResourceParamsSchema, @@ -70,7 +73,7 @@ export async function addAiModelToResource( ); } - const { modelId } = parsedBody.data; + const { modelId, listType } = parsedBody.data; const parsedParams = addAiModelToResourceParamsSchema.safeParse( req.params @@ -98,15 +101,15 @@ export async function addAiModelToResource( ); } - const eligibleError = await assertPublicAllowlistApiEligible(resource); + const eligibleError = await assertPublicModelListApiEligible(resource); if (eligibleError) { return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError)); } - const modelError = await assertModelsBelongToPublicAllowlistProviders({ + const modelError = await assertPublicResourceModelEntriesValid({ orgId: resource.orgId, resourceId, - modelIds: [modelId] + models: [{ modelId, listType }] }); if (modelError) { return next(createHttpError(HttpCode.BAD_REQUEST, modelError)); @@ -131,7 +134,9 @@ export async function addAiModelToResource( ); } - await db.insert(resourceAiModels).values({ resourceId, modelId }); + await db + .insert(resourceAiModels) + .values({ resourceId, modelId, listType }); return response(res, { data: {}, diff --git a/server/routers/resource/addAiProviderToResource.ts b/server/routers/resource/addAiProviderToResource.ts index d51cf244c..bcc175c18 100644 --- a/server/routers/resource/addAiProviderToResource.ts +++ b/server/routers/resource/addAiProviderToResource.ts @@ -11,14 +11,12 @@ import { OpenAPITags, registry } from "@server/openApi"; import { isInferenceFieldsError, listPublicResourceAiProviders, - modelAccessModeSchema, resolveProviderAttachments, setPublicResourceAiProviders } from "@server/lib/aiInferenceResource"; const addAiProviderToResourceBodySchema = z.strictObject({ - providerId: z.number().int().positive(), - modelAccessMode: modelAccessModeSchema.optional() + providerId: z.number().int().positive() }); const addAiProviderToResourceParamsSchema = z.strictObject({ @@ -29,7 +27,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/ai-providers/add", description: - "Add or replace a single AI provider attachment on an inference resource.", + "Add or replace a single AI provider attachment on an inference resource. The provider is attached in inherit mode, using its own allow/block lists.", tags: [OpenAPITags.PublicResource], request: { params: addAiProviderToResourceParamsSchema, @@ -77,7 +75,7 @@ export async function addAiProviderToResource( ); } - const { providerId, modelAccessMode } = parsedBody.data; + const { providerId } = parsedBody.data; const parsedParams = addAiProviderToResourceParamsSchema.safeParse( req.params @@ -120,18 +118,21 @@ export async function addAiProviderToResource( .filter((a) => a.providerId !== providerId) .map((a) => ({ providerId: a.providerId, - modelAccessMode: a.modelAccessMode + accessMode: a.accessMode })), - { providerId, modelAccessMode } + { providerId, accessMode: "inherit" as const } ]; const attachments = await resolveProviderAttachments({ orgId: resource.orgId, attachments: nextAttachments, - requireAtLeastOne: true + requireAtLeastOne: true, + resourceId }); if (isInferenceFieldsError(attachments)) { - return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error)); + return next( + createHttpError(HttpCode.BAD_REQUEST, attachments.error) + ); } await setPublicResourceAiProviders(resourceId, attachments); diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 47e70140f..e225458d1 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -109,7 +109,7 @@ const createHttpResourceSchema = z .array(resourceAiProviderAttachmentSchema) .optional() .describe( - "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." + "For inference-mode resources: AI providers to attach. Providers are attached in inherit mode, using each provider's own allow/block lists. Effective allow model keys must be unique across attached providers." ) }) .refine( @@ -391,9 +391,14 @@ async function createHttpResource( let providerAttachments: ResourceAiProviderAttachment[] = []; if (effectiveMode === "inference") { + // A new resource has no model selections yet, so providers always start + // in inherit mode; select can be enabled afterwards. const resolved = await resolveProviderAttachments({ orgId, - attachments: aiProviderInputs ?? [], + attachments: (aiProviderInputs ?? []).map((p) => ({ + providerId: p.providerId, + accessMode: "inherit" as const + })), requireAtLeastOne: false }); if (isInferenceFieldsError(resolved)) { diff --git a/server/routers/resource/listResourceAiModels.ts b/server/routers/resource/listResourceAiModels.ts index 0a5dea778..f16e0c2da 100644 --- a/server/routers/resource/listResourceAiModels.ts +++ b/server/routers/resource/listResourceAiModels.ts @@ -19,7 +19,9 @@ async function query(resourceId: number) { modelId: aiModels.modelId, modelKey: aiModels.modelKey, name: aiModels.name, - enabled: aiModels.enabled + providerId: aiModels.providerId, + enabled: aiModels.enabled, + listType: resourceAiModels.listType }) .from(resourceAiModels) .innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId)) @@ -34,7 +36,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/ai-models", description: - "List catalog models on this resource's allowlist. Only enforced when modelAccessMode=allowlist; an empty allowlist denies all models.", + "List the models this resource has selected from its select-mode providers' allow/block lists. Providers in inherit mode are not represented here; they use their own lists.", tags: [OpenAPITags.PublicResource], request: { params: listResourceAiModelsParamsSchema diff --git a/server/routers/resource/listResourceAiProviders.ts b/server/routers/resource/listResourceAiProviders.ts index 2c81c45d5..f48ad71fe 100644 --- a/server/routers/resource/listResourceAiProviders.ts +++ b/server/routers/resource/listResourceAiProviders.ts @@ -21,7 +21,8 @@ export type ListResourceAiProvidersResponse = { registry.registerPath({ method: "get", path: "/resource/{resourceId}/ai-providers", - description: "List AI providers attached to an inference resource.", + description: + "List AI providers attached to an inference resource, including each attachment's accessMode.", tags: [OpenAPITags.PublicResource], request: { params: listResourceAiProvidersParamsSchema diff --git a/server/routers/resource/removeAiModelFromResource.ts b/server/routers/resource/removeAiModelFromResource.ts index 7b5cb012f..626b94666 100644 --- a/server/routers/resource/removeAiModelFromResource.ts +++ b/server/routers/resource/removeAiModelFromResource.ts @@ -8,7 +8,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { assertPublicAllowlistApiEligible } from "@server/lib/aiInferenceResource"; +import { assertPublicModelListApiEligible } from "@server/lib/aiInferenceResource"; const removeAiModelFromResourceBodySchema = z.strictObject({ modelId: z.int().positive() @@ -22,7 +22,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/ai-models/remove", description: - "Remove a single catalog model from an inference resource allowlist. Requires at least one attached AI provider in allowlist mode.", + "Remove a single model from an inference resource allow/block list. Requires at least one attached AI provider.", tags: [OpenAPITags.PublicResource], request: { params: removeAiModelFromResourceParamsSchema, @@ -98,7 +98,7 @@ export async function removeAiModelFromResource( ); } - const eligibleError = await assertPublicAllowlistApiEligible(resource); + const eligibleError = await assertPublicModelListApiEligible(resource); if (eligibleError) { return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError)); } diff --git a/server/routers/resource/removeAiProviderFromResource.ts b/server/routers/resource/removeAiProviderFromResource.ts index e74b0c886..a7f9298ee 100644 --- a/server/routers/resource/removeAiProviderFromResource.ts +++ b/server/routers/resource/removeAiProviderFromResource.ts @@ -127,13 +127,14 @@ export async function removeAiProviderFromResource( .filter((a) => a.providerId !== providerId) .map((a) => ({ providerId: a.providerId, - modelAccessMode: a.modelAccessMode + accessMode: a.accessMode })); const attachments = await resolveProviderAttachments({ orgId: resource.orgId, attachments: remaining, - requireAtLeastOne: false + requireAtLeastOne: false, + resourceId }); if (isInferenceFieldsError(attachments)) { return next( diff --git a/server/routers/resource/setResourceAiModels.ts b/server/routers/resource/setResourceAiModels.ts index 0c3323ebe..78a67fdfb 100644 --- a/server/routers/resource/setResourceAiModels.ts +++ b/server/routers/resource/setResourceAiModels.ts @@ -9,12 +9,13 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { - assertPublicAllowlistApiEligible, - assertModelsBelongToPublicAllowlistProviders + assertPublicModelListApiEligible, + assertPublicResourceModelEntriesValid, + resourceAiModelEntrySchema } from "@server/lib/aiInferenceResource"; const setResourceAiModelsBodySchema = z.strictObject({ - modelIds: z.array(z.int().positive()) + models: z.array(resourceAiModelEntrySchema) }); const setResourceAiModelsParamsSchema = z.strictObject({ @@ -25,7 +26,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/ai-models", description: - "Replace the allowlist of catalog models for an inference resource. Requires at least one attached AI provider in allowlist mode. Models must belong to a provider attached in allowlist mode. An empty array denies all models.", + "Replace the allow/block model selection for an inference resource. Requires at least one attached AI provider in select mode. Models must belong to a select-mode provider and their listType must match the provider catalog entry. An empty array clears the selection, which denies all models for select-mode providers.", tags: [OpenAPITags.PublicResource], request: { params: setResourceAiModelsParamsSchema, @@ -71,7 +72,7 @@ export async function setResourceAiModels( ); } - const { modelIds } = parsedBody.data; + const { models } = parsedBody.data; const parsedParams = setResourceAiModelsParamsSchema.safeParse( req.params @@ -99,15 +100,22 @@ export async function setResourceAiModels( ); } - const eligibleError = await assertPublicAllowlistApiEligible(resource); + const eligibleError = await assertPublicModelListApiEligible(resource); if (eligibleError) { return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError)); } - const modelError = await assertModelsBelongToPublicAllowlistProviders({ + const byModelId = new Map( + models.map((m) => [m.modelId, m.listType] as const) + ); + const uniqueModels = [...byModelId.entries()].map( + ([modelId, listType]) => ({ modelId, listType }) + ); + + const modelError = await assertPublicResourceModelEntriesValid({ orgId: resource.orgId, resourceId, - modelIds + models: uniqueModels }); if (modelError) { return next(createHttpError(HttpCode.BAD_REQUEST, modelError)); @@ -118,12 +126,14 @@ export async function setResourceAiModels( .delete(resourceAiModels) .where(eq(resourceAiModels.resourceId, resourceId)); - if (modelIds.length > 0) { - await trx - .insert(resourceAiModels) - .values( - modelIds.map((modelId) => ({ resourceId, modelId })) - ); + if (uniqueModels.length > 0) { + await trx.insert(resourceAiModels).values( + uniqueModels.map((m) => ({ + resourceId, + modelId: m.modelId, + listType: m.listType + })) + ); } }); diff --git a/server/routers/resource/setResourceAiProviders.ts b/server/routers/resource/setResourceAiProviders.ts index 88552954b..ef312ac70 100644 --- a/server/routers/resource/setResourceAiProviders.ts +++ b/server/routers/resource/setResourceAiProviders.ts @@ -27,7 +27,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/ai-providers", description: - "Replace the AI providers attached to an inference resource. An empty list clears all providers. Model keys must be unique across attached catalog providers.", + "Replace the AI providers attached to an inference resource. Each provider uses accessMode inherit (default, uses the provider's own allow/block lists) or select (uses the resource's selected subset of that provider's catalog). An empty list clears all providers. Effective allow model keys must be unique across attached providers.", tags: [OpenAPITags.PublicResource], request: { params: setResourceAiProvidersParamsSchema, @@ -113,7 +113,8 @@ export async function setResourceAiProviders( const attachments = await resolveProviderAttachments({ orgId: resource.orgId, attachments: providers, - requireAtLeastOne: false + requireAtLeastOne: false, + resourceId }); if (isInferenceFieldsError(attachments)) { return next( diff --git a/server/routers/siteResource/addAiModelToSiteResource.ts b/server/routers/siteResource/addAiModelToSiteResource.ts index 0b6a004d7..8e1f8935e 100644 --- a/server/routers/siteResource/addAiModelToSiteResource.ts +++ b/server/routers/siteResource/addAiModelToSiteResource.ts @@ -9,12 +9,14 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { - assertSiteAllowlistApiEligible, - assertModelsBelongToSiteAllowlistProviders + assertSiteModelListApiEligible, + assertSiteResourceModelEntriesValid, + modelListTypeSchema } from "@server/lib/aiInferenceResource"; const addAiModelToSiteResourceBodySchema = z.strictObject({ - modelId: z.int().positive() + modelId: z.number().int().positive(), + listType: modelListTypeSchema.optional().default("allow") }); const addAiModelToSiteResourceParamsSchema = z.strictObject({ @@ -25,7 +27,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/ai-models/add", description: - "Add a single catalog model to an inference site resource allowlist. Requires at least one attached AI provider in allowlist mode. The model must belong to a provider attached in allowlist mode.", + "Add a single model to an inference site resource allow/block selection. Requires at least one attached AI provider in select mode. The model must belong to a select-mode provider and its listType must match the provider catalog entry. listType defaults to allow.", tags: [OpenAPITags.PrivateResource], request: { params: addAiModelToSiteResourceParamsSchema, @@ -73,7 +75,7 @@ export async function addAiModelToSiteResource( ); } - const { modelId } = parsedBody.data; + const { modelId, listType } = parsedBody.data; const parsedParams = addAiModelToSiteResourceParamsSchema.safeParse( req.params @@ -102,15 +104,15 @@ export async function addAiModelToSiteResource( } const eligibleError = - await assertSiteAllowlistApiEligible(siteResource); + await assertSiteModelListApiEligible(siteResource); if (eligibleError) { return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError)); } - const modelError = await assertModelsBelongToSiteAllowlistProviders({ + const modelError = await assertSiteResourceModelEntriesValid({ orgId: siteResource.orgId, siteResourceId, - modelIds: [modelId] + models: [{ modelId, listType }] }); if (modelError) { return next(createHttpError(HttpCode.BAD_REQUEST, modelError)); @@ -135,9 +137,11 @@ export async function addAiModelToSiteResource( ); } - await db - .insert(siteResourceAiModels) - .values({ siteResourceId, modelId }); + await db.insert(siteResourceAiModels).values({ + siteResourceId, + modelId, + listType + }); return response(res, { data: {}, diff --git a/server/routers/siteResource/addAiProviderToSiteResource.ts b/server/routers/siteResource/addAiProviderToSiteResource.ts index bae346347..79a0ae093 100644 --- a/server/routers/siteResource/addAiProviderToSiteResource.ts +++ b/server/routers/siteResource/addAiProviderToSiteResource.ts @@ -11,14 +11,12 @@ import { OpenAPITags, registry } from "@server/openApi"; import { isInferenceFieldsError, listSiteResourceAiProviders, - modelAccessModeSchema, resolveProviderAttachments, setSiteResourceAiProviders } from "@server/lib/aiInferenceResource"; const addAiProviderToSiteResourceBodySchema = z.strictObject({ - providerId: z.number().int().positive(), - modelAccessMode: modelAccessModeSchema.optional() + providerId: z.number().int().positive() }); const addAiProviderToSiteResourceParamsSchema = z.strictObject({ @@ -29,7 +27,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/ai-providers/add", description: - "Add or replace a single AI provider attachment on an inference site resource.", + "Add or replace a single AI provider attachment on an inference site resource. The provider is attached in inherit mode, using its own allow/block lists.", tags: [OpenAPITags.PrivateResource], request: { params: addAiProviderToSiteResourceParamsSchema, @@ -77,7 +75,7 @@ export async function addAiProviderToSiteResource( ); } - const { providerId, modelAccessMode } = parsedBody.data; + const { providerId } = parsedBody.data; const parsedParams = addAiProviderToSiteResourceParamsSchema.safeParse( req.params @@ -120,18 +118,21 @@ export async function addAiProviderToSiteResource( .filter((a) => a.providerId !== providerId) .map((a) => ({ providerId: a.providerId, - modelAccessMode: a.modelAccessMode + accessMode: a.accessMode })), - { providerId, modelAccessMode } + { providerId, accessMode: "inherit" as const } ]; const attachments = await resolveProviderAttachments({ orgId: siteResource.orgId, attachments: nextAttachments, - requireAtLeastOne: true + requireAtLeastOne: true, + siteResourceId }); if (isInferenceFieldsError(attachments)) { - return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error)); + return next( + createHttpError(HttpCode.BAD_REQUEST, attachments.error) + ); } await setSiteResourceAiProviders(siteResourceId, attachments); diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 31f631ce2..15b82cb56 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -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 (catalog or allowlist); defaults to catalog. Model keys must be unique across attached catalog providers." + "For inference-mode site resources: AI providers to attach. Providers are attached in inherit mode, using each provider's own allow/block lists. Effective allow model keys must be unique across attached providers." ) }) .strict() @@ -350,9 +350,14 @@ export async function createSiteResource( let providerAttachments: ResourceAiProviderAttachment[] = []; if (mode === "inference") { + // A new site resource has no model selections yet, so providers + // always start in inherit mode; select can be enabled afterwards. const resolved = await resolveProviderAttachments({ orgId, - attachments: aiProviderInputs ?? [], + attachments: (aiProviderInputs ?? []).map((p) => ({ + providerId: p.providerId, + accessMode: "inherit" as const + })), requireAtLeastOne: false }); if (isInferenceFieldsError(resolved)) { diff --git a/server/routers/siteResource/listSiteResourceAiModels.ts b/server/routers/siteResource/listSiteResourceAiModels.ts index 26e709140..fd65d29ea 100644 --- a/server/routers/siteResource/listSiteResourceAiModels.ts +++ b/server/routers/siteResource/listSiteResourceAiModels.ts @@ -19,7 +19,9 @@ async function query(siteResourceId: number) { modelId: aiModels.modelId, modelKey: aiModels.modelKey, name: aiModels.name, - enabled: aiModels.enabled + providerId: aiModels.providerId, + enabled: aiModels.enabled, + listType: siteResourceAiModels.listType }) .from(siteResourceAiModels) .innerJoin(aiModels, eq(siteResourceAiModels.modelId, aiModels.modelId)) @@ -34,7 +36,7 @@ registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/ai-models", description: - "List catalog models on this site resource's allowlist. Only enforced when modelAccessMode=allowlist; an empty allowlist denies all models.", + "List the models this site resource has selected from its select-mode providers' allow/block lists. Providers in inherit mode are not represented here; they use their own lists.", tags: [OpenAPITags.PrivateResource], request: { params: listSiteResourceAiModelsParamsSchema diff --git a/server/routers/siteResource/listSiteResourceAiProviders.ts b/server/routers/siteResource/listSiteResourceAiProviders.ts index bcbe37e8b..7d7671dad 100644 --- a/server/routers/siteResource/listSiteResourceAiProviders.ts +++ b/server/routers/siteResource/listSiteResourceAiProviders.ts @@ -21,7 +21,8 @@ export type ListSiteResourceAiProvidersResponse = { registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/ai-providers", - description: "List AI providers attached to an inference site resource.", + description: + "List AI providers attached to an inference site resource, including each attachment's accessMode.", tags: [OpenAPITags.PrivateResource], request: { params: listSiteResourceAiProvidersParamsSchema diff --git a/server/routers/siteResource/removeAiModelFromSiteResource.ts b/server/routers/siteResource/removeAiModelFromSiteResource.ts index eb8d649a2..c52dfb43d 100644 --- a/server/routers/siteResource/removeAiModelFromSiteResource.ts +++ b/server/routers/siteResource/removeAiModelFromSiteResource.ts @@ -8,7 +8,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { assertSiteAllowlistApiEligible } from "@server/lib/aiInferenceResource"; +import { assertSiteModelListApiEligible } from "@server/lib/aiInferenceResource"; const removeAiModelFromSiteResourceBodySchema = z.strictObject({ modelId: z.int().positive() @@ -22,7 +22,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/ai-models/remove", description: - "Remove a single catalog model from an inference site resource allowlist. Requires at least one attached AI provider in allowlist mode.", + "Remove a single model from an inference site resource allow/block list. Requires at least one attached AI provider.", tags: [OpenAPITags.PrivateResource], request: { params: removeAiModelFromSiteResourceParamsSchema, @@ -98,7 +98,7 @@ export async function removeAiModelFromSiteResource( } const eligibleError = - await assertSiteAllowlistApiEligible(siteResource); + await assertSiteModelListApiEligible(siteResource); if (eligibleError) { return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError)); } diff --git a/server/routers/siteResource/removeAiProviderFromSiteResource.ts b/server/routers/siteResource/removeAiProviderFromSiteResource.ts index 3d4392820..ffb4f1ff8 100644 --- a/server/routers/siteResource/removeAiProviderFromSiteResource.ts +++ b/server/routers/siteResource/removeAiProviderFromSiteResource.ts @@ -126,13 +126,14 @@ export async function removeAiProviderFromSiteResource( .filter((a) => a.providerId !== providerId) .map((a) => ({ providerId: a.providerId, - modelAccessMode: a.modelAccessMode + accessMode: a.accessMode })); const attachments = await resolveProviderAttachments({ orgId: siteResource.orgId, attachments: remaining, - requireAtLeastOne: false + requireAtLeastOne: false, + siteResourceId }); if (isInferenceFieldsError(attachments)) { return next( diff --git a/server/routers/siteResource/setSiteResourceAiModels.ts b/server/routers/siteResource/setSiteResourceAiModels.ts index 391634553..eeacbbd23 100644 --- a/server/routers/siteResource/setSiteResourceAiModels.ts +++ b/server/routers/siteResource/setSiteResourceAiModels.ts @@ -9,12 +9,13 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { - assertSiteAllowlistApiEligible, - assertModelsBelongToSiteAllowlistProviders + assertSiteModelListApiEligible, + assertSiteResourceModelEntriesValid, + resourceAiModelEntrySchema } from "@server/lib/aiInferenceResource"; const setSiteResourceAiModelsBodySchema = z.strictObject({ - modelIds: z.array(z.int().positive()) + models: z.array(resourceAiModelEntrySchema) }); const setSiteResourceAiModelsParamsSchema = z.strictObject({ @@ -25,7 +26,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/ai-models", description: - "Replace the allowlist of catalog models for an inference site resource. Requires at least one attached AI provider in allowlist mode. Models must belong to a provider attached in allowlist mode. An empty array denies all models.", + "Replace the allow/block model selection for an inference site resource. Requires at least one attached AI provider in select mode. Models must belong to a select-mode provider and their listType must match the provider catalog entry. An empty array clears the selection, which denies all models for select-mode providers.", tags: [OpenAPITags.PrivateResource], request: { params: setSiteResourceAiModelsParamsSchema, @@ -73,7 +74,7 @@ export async function setSiteResourceAiModels( ); } - const { modelIds } = parsedBody.data; + const { models } = parsedBody.data; const parsedParams = setSiteResourceAiModelsParamsSchema.safeParse( req.params @@ -102,15 +103,22 @@ export async function setSiteResourceAiModels( } const eligibleError = - await assertSiteAllowlistApiEligible(siteResource); + await assertSiteModelListApiEligible(siteResource); if (eligibleError) { return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError)); } - const modelError = await assertModelsBelongToSiteAllowlistProviders({ + const byModelId = new Map( + models.map((m) => [m.modelId, m.listType] as const) + ); + const uniqueModels = [...byModelId.entries()].map( + ([modelId, listType]) => ({ modelId, listType }) + ); + + const modelError = await assertSiteResourceModelEntriesValid({ orgId: siteResource.orgId, siteResourceId, - modelIds + models: uniqueModels }); if (modelError) { return next(createHttpError(HttpCode.BAD_REQUEST, modelError)); @@ -121,11 +129,12 @@ export async function setSiteResourceAiModels( .delete(siteResourceAiModels) .where(eq(siteResourceAiModels.siteResourceId, siteResourceId)); - if (modelIds.length > 0) { + if (uniqueModels.length > 0) { await trx.insert(siteResourceAiModels).values( - modelIds.map((modelId) => ({ + uniqueModels.map((m) => ({ siteResourceId, - modelId + modelId: m.modelId, + listType: m.listType })) ); } diff --git a/server/routers/siteResource/setSiteResourceAiProviders.ts b/server/routers/siteResource/setSiteResourceAiProviders.ts index fd685bb20..a6c8a52c7 100644 --- a/server/routers/siteResource/setSiteResourceAiProviders.ts +++ b/server/routers/siteResource/setSiteResourceAiProviders.ts @@ -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. An empty list clears all providers. Model keys must be unique across attached catalog providers.", + "Replace the AI providers attached to an inference site resource. Each provider uses accessMode inherit (default, uses the provider's own allow/block lists) or select (uses the site resource's selected subset of that provider's catalog). An empty list clears all providers. Effective allow model keys must be unique across attached providers.", tags: [OpenAPITags.PrivateResource], request: { params: setSiteResourceAiProvidersParamsSchema, @@ -115,7 +115,8 @@ export async function setSiteResourceAiProviders( const attachments = await resolveProviderAttachments({ orgId: siteResource.orgId, attachments: providers, - requireAtLeastOne: false + requireAtLeastOne: false, + siteResourceId }); if (isInferenceFieldsError(attachments)) { return next( diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx index f90143492..10db41571 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx @@ -12,6 +12,7 @@ import { } from "@app/components/Settings"; import { TagInput, type Tag } from "@app/components/tags/tag-input"; import { Button } from "@app/components/ui/button"; +import { Label } from "@app/components/ui/label"; import { useAiProviderContext } from "@app/hooks/useAiProviderContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; @@ -21,6 +22,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; +type ModelListType = "allow" | "block"; + export default function AiProviderModelsPage() { const { provider } = useAiProviderContext(); const { env } = useEnvContext(); @@ -28,8 +31,14 @@ export default function AiProviderModelsPage() { const queryClient = useQueryClient(); const t = useTranslations(); const [saveLoading, setSaveLoading] = useState(false); - const [tags, setTags] = useState([]); - const [activeTagIndex, setActiveTagIndex] = useState(null); + const [allowTags, setAllowTags] = useState([]); + const [blockTags, setBlockTags] = useState([]); + const [activeAllowTagIndex, setActiveAllowTagIndex] = useState< + number | null + >(null); + const [activeBlockTagIndex, setActiveBlockTagIndex] = useState< + number | null + >(null); const modelsQuery = useQuery( aiProviderQueries.providerModels({ providerId: provider.providerId }) @@ -37,11 +46,21 @@ export default function AiProviderModelsPage() { useEffect(() => { if (!modelsQuery.data) return; - setTags( - modelsQuery.data.map((model) => ({ - id: String(model.modelId), - text: model.modelKey - })) + setAllowTags( + modelsQuery.data + .filter((model) => (model.listType ?? "allow") === "allow") + .map((model) => ({ + id: String(model.modelId), + text: model.modelKey + })) + ); + setBlockTags( + modelsQuery.data + .filter((model) => model.listType === "block") + .map((model) => ({ + id: String(model.modelId), + text: model.modelKey + })) ); }, [modelsQuery.data]); @@ -52,27 +71,74 @@ export default function AiProviderModelsPage() { const existingByKey = new Map( existing.map((model) => [model.modelKey, model]) ); - const nextKeys = new Set( - tags.map((tag) => tag.text.trim()).filter(Boolean) + + const nextAllow = new Set( + allowTags.map((tag) => tag.text.trim()).filter(Boolean) + ); + const nextBlock = new Set( + blockTags.map((tag) => tag.text.trim()).filter(Boolean) ); - const toCreate = [...nextKeys].filter( - (key) => !existingByKey.has(key) - ); - const toDelete = existing.filter( - (model) => !nextKeys.has(model.modelKey) - ); + const overlap = [...nextAllow].filter((key) => nextBlock.has(key)); + if (overlap.length > 0) { + toast({ + variant: "destructive", + title: t("aiProviderModelsErrorUpdate"), + description: t("aiProviderModelsOverlapError", { + keys: overlap.join(", ") + }) + }); + return; + } + + const desired = new Map(); + for (const key of nextAllow) { + desired.set(key, "allow"); + } + for (const key of nextBlock) { + desired.set(key, "block"); + } + + const toCreate: { modelKey: string; listType: ModelListType }[] = + []; + const toUpdate: { + modelId: number; + listType: ModelListType; + }[] = []; + const toDelete: number[] = []; + + for (const [modelKey, listType] of desired) { + const existingModel = existingByKey.get(modelKey); + if (!existingModel) { + toCreate.push({ modelKey, listType }); + continue; + } + if ((existingModel.listType ?? "allow") !== listType) { + toUpdate.push({ + modelId: existingModel.modelId, + listType + }); + } + } + + for (const model of existing) { + if (!desired.has(model.modelKey)) { + toDelete.push(model.modelId); + } + } await Promise.all([ - ...toCreate.map((modelKey) => + ...toCreate.map(({ modelKey, listType }) => api.put(`/ai-provider/${provider.providerId}/model`, { modelKey, - name: modelKey + name: modelKey, + listType }) ), - ...toDelete.map((model) => - api.delete(`/ai-model/${model.modelId}`) - ) + ...toUpdate.map(({ modelId, listType }) => + api.post(`/ai-model/${modelId}`, { listType }) + ), + ...toDelete.map((modelId) => api.delete(`/ai-model/${modelId}`)) ]); await queryClient.invalidateQueries( @@ -113,24 +179,59 @@ export default function AiProviderModelsPage() { - { - const next = - typeof newTags === "function" - ? newTags(tags) - : newTags; - setTags(next as Tag[]); - }} - allowDuplicates={false} - sortTags - delimiterList={[",", "Enter"]} - disabled={modelsQuery.isLoading || saveLoading} - /> +
+ + { + const next = + typeof newTags === "function" + ? newTags(allowTags) + : newTags; + setAllowTags(next as Tag[]); + }} + allowDuplicates={false} + sortTags + delimiterList={[",", "Enter"]} + disabled={modelsQuery.isLoading || saveLoading} + /> +

+ {t("aiProviderModelsAllowDescription")} +

+
+ +
+ + { + const next = + typeof newTags === "function" + ? newTags(blockTags) + : newTags; + setBlockTags(next as Tag[]); + }} + allowDuplicates={false} + sortTags + delimiterList={[",", "Enter"]} + disabled={modelsQuery.isLoading || saveLoading} + /> +

+ {t("aiProviderModelsBlockDescription")} +

+
diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx index 48304d350..f9ad664a0 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx @@ -130,8 +130,7 @@ export default function PrivateResourceInferencePage() { await api.post(`/site-resource/${siteResource.id}/ai-providers`, { providers: data.providerIds.map((providerId) => ({ - providerId, - modelAccessMode: "catalog" + providerId })) }); @@ -258,12 +257,10 @@ export default function PrivateResourceInferencePage() { cols={2} hideFreeDomain defaultSubdomain={ - httpConfigSubdomain ?? - undefined + httpConfigSubdomain ?? undefined } defaultDomainId={ - httpConfigDomainId ?? - undefined + httpConfigDomainId ?? undefined } defaultFullDomain={ httpConfigFullDomain ?? diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx index 8741159b4..ad7230591 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx @@ -102,8 +102,7 @@ export default function PublicResourceInferencePage() { try { await api.post(`/resource/${resource.resourceId}/ai-providers`, { providers: data.providerIds.map((providerId) => ({ - providerId, - modelAccessMode: "catalog" + providerId })) }); diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index a5088d84e..4d1c24efb 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -497,8 +497,7 @@ export default function Page() { if (resourceType === "inference") { Object.assign(payload, { aiProviders: selectedProviders.map((provider) => ({ - providerId: parseInt(provider.id, 10), - modelAccessMode: "catalog" + providerId: parseInt(provider.id, 10) })) }); } else if (resourceType === "ssh") { diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index f186ffb9d..669a96363 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -217,8 +217,7 @@ export function buildCreateSiteResourcePayload( }), ...(data.mode === "inference" && { aiProviders: (data.providerIds ?? []).map((providerId) => ({ - providerId, - modelAccessMode: "catalog" as const + providerId })), ssl: data.ssl ?? false, domainId: data.httpConfigDomainId diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 9a0cb2403..6b7070f5b 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1288,10 +1288,10 @@ export const resourceQueries = { AxiosResponse<{ providers: Array<{ providerId: number; - modelAccessMode: "catalog" | "allowlist"; name: string; type: string; enabled: boolean; + accessMode: "inherit" | "select"; }>; }> >(`/site-resource/${siteResourceId}/ai-providers`, { @@ -1308,10 +1308,10 @@ export const resourceQueries = { AxiosResponse<{ providers: Array<{ providerId: number; - modelAccessMode: "catalog" | "allowlist"; name: string; type: string; enabled: boolean; + accessMode: "inherit" | "select"; }>; }> >(`/resource/${resourceId}/ai-providers`, { From d699455b383f3c42e1ebb229d50339e50559f8df Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 7 Aug 2026 11:23:07 -0400 Subject: [PATCH 243/494] support changing capabilities on provider integrations --- messages/en-US.json | 2 +- server/lib/aiCapabilities.ts | 5 +- server/routers/aiProvider/createAiProvider.ts | 9 +++ server/routers/aiProvider/updateAiProvider.ts | 17 +---- server/routers/aiProvider/validation.ts | 10 +++ .../[providerId]/general/page.tsx | 69 +++++-------------- .../settings/ai-providers/create/page.tsx | 59 ++++------------ src/lib/aiProviderFormSchema.ts | 10 +-- 8 files changed, 60 insertions(+), 121 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 8cc780829..2d77bb365 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1729,7 +1729,7 @@ "aiProviderErrorRoutingModeTarget": "Site targets routing is only available for custom providers", "aiProviderErrorCapabilitiesRequired": "Select at least one API capability", "aiProviderCapabilities": "API Capabilities", - "aiProviderCapabilitiesDescription": "Which API formats this provider accepts. Built-in providers use fixed capabilities.", + "aiProviderCapabilitiesDescription": "Select which API formats this provider can handle. Known providers start with recommended defaults.", "aiProviderCapabilitiesCustomDescription": "Select which API formats this custom provider can handle", "aiProviderCapabilitiesSelect": "Select capabilities", "aiProviderCapabilitiesEmpty": "No capabilities found", diff --git a/server/lib/aiCapabilities.ts b/server/lib/aiCapabilities.ts index f7b370691..356122e7f 100644 --- a/server/lib/aiCapabilities.ts +++ b/server/lib/aiCapabilities.ts @@ -261,8 +261,11 @@ export function resolveCapabilitiesForCreate(input: { type: AiProviderType; capabilities?: AiCapability[] | null; }): AiCapability[] { + if (input.capabilities != null) { + return parseCapabilities(input.capabilities); + } if (input.type === "custom") { - return parseCapabilities(input.capabilities ?? []); + return []; } return [...AI_PROVIDER_CAPABILITY_DEFAULTS[input.type]]; } diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts index 995e9eaa0..195c8040e 100644 --- a/server/routers/aiProvider/createAiProvider.ts +++ b/server/routers/aiProvider/createAiProvider.ts @@ -124,6 +124,15 @@ export async function createAiProvider( capabilities }); + if (resolvedCapabilities.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "At least one capability is required" + ) + ); + } + const [provider] = await db .insert(aiProviders) .values({ diff --git a/server/routers/aiProvider/updateAiProvider.ts b/server/routers/aiProvider/updateAiProvider.ts index 7cbf1145a..1f22dc6ef 100644 --- a/server/routers/aiProvider/updateAiProvider.ts +++ b/server/routers/aiProvider/updateAiProvider.ts @@ -131,20 +131,9 @@ export async function updateAiProvider( ? body.authType : (existing.authType as AiProviderAuthType); - if (body.capabilities !== undefined && providerType !== "custom") { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Capabilities can only be updated for custom providers" - ) - ); - } - const nextCapabilities = - providerType === "custom" - ? body.capabilities !== undefined - ? body.capabilities - : parseCapabilities(existing.capabilities) + body.capabilities !== undefined + ? body.capabilities : parseCapabilities(existing.capabilities); const validation = z @@ -195,7 +184,7 @@ export async function updateAiProvider( if (body.authType !== undefined) { updateData.authType = body.authType; } - if (providerType === "custom" && body.capabilities !== undefined) { + if (body.capabilities !== undefined) { updateData.capabilities = serializeCapabilities(body.capabilities); } diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts index a56ccad1c..cc1346325 100644 --- a/server/routers/aiProvider/validation.ts +++ b/server/routers/aiProvider/validation.ts @@ -112,5 +112,15 @@ export function refineProviderUpstreamFields( path: ["capabilities"] }); } + } else if ( + data.capabilities !== undefined && + data.capabilities !== null && + data.capabilities.length === 0 + ) { + ctx.addIssue({ + code: "custom", + message: "At least one capability is required", + path: ["capabilities"] + }); } } diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx index 62ffa6eb6..6dabdbaad 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx @@ -12,10 +12,7 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; -import { - AiProviderCapabilitiesSelect, - capabilityLabelKey -} from "@app/components/AiProviderCapabilitiesSelect"; +import { AiProviderCapabilitiesSelect } from "@app/components/AiProviderCapabilitiesSelect"; import { SwitchInput } from "@app/components/SwitchInput"; import { Button } from "@app/components/ui/button"; import { @@ -49,7 +46,6 @@ export default function AiProviderGeneralPage() { const router = useRouter(); const t = useTranslations(); const [saveLoading, setSaveLoading] = useState(false); - const isCustom = provider.type === "custom"; const generalSchema = useMemo( () => @@ -63,10 +59,7 @@ export default function AiProviderGeneralPage() { capabilities: z.array(z.enum(AI_CAPABILITIES)).optional() }) .superRefine((data, ctx) => { - if ( - isCustom && - (!data.capabilities || data.capabilities.length === 0) - ) { + if (!data.capabilities || data.capabilities.length === 0) { ctx.addIssue({ code: "custom", message: t("aiProviderErrorCapabilitiesRequired"), @@ -74,7 +67,7 @@ export default function AiProviderGeneralPage() { }); } }), - [t, isCustom] + [t] ); type GeneralFormValues = z.infer; @@ -97,11 +90,9 @@ export default function AiProviderGeneralPage() { capabilities?: AiCapability[]; } = { name: values.name.trim(), - enabled: values.enabled + enabled: values.enabled, + capabilities: values.capabilities ?? [] }; - if (isCustom) { - body.capabilities = values.capabilities ?? []; - } const res = await api.post< AxiosResponse @@ -211,46 +202,20 @@ export default function AiProviderGeneralPage() { )} - {isCustom ? ( - - ) : ( -
- {( - provider.capabilities ?? - [] - ).map((cap) => ( - - {t( - capabilityLabelKey( - cap - ) - )} - - ))} -
- )} +
- {isCustom - ? t( - "aiProviderCapabilitiesCustomDescription" - ) - : t( - "aiProviderCapabilitiesDescription" - )} + {t( + "aiProviderCapabilitiesDescription" + )} diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 0d9541e54..999782bc3 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -20,10 +20,7 @@ import { } from "@app/components/Settings"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect"; -import { - AiProviderCapabilitiesSelect, - capabilityLabelKey -} from "@app/components/AiProviderCapabilitiesSelect"; +import { AiProviderCapabilitiesSelect } from "@app/components/AiProviderCapabilitiesSelect"; import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect"; import { HeadersInput } from "@app/components/HeadersInput"; import { StrategySelect } from "@app/components/StrategySelect"; @@ -93,14 +90,12 @@ export default function CreateAiProviderPage() { const providerType = form.watch("type"); const routingMode = form.watch("routingMode"); const authType = form.watch("authType"); - const capabilities = form.watch("capabilities"); const showUpstream = showsUpstreamUrlField(providerType, routingMode); const requireUpstream = upstreamUrlRequired(providerType, routingMode); const showRoutingMode = providerType === "custom"; const showTargets = providerType === "custom" && routingMode === "target"; const showApiKey = authTypeRequiresApiKey(authType ?? "bearer"); - const showCapabilitiesSelect = providerType === "custom"; async function createTargets( providerId: number, @@ -326,48 +321,20 @@ export default function CreateAiProviderPage() { )} - {showCapabilitiesSelect ? ( - - ) : ( -
- {( - capabilities ?? - defaultCapabilitiesForProvider( - providerType - ) - ).map((cap) => ( - - {t( - capabilityLabelKey( - cap - ) - )} - - ))} -
- )} +
- {showCapabilitiesSelect - ? t( - "aiProviderCapabilitiesCustomDescription" - ) - : t( - "aiProviderCapabilitiesDescription" - )} + {t( + "aiProviderCapabilitiesDescription" + )} diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index 794d8ad62..04e38a71a 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -97,10 +97,7 @@ export function createAiProviderFormSchema(t: TranslateFn) { }); } - if ( - data.type === "custom" && - (!data.capabilities || data.capabilities.length === 0) - ) { + if (!data.capabilities || data.capabilities.length === 0) { ctx.addIssue({ code: "custom", message: t("aiProviderErrorCapabilitiesRequired"), @@ -187,8 +184,7 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) { upstreamUrl, apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined, authType: values.authType ?? "bearer", - capabilities: - values.type === "custom" ? (values.capabilities ?? []) : undefined, + capabilities: values.capabilities ?? [], headers: values.headers && values.headers.length > 0 ? values.headers : null, skipTlsVerification: values.skipTlsVerification, @@ -216,7 +212,7 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) { enabled: values.enabled ?? true }; - if (values.type === "custom" && values.capabilities) { + if (values.capabilities) { payload.capabilities = values.capabilities; } From 297cb9c8f2f0ecbcb9410455a9626231a9c76a8b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 7 Aug 2026 12:25:29 -0400 Subject: [PATCH 244/494] set provider default capabilities --- server/lib/aiCapabilities.ts | 37 -------------- server/lib/aiProviderDefaults.ts | 51 ++++++++++++++++--- server/routers/aiProvider/createAiProvider.ts | 12 ++--- src/lib/aiProviderFormSchema.ts | 7 +-- 4 files changed, 51 insertions(+), 56 deletions(-) diff --git a/server/lib/aiCapabilities.ts b/server/lib/aiCapabilities.ts index 356122e7f..2b3691493 100644 --- a/server/lib/aiCapabilities.ts +++ b/server/lib/aiCapabilities.ts @@ -1,5 +1,4 @@ import type { Request } from "express"; -import type { AiProviderType } from "@server/lib/aiProviderDefaults"; export const AI_CAPABILITIES = [ "openai_chat", @@ -188,20 +187,6 @@ export const AI_CAPABILITY_DEFS: Record = } }; -export const AI_PROVIDER_CAPABILITY_DEFAULTS: Record< - Exclude, - readonly AiCapability[] -> = { - openai: ["openai_chat"], - anthropic: ["anthropic_messages"], - googleGemini: ["gemini_generate_content"], - vertexAi: ["google_generate_content"], - bedrock: ["bedrock_converse"], - microsoftFoundry: ["openai_chat"], - openRouter: ["openai_chat"], - vercelAiGateway: ["openai_chat"] -}; - export function isAiCapability(value: unknown): value is AiCapability { return ( typeof value === "string" && @@ -256,25 +241,3 @@ export function providerHasCapability( : capabilities; return list.includes(capability); } - -export function resolveCapabilitiesForCreate(input: { - type: AiProviderType; - capabilities?: AiCapability[] | null; -}): AiCapability[] { - if (input.capabilities != null) { - return parseCapabilities(input.capabilities); - } - if (input.type === "custom") { - return []; - } - return [...AI_PROVIDER_CAPABILITY_DEFAULTS[input.type]]; -} - -export function defaultsForProviderType( - type: AiProviderType -): readonly AiCapability[] { - if (type === "custom") { - return []; - } - return AI_PROVIDER_CAPABILITY_DEFAULTS[type]; -} diff --git a/server/lib/aiProviderDefaults.ts b/server/lib/aiProviderDefaults.ts index f24330ec8..a8b3a0ca0 100644 --- a/server/lib/aiProviderDefaults.ts +++ b/server/lib/aiProviderDefaults.ts @@ -1,4 +1,8 @@ import { decrypt, encrypt } from "@server/lib/crypto"; +import { + parseCapabilities, + type AiCapability +} from "@server/lib/aiCapabilities"; export type AiProviderType = | "openai" @@ -28,6 +32,7 @@ export type AiProviderRoutingMode = "url" | "target"; type AiProviderDefaults = { upstreamUrl: string | null; authType: AiProviderAuthType; + capabilities: readonly AiCapability[]; }; export const AI_PROVIDER_DEFAULTS: Record< @@ -36,35 +41,43 @@ export const AI_PROVIDER_DEFAULTS: Record< > = { openai: { upstreamUrl: "https://api.openai.com/v1", - authType: "bearer" + authType: "bearer", + capabilities: ["openai_chat", "openai_responses"] }, anthropic: { upstreamUrl: "https://api.anthropic.com", - authType: "x-api-key" + authType: "x-api-key", + capabilities: ["anthropic_messages"] }, googleGemini: { upstreamUrl: "https://generativelanguage.googleapis.com", - authType: "x-goog-api-key" + authType: "x-goog-api-key", + capabilities: ["gemini_generate_content"] }, vertexAi: { upstreamUrl: null, - authType: "bearer" + authType: "bearer", + capabilities: ["google_generate_content", "google_raw_predict"] }, bedrock: { upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - authType: "bearer" + authType: "bearer", + capabilities: ["bedrock_converse"] }, microsoftFoundry: { upstreamUrl: null, - authType: "bearer" + authType: "bearer", + capabilities: ["openai_chat", "openai_responses", "anthropic_messages"] }, openRouter: { upstreamUrl: "https://openrouter.ai/api/v1", - authType: "bearer" + authType: "bearer", + capabilities: ["openai_chat"] }, vercelAiGateway: { upstreamUrl: "https://ai-gateway.vercel.sh/v1", - authType: "bearer" + authType: "bearer", + capabilities: ["openai_chat", "openai_responses"] } }; @@ -225,3 +238,25 @@ export function applyAiProviderAuthHeaders( break; } } + +export function resolveCapabilitiesForCreate(input: { + type: AiProviderType; + capabilities?: AiCapability[] | null; +}): AiCapability[] { + if (input.capabilities != null) { + return parseCapabilities(input.capabilities); + } + if (input.type === "custom") { + return []; + } + return [...AI_PROVIDER_DEFAULTS[input.type].capabilities]; +} + +export function defaultsForProviderType( + type: AiProviderType +): readonly AiCapability[] { + if (type === "custom") { + return []; + } + return AI_PROVIDER_DEFAULTS[type].capabilities; +} diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts index 195c8040e..f115d674a 100644 --- a/server/routers/aiProvider/createAiProvider.ts +++ b/server/routers/aiProvider/createAiProvider.ts @@ -9,7 +9,11 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -import { resolveAiProviderCreateFields } from "@server/lib/aiProviderDefaults"; +import { + resolveAiProviderCreateFields, + resolveCapabilitiesForCreate, + serializeAiProviderHeaders +} from "@server/lib/aiProviderDefaults"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import { toPublicAiProvider } from "@server/routers/aiProvider/types"; import { @@ -20,11 +24,7 @@ import { aiRoutingModeSchema, refineProviderUpstreamFields } from "@server/routers/aiProvider/validation"; -import { serializeAiProviderHeaders } from "@server/lib/aiProviderDefaults"; -import { - resolveCapabilitiesForCreate, - serializeCapabilities -} from "@server/lib/aiCapabilities"; +import { serializeCapabilities } from "@server/lib/aiCapabilities"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index 04e38a71a..2f75c92ec 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -3,15 +3,12 @@ import { AI_PROVIDER_AUTH_TYPES, AI_PROVIDER_DEFAULTS, authTypeRequiresApiKey, + defaultsForProviderType, providerRequiresUpstreamUrl, type AiProviderAuthType, type AiProviderType } from "@server/lib/aiProviderDefaults"; -import { - AI_CAPABILITIES, - defaultsForProviderType, - type AiCapability -} from "@server/lib/aiCapabilities"; +import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities"; type TranslateFn = (key: string) => string; From 6689a8d93ea7d4c2dcdd0dc8e4e9f0e2fe070c0f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 7 Aug 2026 19:05:28 +0200 Subject: [PATCH 245/494] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20test=20alert=20ru?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 4 + server/auth/actions.ts | 1 + server/private/routers/alertRule/index.ts | 3 +- .../routers/alertRule/testSiteAlertRule.ts | 73 +++++++++++++ server/private/routers/external.ts | 8 ++ .../AlertRuleGraphEditor.tsx | 100 +++++++++++++----- 6 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 server/private/routers/alertRule/testSiteAlertRule.ts diff --git a/messages/en-US.json b/messages/en-US.json index aff3be28b..2dd438a12 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1804,6 +1804,10 @@ "alertingRulesBannerDescription": "Each rule ties together what to watch (a site, health check, or resource), when to fire (for example offline or unhealthy), and how to notify your team via email, webhooks, or integrations. Use this list to create, enable, and manage those rules.", "alertingHealthChecksBannerTitle": "Monitor Health & Resources", "alertingHealthChecksBannerDescription": "Health checks are HTTP or TCP monitors you define once. You can then use them as sources in alert rules so you get notified when a target becomes healthy or unhealthy. Health checks on resources also appear here.", + "alertingTestRule": "Test Alert Rule", + "alertingNoActionsTitle": "No actions configured", + "alertingNoActionsSaveDescription": "Add at least one action so this rule can notify someone when it fires.", + "alertingNoActionsTestDescription": "Add at least one action before you can test this rule.", "standaloneHcTableTitle": "Health Checks", "standaloneHcSearchPlaceholder": "Search health checks…", "standaloneHcAddButton": "Create Health Check", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 741d7a057..4cb46f518 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -151,6 +151,7 @@ export enum ActionsEnum { createAlertRule = "createAlertRule", updateAlertRule = "updateAlertRule", deleteAlertRule = "deleteAlertRule", + testAlertRule = "testAlertRule", listAlertRules = "listAlertRules", listOrgLabels = "listOrgLabels", createOrgLabel = "createOrgLabel", diff --git a/server/private/routers/alertRule/index.ts b/server/private/routers/alertRule/index.ts index 19e35f7dc..e80a9ba16 100644 --- a/server/private/routers/alertRule/index.ts +++ b/server/private/routers/alertRule/index.ts @@ -15,4 +15,5 @@ export * from "./createAlertRule"; export * from "./updateAlertRule"; export * from "./deleteAlertRule"; export * from "./listAlertRules"; -export * from "./getAlertRule"; \ No newline at end of file +export * from "./getAlertRule"; +export * from "./testSiteAlertRule"; diff --git a/server/private/routers/alertRule/testSiteAlertRule.ts b/server/private/routers/alertRule/testSiteAlertRule.ts new file mode 100644 index 000000000..149ac9313 --- /dev/null +++ b/server/private/routers/alertRule/testSiteAlertRule.ts @@ -0,0 +1,73 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + alertRules, + alertSites, + alertHealthChecks, + alertResources +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; +import { ListAlertRulesResponse } from "@server/routers/alertRule/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const querySchema = z.strictObject({ + event: z.enum(["site_offline", "site_online", "site_toggle"]) +}); + +export async function testSiteAlertRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId } = parsedParams.data; + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { event } = parsedQuery.data; + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index fab026418..c94bce1df 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -808,6 +808,14 @@ authenticated.get( alertRule.listAlertRules ); +authenticated.get( + "/org/:orgId/test-site-alert-rule/:alertRuleId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.testAlertRule), + alertRule.testSiteAlertRule +); + authenticated.get( "/org/:orgId/alert-rule/:alertRuleId", verifyValidLicense, diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index 7cac1960f..a10f8f3b3 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -6,7 +6,9 @@ import { AlertRuleSourceFields, AlertRuleTriggerFields } from "@app/components/alert-rule-editor/AlertRuleFields"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { SettingsContainer } from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; import { Button } from "@app/components/ui/button"; import { Card, CardContent } from "@app/components/ui/card"; import { @@ -19,6 +21,7 @@ import { FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; +import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { buildFormSchema, @@ -27,19 +30,15 @@ import { type AlertRuleFormValues } from "@app/lib/alertRuleForm"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { CreateAlertRuleResponse } from "@server/routers/alertRule/types"; import type { AxiosResponse } from "axios"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { ChevronLeft, Cog, Flag, Zap } from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useMemo, useState, type ReactNode } from "react"; -import { useFieldArray, useForm, type Resolver } from "react-hook-form"; +import { Cog, Flag, Zap, ZapIcon } from "lucide-react"; import { useTranslations } from "next-intl"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { useRouter } from "next/navigation"; +import { useActionState, useMemo, useTransition, type ReactNode } from "react"; +import { useFieldArray, useForm, type Resolver } from "react-hook-form"; import { Badge } from "../ui/badge"; const FORM_ID = "alert-rule-form"; @@ -115,7 +114,6 @@ export default function AlertRuleGraphEditor({ const t = useTranslations(); const router = useRouter(); const api = createApiClient(useEnvContext()); - const [isSaving, setIsSaving] = useState(false); const schema = useMemo(() => buildFormSchema(t), [t]); const form = useForm({ resolver: zodResolver(schema) as Resolver, @@ -127,8 +125,22 @@ export default function AlertRuleGraphEditor({ name: "actions" }); - const onSubmit = form.handleSubmit(async (values) => { - setIsSaving(true); + const saveAlert = async () => { + const isValid = await form.trigger(); + if (!isValid) { + const values = form.getValues(); + if (values.actions.length === 0) { + toast({ + variant: "warning", + title: t("alertingNoActionsTitle"), + description: t("alertingNoActionsSaveDescription") + }); + } + return; + } + + const values = form.getValues(); + try { const payload = formValuesToApiPayload(values); if (isNew) { @@ -158,14 +170,37 @@ export default function AlertRuleGraphEditor({ description: formatAxiosError(e), variant: "destructive" }); - } finally { - setIsSaving(false); } - }); + // const submit = form.handleSubmit(async (values) => { + + // }); + + // await submit(); + }; + + const testAlert = async () => { + const isValid = await form.trigger(); + if (!isValid) { + const values = form.getValues(); + if (values.actions.length === 0) { + toast({ + variant: "warning", + title: t("alertingNoActionsTitle"), + description: t("alertingNoActionsTestDescription") + }); + } + return; + } + + const values = form.getValues(); + }; + + const [, formAction, isSaving] = useActionState(saveAlert, null); + const [isTestingAlert, startTransition] = useTransition(); return (
- +
@@ -263,14 +298,29 @@ export default function AlertRuleGraphEditor({ )} /> - +
+ + + +
From 12056aebc6ac219d27814249b27d19426b6be5f7 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 7 Aug 2026 13:54:14 -0400 Subject: [PATCH 246/494] Add basic cost calculations for testing --- config/models.json | 7559 ++++++++++++++++++++++++++ package-lock.json | 7 + package.json | 3 +- server/lib/aiModelPricing.ts | 230 + server/lib/aiUsageExtraction.ts | 468 ++ server/routers/aiGateway/pipeline.ts | 111 +- 6 files changed, 8374 insertions(+), 4 deletions(-) create mode 100644 config/models.json create mode 100644 server/lib/aiModelPricing.ts create mode 100644 server/lib/aiUsageExtraction.ts diff --git a/config/models.json b/config/models.json new file mode 100644 index 000000000..dccd0a370 --- /dev/null +++ b/config/models.json @@ -0,0 +1,7559 @@ +{ + "data": [ + { + "id": "ai21.j2-mid-v1", + "name": "ai21.j2-mid-v1", + "provider": "bedrock", + "input_cost_per_token": 0.0000125, + "output_cost_per_token": 0.0000125, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "ai21.j2-ultra-v1", + "name": "ai21.j2-ultra-v1", + "provider": "bedrock", + "input_cost_per_token": 0.0000188, + "output_cost_per_token": 0.0000188, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "ai21.jamba-1-5-large-v1:0", + "name": "ai21.jamba-1-5-large-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000008, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "ai21.jamba-1-5-mini-v1:0", + "name": "ai21.jamba-1-5-mini-v1:0", + "provider": "bedrock", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "ai21.jamba-instruct-v1:0", + "name": "ai21.jamba-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 7e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.writer.palmyra-x4-v1:0", + "name": "us.writer.palmyra-x4-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.writer.palmyra-x5-v1:0", + "name": "us.writer.palmyra-x5-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "writer.palmyra-x4-v1:0", + "name": "writer.palmyra-x4-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "writer.palmyra-x5-v1:0", + "name": "writer.palmyra-x5-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "amazon.nova-lite-v1:0", + "name": "amazon.nova-lite-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 6e-8, + "output_cost_per_token": 2.4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "amazon.nova-2-lite-v1:0", + "name": "amazon.nova-2-lite-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "amazon.nova-2-pro-preview-20251202-v1:0", + "name": "amazon.nova-2-pro-preview-20251202-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000021875, + "output_cost_per_token": 0.0000175, + "cache_read_input_token_cost": 5.46875e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "apac.amazon.nova-2-lite-v1:0", + "name": "apac.amazon.nova-2-lite-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 3.3e-7, + "output_cost_per_token": 0.00000275, + "cache_read_input_token_cost": 8.25e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "apac.amazon.nova-2-pro-preview-20251202-v1:0", + "name": "apac.amazon.nova-2-pro-preview-20251202-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000021875, + "output_cost_per_token": 0.0000175, + "cache_read_input_token_cost": 5.46875e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.amazon.nova-2-lite-v1:0", + "name": "eu.amazon.nova-2-lite-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 3.3e-7, + "output_cost_per_token": 0.00000275, + "cache_read_input_token_cost": 8.25e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.amazon.nova-2-pro-preview-20251202-v1:0", + "name": "eu.amazon.nova-2-pro-preview-20251202-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000021875, + "output_cost_per_token": 0.0000175, + "cache_read_input_token_cost": 5.46875e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.amazon.nova-2-lite-v1:0", + "name": "us.amazon.nova-2-lite-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 3.3e-7, + "output_cost_per_token": 0.00000275, + "cache_read_input_token_cost": 8.25e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.amazon.nova-2-pro-preview-20251202-v1:0", + "name": "us.amazon.nova-2-pro-preview-20251202-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000021875, + "output_cost_per_token": 0.0000175, + "cache_read_input_token_cost": 5.46875e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "amazon.nova-micro-v1:0", + "name": "amazon.nova-micro-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 3.5e-8, + "output_cost_per_token": 1.4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "amazon.nova-pro-v1:0", + "name": "amazon.nova-pro-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 8e-7, + "output_cost_per_token": 0.0000032, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "twelvelabs.pegasus-1-2-v1:0", + "name": "twelvelabs.pegasus-1-2-v1:0", + "provider": "bedrock", + "input_cost_per_token": null, + "output_cost_per_token": 0.0000075, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.twelvelabs.pegasus-1-2-v1:0", + "name": "us.twelvelabs.pegasus-1-2-v1:0", + "provider": "bedrock", + "input_cost_per_token": null, + "output_cost_per_token": 0.0000075, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.twelvelabs.pegasus-1-2-v1:0", + "name": "eu.twelvelabs.pegasus-1-2-v1:0", + "provider": "bedrock", + "input_cost_per_token": null, + "output_cost_per_token": 0.0000075, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "amazon.titan-text-express-v1", + "name": "amazon.titan-text-express-v1", + "provider": "bedrock", + "input_cost_per_token": 0.0000013, + "output_cost_per_token": 0.0000017, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "amazon.titan-text-lite-v1", + "name": "amazon.titan-text-lite-v1", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "amazon.titan-text-premier-v1:0", + "name": "amazon.titan-text-premier-v1:0", + "provider": "bedrock", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-3-5-haiku-20241022-v1:0", + "name": "anthropic.claude-3-5-haiku-20241022-v1:0", + "provider": "bedrock", + "input_cost_per_token": 8e-7, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": 8e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-haiku-4-5-20251001-v1:0", + "name": "anthropic.claude-haiku-4-5-20251001-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "cache_read_input_token_cost": 1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-haiku-4-5@20251001", + "name": "anthropic.claude-haiku-4-5@20251001", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "cache_read_input_token_cost": 1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-3-5-sonnet-20240620-v1:0", + "name": "anthropic.claude-3-5-sonnet-20240620-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-3-5-sonnet-20241022-v2:0", + "name": "anthropic.claude-3-5-sonnet-20241022-v2:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-3-7-sonnet-20240620-v1:0", + "name": "anthropic.claude-3-7-sonnet-20240620-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.0000036, + "output_cost_per_token": 0.000018, + "cache_read_input_token_cost": 3.6e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-3-7-sonnet-20250219-v1:0", + "name": "anthropic.claude-3-7-sonnet-20250219-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-3-haiku-20240307-v1:0", + "name": "anthropic.claude-3-haiku-20240307-v1:0", + "provider": "bedrock", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.00000125, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-3-opus-20240229-v1:0", + "name": "anthropic.claude-3-opus-20240229-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-3-sonnet-20240229-v1:0", + "name": "anthropic.claude-3-sonnet-20240229-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-instant-v1", + "name": "anthropic.claude-instant-v1", + "provider": "bedrock", + "input_cost_per_token": 8e-7, + "output_cost_per_token": 0.0000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-opus-4-1-20250805-v1:0", + "name": "anthropic.claude-opus-4-1-20250805-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-opus-4-20250514-v1:0", + "name": "anthropic.claude-opus-4-20250514-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-opus-4-5-20251101-v1:0", + "name": "anthropic.claude-opus-4-5-20251101-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-opus-4-6-v1", + "name": "anthropic.claude-opus-4-6-v1", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "global.anthropic.claude-opus-4-6-v1", + "name": "global.anthropic.claude-opus-4-6-v1", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-opus-4-6-v1", + "name": "us.anthropic.claude-opus-4-6-v1", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-opus-4-6-v1", + "name": "eu.anthropic.claude-opus-4-6-v1", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "au.anthropic.claude-opus-4-6-v1", + "name": "au.anthropic.claude-opus-4-6-v1", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-opus-4-7", + "name": "anthropic.claude-opus-4-7", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-mythos-preview", + "name": "anthropic.claude-mythos-preview", + "provider": "bedrock", + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "global.anthropic.claude-opus-4-7", + "name": "global.anthropic.claude-opus-4-7", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-opus-4-7", + "name": "us.anthropic.claude-opus-4-7", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-opus-4-7", + "name": "eu.anthropic.claude-opus-4-7", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "au.anthropic.claude-opus-4-7", + "name": "au.anthropic.claude-opus-4-7", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-fable-5", + "name": "anthropic.claude-fable-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00005, + "cache_read_input_token_cost": 0.000001, + "output_cost_per_reasoning_token": null + }, + { + "id": "global.anthropic.claude-fable-5", + "name": "global.anthropic.claude-fable-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00005, + "cache_read_input_token_cost": 0.000001, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-fable-5", + "name": "us.anthropic.claude-fable-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000011, + "output_cost_per_token": 0.000055, + "cache_read_input_token_cost": 0.0000011, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-fable-5", + "name": "eu.anthropic.claude-fable-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000011, + "output_cost_per_token": 0.000055, + "cache_read_input_token_cost": 0.0000011, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-opus-5", + "name": "anthropic.claude-opus-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "global.anthropic.claude-opus-5", + "name": "global.anthropic.claude-opus-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-opus-5", + "name": "us.anthropic.claude-opus-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-opus-5", + "name": "eu.anthropic.claude-opus-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "au.anthropic.claude-opus-5", + "name": "au.anthropic.claude-opus-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "jp.anthropic.claude-opus-5", + "name": "jp.anthropic.claude-opus-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-opus-4-8", + "name": "anthropic.claude-opus-4-8", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "global.anthropic.claude-opus-4-8", + "name": "global.anthropic.claude-opus-4-8", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-opus-4-8", + "name": "us.anthropic.claude-opus-4-8", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-opus-4-8", + "name": "eu.anthropic.claude-opus-4-8", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "au.anthropic.claude-opus-4-8", + "name": "au.anthropic.claude-opus-4-8", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "jp.anthropic.claude-opus-4-8", + "name": "jp.anthropic.claude-opus-4-8", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "jp.anthropic.claude-opus-4-7", + "name": "jp.anthropic.claude-opus-4-7", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-sonnet-5", + "name": "anthropic.claude-sonnet-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "global.anthropic.claude-sonnet-5", + "name": "global.anthropic.claude-sonnet-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-sonnet-5", + "name": "us.anthropic.claude-sonnet-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000022, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": 2.2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-sonnet-5", + "name": "eu.anthropic.claude-sonnet-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000022, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": 2.2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "au.anthropic.claude-sonnet-5", + "name": "au.anthropic.claude-sonnet-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000022, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": 2.2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "jp.anthropic.claude-sonnet-5", + "name": "jp.anthropic.claude-sonnet-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000022, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": 2.2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-sonnet-4-6", + "name": "anthropic.claude-sonnet-4-6", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "global.anthropic.claude-sonnet-4-6", + "name": "global.anthropic.claude-sonnet-4-6", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-sonnet-4-6", + "name": "us.anthropic.claude-sonnet-4-6", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000033, + "output_cost_per_token": 0.0000165, + "cache_read_input_token_cost": 3.3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-sonnet-4-6", + "name": "eu.anthropic.claude-sonnet-4-6", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000033, + "output_cost_per_token": 0.0000165, + "cache_read_input_token_cost": 3.3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "au.anthropic.claude-sonnet-4-6", + "name": "au.anthropic.claude-sonnet-4-6", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000033, + "output_cost_per_token": 0.0000165, + "cache_read_input_token_cost": 3.3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "jp.anthropic.claude-sonnet-4-6", + "name": "jp.anthropic.claude-sonnet-4-6", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000033, + "output_cost_per_token": 0.0000165, + "cache_read_input_token_cost": 3.3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-sonnet-4-20250514-v1:0", + "name": "anthropic.claude-sonnet-4-20250514-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-sonnet-4-5-20250929-v1:0", + "name": "anthropic.claude-sonnet-4-5-20250929-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-v1", + "name": "anthropic.claude-v1", + "provider": "bedrock", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "anthropic.claude-v2:1", + "name": "anthropic.claude-v2:1", + "provider": "bedrock", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "apac.amazon.nova-lite-v1:0", + "name": "apac.amazon.nova-lite-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 6.3e-8, + "output_cost_per_token": 2.52e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "apac.amazon.nova-micro-v1:0", + "name": "apac.amazon.nova-micro-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 3.7e-8, + "output_cost_per_token": 1.48e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "apac.amazon.nova-pro-v1:0", + "name": "apac.amazon.nova-pro-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 8.4e-7, + "output_cost_per_token": 0.00000336, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "apac.anthropic.claude-3-5-sonnet-20240620-v1:0", + "name": "apac.anthropic.claude-3-5-sonnet-20240620-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "apac.anthropic.claude-3-5-sonnet-20241022-v2:0", + "name": "apac.anthropic.claude-3-5-sonnet-20241022-v2:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "apac.anthropic.claude-3-haiku-20240307-v1:0", + "name": "apac.anthropic.claude-3-haiku-20240307-v1:0", + "provider": "bedrock", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.00000125, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "apac.anthropic.claude-haiku-4-5-20251001-v1:0", + "name": "apac.anthropic.claude-haiku-4-5-20251001-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000055, + "cache_read_input_token_cost": 1.1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "apac.anthropic.claude-3-sonnet-20240229-v1:0", + "name": "apac.anthropic.claude-3-sonnet-20240229-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "apac.anthropic.claude-sonnet-4-20250514-v1:0", + "name": "apac.anthropic.claude-sonnet-4-20250514-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "au.anthropic.claude-sonnet-4-5-20250929-v1:0", + "name": "au.anthropic.claude-sonnet-4-5-20250929-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000033, + "output_cost_per_token": 0.0000165, + "cache_read_input_token_cost": 3.3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/command-r-plus", + "name": "command-r-plus", + "provider": "azure", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/claude-haiku-4-5", + "name": "claude-haiku-4-5", + "provider": "azure_ai", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "cache_read_input_token_cost": 1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/claude-opus-4-5", + "name": "claude-opus-4-5", + "provider": "azure_ai", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/claude-opus-4-6", + "name": "claude-opus-4-6", + "provider": "azure_ai", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/claude-opus-4-7", + "name": "claude-opus-4-7", + "provider": "azure_ai", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/claude-fable-5", + "name": "claude-fable-5", + "provider": "azure_ai", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00005, + "cache_read_input_token_cost": 0.000001, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/claude-opus-5", + "name": "claude-opus-5", + "provider": "azure_ai", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/claude-opus-4-8", + "name": "claude-opus-4-8", + "provider": "azure_ai", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/claude-opus-4-1", + "name": "claude-opus-4-1", + "provider": "azure_ai", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/claude-sonnet-4-5", + "name": "claude-sonnet-4-5", + "provider": "azure_ai", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/claude-sonnet-5", + "name": "claude-sonnet-5", + "provider": "azure_ai", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/claude-sonnet-4-6", + "name": "claude-sonnet-4-6", + "provider": "azure_ai", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/computer-use-preview", + "name": "computer-use-preview", + "provider": "azure", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/gpt-oss-120b", + "name": "gpt-oss-120b", + "provider": "azure_ai", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/gpt-5.5", + "name": "gpt-5.5", + "provider": "azure_ai", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/gpt-5.5-2026-04-23", + "name": "gpt-5.5-2026-04-23", + "provider": "azure_ai", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/gpt-5.4", + "name": "gpt-5.4", + "provider": "azure_ai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 2.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/gpt-5.4-2026-03-05", + "name": "gpt-5.4-2026-03-05", + "provider": "azure_ai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 2.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/gpt-5.4-mini", + "name": "gpt-5.4-mini", + "provider": "azure_ai", + "input_cost_per_token": 7.5e-7, + "output_cost_per_token": 0.0000045, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/gpt-5.4-mini-2026-03-17", + "name": "gpt-5.4-mini-2026-03-17", + "provider": "azure_ai", + "input_cost_per_token": 7.5e-7, + "output_cost_per_token": 0.0000045, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/gpt-5.4-nano", + "name": "gpt-5.4-nano", + "provider": "azure_ai", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 0.00000125, + "cache_read_input_token_cost": 2e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/gpt-5.4-nano-2026-03-17", + "name": "gpt-5.4-nano-2026-03-17", + "provider": "azure_ai", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 0.00000125, + "cache_read_input_token_cost": 2e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/model_router", + "name": "model_router", + "provider": "azure_ai", + "input_cost_per_token": 1.4e-7, + "output_cost_per_token": 0, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-4o-2024-08-06", + "name": "eu/gpt-4o-2024-08-06", + "provider": "azure", + "input_cost_per_token": 0.00000275, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": 0.000001375, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-4o-2024-11-20", + "name": "eu/gpt-4o-2024-11-20", + "provider": "azure", + "input_cost_per_token": 0.00000275, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-4o-mini-2024-07-18", + "name": "eu/gpt-4o-mini-2024-07-18", + "provider": "azure", + "input_cost_per_token": 1.65e-7, + "output_cost_per_token": 6.6e-7, + "cache_read_input_token_cost": 8.3e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-5-2025-08-07", + "name": "eu/gpt-5-2025-08-07", + "provider": "azure", + "input_cost_per_token": 0.000001375, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": 1.375e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-5-mini-2025-08-07", + "name": "eu/gpt-5-mini-2025-08-07", + "provider": "azure", + "input_cost_per_token": 2.75e-7, + "output_cost_per_token": 0.0000022, + "cache_read_input_token_cost": 2.75e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-5.1", + "name": "eu/gpt-5.1", + "provider": "azure", + "input_cost_per_token": 0.00000138, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": 1.4e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-5.1-chat", + "name": "eu/gpt-5.1-chat", + "provider": "azure", + "input_cost_per_token": 0.00000138, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": 1.4e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-5-nano-2025-08-07", + "name": "eu/gpt-5-nano-2025-08-07", + "provider": "azure", + "input_cost_per_token": 5.5e-8, + "output_cost_per_token": 4.4e-7, + "cache_read_input_token_cost": 5.5e-9, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/o1-2024-12-17", + "name": "eu/o1-2024-12-17", + "provider": "azure", + "input_cost_per_token": 0.0000165, + "output_cost_per_token": 0.000066, + "cache_read_input_token_cost": 0.00000825, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/o1-mini-2024-09-12", + "name": "eu/o1-mini-2024-09-12", + "provider": "azure", + "input_cost_per_token": 0.00000121, + "output_cost_per_token": 0.00000484, + "cache_read_input_token_cost": 6.05e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/o1-preview-2024-09-12", + "name": "eu/o1-preview-2024-09-12", + "provider": "azure", + "input_cost_per_token": 0.0000165, + "output_cost_per_token": 0.000066, + "cache_read_input_token_cost": 0.00000825, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/o3-mini-2025-01-31", + "name": "eu/o3-mini-2025-01-31", + "provider": "azure", + "input_cost_per_token": 0.00000121, + "output_cost_per_token": 0.00000484, + "cache_read_input_token_cost": 6.05e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/global-standard/gpt-4o-2024-08-06", + "name": "global-standard/gpt-4o-2024-08-06", + "provider": "azure", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 0.00000125, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/global-standard/gpt-4o-2024-11-20", + "name": "global-standard/gpt-4o-2024-11-20", + "provider": "azure", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 0.00000125, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/global-standard/gpt-4o-mini", + "name": "global-standard/gpt-4o-mini", + "provider": "azure", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/global/gpt-4o-2024-08-06", + "name": "global/gpt-4o-2024-08-06", + "provider": "azure", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 0.00000125, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/global/gpt-4o-2024-11-20", + "name": "global/gpt-4o-2024-11-20", + "provider": "azure", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 0.00000125, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/global/gpt-5.1", + "name": "global/gpt-5.1", + "provider": "azure", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/global/gpt-5.1-chat", + "name": "global/gpt-5.1-chat", + "provider": "azure", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-3.5-turbo", + "name": "gpt-3.5-turbo", + "provider": "azure", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-3.5-turbo-0125", + "name": "gpt-3.5-turbo-0125", + "provider": "azure", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-35-turbo", + "name": "gpt-35-turbo", + "provider": "azure", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-35-turbo-0125", + "name": "gpt-35-turbo-0125", + "provider": "azure", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-35-turbo-1106", + "name": "gpt-35-turbo-1106", + "provider": "azure", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-35-turbo-16k", + "name": "gpt-35-turbo-16k", + "provider": "azure", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-35-turbo-16k-0613", + "name": "gpt-35-turbo-16k-0613", + "provider": "azure", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4", + "name": "gpt-4", + "provider": "azure", + "input_cost_per_token": 0.00003, + "output_cost_per_token": 0.00006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4-0125-preview", + "name": "gpt-4-0125-preview", + "provider": "azure", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4-0613", + "name": "gpt-4-0613", + "provider": "azure", + "input_cost_per_token": 0.00003, + "output_cost_per_token": 0.00006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4-1106-preview", + "name": "gpt-4-1106-preview", + "provider": "azure", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4-32k", + "name": "gpt-4-32k", + "provider": "azure", + "input_cost_per_token": 0.00006, + "output_cost_per_token": 0.00012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4-32k-0613", + "name": "gpt-4-32k-0613", + "provider": "azure", + "input_cost_per_token": 0.00006, + "output_cost_per_token": 0.00012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4-turbo", + "name": "gpt-4-turbo", + "provider": "azure", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4-turbo-2024-04-09", + "name": "gpt-4-turbo-2024-04-09", + "provider": "azure", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4-turbo-vision-preview", + "name": "gpt-4-turbo-vision-preview", + "provider": "azure", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4.1", + "name": "gpt-4.1", + "provider": "azure", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000008, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4.1-2025-04-14", + "name": "gpt-4.1-2025-04-14", + "provider": "azure", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000008, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4.1-mini", + "name": "gpt-4.1-mini", + "provider": "azure", + "input_cost_per_token": 4e-7, + "output_cost_per_token": 0.0000016, + "cache_read_input_token_cost": 1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4.1-mini-2025-04-14", + "name": "gpt-4.1-mini-2025-04-14", + "provider": "azure", + "input_cost_per_token": 4e-7, + "output_cost_per_token": 0.0000016, + "cache_read_input_token_cost": 1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4.1-nano", + "name": "gpt-4.1-nano", + "provider": "azure", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4.1-nano-2025-04-14", + "name": "gpt-4.1-nano-2025-04-14", + "provider": "azure", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4.5-preview", + "name": "gpt-4.5-preview", + "provider": "azure", + "input_cost_per_token": 0.000075, + "output_cost_per_token": 0.00015, + "cache_read_input_token_cost": 0.0000375, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4o", + "name": "gpt-4o", + "provider": "azure", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 0.00000125, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4o-2024-05-13", + "name": "gpt-4o-2024-05-13", + "provider": "azure", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4o-2024-08-06", + "name": "gpt-4o-2024-08-06", + "provider": "azure", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 0.00000125, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4o-2024-11-20", + "name": "gpt-4o-2024-11-20", + "provider": "azure", + "input_cost_per_token": 0.00000275, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": 0.00000125, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-audio-2025-08-28", + "name": "gpt-audio-2025-08-28", + "provider": "azure", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-audio-1.5-2026-02-23", + "name": "gpt-audio-1.5-2026-02-23", + "provider": "azure", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-audio-mini-2025-10-06", + "name": "gpt-audio-mini-2025-10-06", + "provider": "azure", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4o-audio-preview-2024-12-17", + "name": "gpt-4o-audio-preview-2024-12-17", + "provider": "azure", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4o-mini", + "name": "gpt-4o-mini", + "provider": "azure", + "input_cost_per_token": 1.65e-7, + "output_cost_per_token": 6.6e-7, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4o-mini-2024-07-18", + "name": "gpt-4o-mini-2024-07-18", + "provider": "azure", + "input_cost_per_token": 1.65e-7, + "output_cost_per_token": 6.6e-7, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-4o-mini-audio-preview-2024-12-17", + "name": "gpt-4o-mini-audio-preview-2024-12-17", + "provider": "azure", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.1-2025-11-13", + "name": "gpt-5.1-2025-11-13", + "provider": "azure", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.1-chat-2025-11-13", + "name": "gpt-5.1-chat-2025-11-13", + "provider": "azure", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5", + "name": "gpt-5", + "provider": "azure", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5-2025-08-07", + "name": "gpt-5-2025-08-07", + "provider": "azure", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5-chat", + "name": "gpt-5-chat", + "provider": "azure", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5-chat-latest", + "name": "gpt-5-chat-latest", + "provider": "azure", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5-mini", + "name": "gpt-5-mini", + "provider": "azure", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5-mini-2025-08-07", + "name": "gpt-5-mini-2025-08-07", + "provider": "azure", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5-nano", + "name": "gpt-5-nano", + "provider": "azure", + "input_cost_per_token": 5e-8, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 5e-9, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5-nano-2025-08-07", + "name": "gpt-5-nano-2025-08-07", + "provider": "azure", + "input_cost_per_token": 5e-8, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 5e-9, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.1", + "name": "gpt-5.1", + "provider": "azure", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.1-chat", + "name": "gpt-5.1-chat", + "provider": "azure", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.2", + "name": "gpt-5.2", + "provider": "azure", + "input_cost_per_token": 0.00000175, + "output_cost_per_token": 0.000014, + "cache_read_input_token_cost": 1.75e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.2-2025-12-11", + "name": "gpt-5.2-2025-12-11", + "provider": "azure", + "input_cost_per_token": 0.00000175, + "output_cost_per_token": 0.000014, + "cache_read_input_token_cost": 1.75e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.2-chat", + "name": "gpt-5.2-chat", + "provider": "azure", + "input_cost_per_token": 0.00000175, + "output_cost_per_token": 0.000014, + "cache_read_input_token_cost": 1.75e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.2-chat-2025-12-11", + "name": "gpt-5.2-chat-2025-12-11", + "provider": "azure", + "input_cost_per_token": 0.00000175, + "output_cost_per_token": 0.000014, + "cache_read_input_token_cost": 1.75e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.3-chat", + "name": "gpt-5.3-chat", + "provider": "azure", + "input_cost_per_token": 0.00000175, + "output_cost_per_token": 0.000014, + "cache_read_input_token_cost": 1.75e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.4", + "name": "gpt-5.4", + "provider": "azure", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 2.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-5.4", + "name": "us/gpt-5.4", + "provider": "azure", + "input_cost_per_token": 0.00000275, + "output_cost_per_token": 0.0000165, + "cache_read_input_token_cost": 2.8e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-5.4", + "name": "eu/gpt-5.4", + "provider": "azure", + "input_cost_per_token": 0.00000275, + "output_cost_per_token": 0.0000165, + "cache_read_input_token_cost": 2.8e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.4-2026-03-05", + "name": "gpt-5.4-2026-03-05", + "provider": "azure", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 2.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-5.4-2026-03-05", + "name": "us/gpt-5.4-2026-03-05", + "provider": "azure", + "input_cost_per_token": 0.00000275, + "output_cost_per_token": 0.0000165, + "cache_read_input_token_cost": 2.8e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-5.4-2026-03-05", + "name": "eu/gpt-5.4-2026-03-05", + "provider": "azure", + "input_cost_per_token": 0.00000275, + "output_cost_per_token": 0.0000165, + "cache_read_input_token_cost": 2.8e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.6", + "name": "gpt-5.6", + "provider": "azure", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.6-sol", + "name": "gpt-5.6-sol", + "provider": "azure", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.6-terra", + "name": "gpt-5.6-terra", + "provider": "azure", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.6-luna", + "name": "gpt-5.6-luna", + "provider": "azure", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": 2e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-5.6", + "name": "us/gpt-5.6", + "provider": "azure", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.000033, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-5.6-sol", + "name": "us/gpt-5.6-sol", + "provider": "azure", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.000033, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-5.6-terra", + "name": "us/gpt-5.6-terra", + "provider": "azure", + "input_cost_per_token": 0.0000022, + "output_cost_per_token": 0.0000132, + "cache_read_input_token_cost": 2.2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-5.6-luna", + "name": "us/gpt-5.6-luna", + "provider": "azure", + "input_cost_per_token": 2.2e-7, + "output_cost_per_token": 0.00000132, + "cache_read_input_token_cost": 2.2e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-5.6", + "name": "eu/gpt-5.6", + "provider": "azure", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.000033, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-5.6-sol", + "name": "eu/gpt-5.6-sol", + "provider": "azure", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.000033, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-5.6-terra", + "name": "eu/gpt-5.6-terra", + "provider": "azure", + "input_cost_per_token": 0.0000022, + "output_cost_per_token": 0.0000132, + "cache_read_input_token_cost": 2.2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-5.6-luna", + "name": "eu/gpt-5.6-luna", + "provider": "azure", + "input_cost_per_token": 2.2e-7, + "output_cost_per_token": 0.00000132, + "cache_read_input_token_cost": 2.2e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.5", + "name": "gpt-5.5", + "provider": "azure", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-5.5", + "name": "us/gpt-5.5", + "provider": "azure", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.000033, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-5.5", + "name": "eu/gpt-5.5", + "provider": "azure", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.000033, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.5-2026-04-23", + "name": "gpt-5.5-2026-04-23", + "provider": "azure", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-5.5-2026-04-23", + "name": "us/gpt-5.5-2026-04-23", + "provider": "azure", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.000033, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/eu/gpt-5.5-2026-04-23", + "name": "eu/gpt-5.5-2026-04-23", + "provider": "azure", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.000033, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.4-mini", + "name": "gpt-5.4-mini", + "provider": "azure", + "input_cost_per_token": 7.5e-7, + "output_cost_per_token": 0.0000045, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.4-mini-2026-03-17", + "name": "gpt-5.4-mini-2026-03-17", + "provider": "azure", + "input_cost_per_token": 7.5e-7, + "output_cost_per_token": 0.0000045, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.4-nano", + "name": "gpt-5.4-nano", + "provider": "azure", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 0.00000125, + "cache_read_input_token_cost": 2e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-5.4-nano-2026-03-17", + "name": "gpt-5.4-nano-2026-03-17", + "provider": "azure", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 0.00000125, + "cache_read_input_token_cost": 2e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/mistral-large-2402", + "name": "mistral-large-2402", + "provider": "azure", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/mistral-large-latest", + "name": "mistral-large-latest", + "provider": "azure", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/o1", + "name": "o1", + "provider": "azure", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.00006, + "cache_read_input_token_cost": 0.0000075, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/o1-2024-12-17", + "name": "o1-2024-12-17", + "provider": "azure", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.00006, + "cache_read_input_token_cost": 0.0000075, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/o1-mini", + "name": "o1-mini", + "provider": "azure", + "input_cost_per_token": 0.00000121, + "output_cost_per_token": 0.00000484, + "cache_read_input_token_cost": 6.05e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/o1-mini-2024-09-12", + "name": "o1-mini-2024-09-12", + "provider": "azure", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000044, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/o1-preview", + "name": "o1-preview", + "provider": "azure", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.00006, + "cache_read_input_token_cost": 0.0000075, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/o1-preview-2024-09-12", + "name": "o1-preview-2024-09-12", + "provider": "azure", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.00006, + "cache_read_input_token_cost": 0.0000075, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/o3", + "name": "o3", + "provider": "azure", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000008, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/o3-2025-04-16", + "name": "o3-2025-04-16", + "provider": "azure", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000008, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/o3-mini", + "name": "o3-mini", + "provider": "azure", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000044, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/o3-mini-2025-01-31", + "name": "o3-mini-2025-01-31", + "provider": "azure", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000044, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/o4-mini", + "name": "o4-mini", + "provider": "azure", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000044, + "cache_read_input_token_cost": 2.75e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/o4-mini-2025-04-16", + "name": "o4-mini-2025-04-16", + "provider": "azure", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000044, + "cache_read_input_token_cost": 2.75e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-4.1-2025-04-14", + "name": "us/gpt-4.1-2025-04-14", + "provider": "azure", + "input_cost_per_token": 0.0000022, + "output_cost_per_token": 0.0000088, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-4.1-mini-2025-04-14", + "name": "us/gpt-4.1-mini-2025-04-14", + "provider": "azure", + "input_cost_per_token": 4.4e-7, + "output_cost_per_token": 0.00000176, + "cache_read_input_token_cost": 1.1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-4.1-nano-2025-04-14", + "name": "us/gpt-4.1-nano-2025-04-14", + "provider": "azure", + "input_cost_per_token": 1.1e-7, + "output_cost_per_token": 4.4e-7, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-4o-2024-08-06", + "name": "us/gpt-4o-2024-08-06", + "provider": "azure", + "input_cost_per_token": 0.00000275, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": 0.000001375, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-4o-2024-11-20", + "name": "us/gpt-4o-2024-11-20", + "provider": "azure", + "input_cost_per_token": 0.00000275, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-4o-mini-2024-07-18", + "name": "us/gpt-4o-mini-2024-07-18", + "provider": "azure", + "input_cost_per_token": 1.65e-7, + "output_cost_per_token": 6.6e-7, + "cache_read_input_token_cost": 8.3e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-5-2025-08-07", + "name": "us/gpt-5-2025-08-07", + "provider": "azure", + "input_cost_per_token": 0.000001375, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": 1.375e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-5-mini-2025-08-07", + "name": "us/gpt-5-mini-2025-08-07", + "provider": "azure", + "input_cost_per_token": 2.75e-7, + "output_cost_per_token": 0.0000022, + "cache_read_input_token_cost": 2.75e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-5-nano-2025-08-07", + "name": "us/gpt-5-nano-2025-08-07", + "provider": "azure", + "input_cost_per_token": 5.5e-8, + "output_cost_per_token": 4.4e-7, + "cache_read_input_token_cost": 5.5e-9, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-5.1", + "name": "us/gpt-5.1", + "provider": "azure", + "input_cost_per_token": 0.00000138, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": 1.4e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/gpt-5.1-chat", + "name": "us/gpt-5.1-chat", + "provider": "azure", + "input_cost_per_token": 0.00000138, + "output_cost_per_token": 0.000011, + "cache_read_input_token_cost": 1.4e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/o1-2024-12-17", + "name": "us/o1-2024-12-17", + "provider": "azure", + "input_cost_per_token": 0.0000165, + "output_cost_per_token": 0.000066, + "cache_read_input_token_cost": 0.00000825, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/o1-mini-2024-09-12", + "name": "us/o1-mini-2024-09-12", + "provider": "azure", + "input_cost_per_token": 0.00000121, + "output_cost_per_token": 0.00000484, + "cache_read_input_token_cost": 6.05e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/o1-preview-2024-09-12", + "name": "us/o1-preview-2024-09-12", + "provider": "azure", + "input_cost_per_token": 0.0000165, + "output_cost_per_token": 0.000066, + "cache_read_input_token_cost": 0.00000825, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/o3-2025-04-16", + "name": "us/o3-2025-04-16", + "provider": "azure", + "input_cost_per_token": 0.0000022, + "output_cost_per_token": 0.0000088, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/o3-mini-2025-01-31", + "name": "us/o3-mini-2025-01-31", + "provider": "azure", + "input_cost_per_token": 0.00000121, + "output_cost_per_token": 0.00000484, + "cache_read_input_token_cost": 6.05e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/us/o4-mini-2025-04-16", + "name": "us/o4-mini-2025-04-16", + "provider": "azure", + "input_cost_per_token": 0.00000121, + "output_cost_per_token": 0.00000484, + "cache_read_input_token_cost": 3.1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Llama-3.2-11B-Vision-Instruct", + "name": "Llama-3.2-11B-Vision-Instruct", + "provider": "azure_ai", + "input_cost_per_token": 3.7e-7, + "output_cost_per_token": 3.7e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Llama-3.2-90B-Vision-Instruct", + "name": "Llama-3.2-90B-Vision-Instruct", + "provider": "azure_ai", + "input_cost_per_token": 0.00000204, + "output_cost_per_token": 0.00000204, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Llama-3.3-70B-Instruct", + "name": "Llama-3.3-70B-Instruct", + "provider": "azure_ai", + "input_cost_per_token": 7.1e-7, + "output_cost_per_token": 7.1e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8", + "name": "Llama-4-Maverick-17B-128E-Instruct-FP8", + "provider": "azure_ai", + "input_cost_per_token": 0.00000141, + "output_cost_per_token": 3.5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Llama-4-Scout-17B-16E-Instruct", + "name": "Llama-4-Scout-17B-16E-Instruct", + "provider": "azure_ai", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 7.8e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Meta-Llama-3-70B-Instruct", + "name": "Meta-Llama-3-70B-Instruct", + "provider": "azure_ai", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 3.7e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Meta-Llama-3.1-405B-Instruct", + "name": "Meta-Llama-3.1-405B-Instruct", + "provider": "azure_ai", + "input_cost_per_token": 0.00000533, + "output_cost_per_token": 0.000016, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Meta-Llama-3.1-70B-Instruct", + "name": "Meta-Llama-3.1-70B-Instruct", + "provider": "azure_ai", + "input_cost_per_token": 0.00000268, + "output_cost_per_token": 0.00000354, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Meta-Llama-3.1-8B-Instruct", + "name": "Meta-Llama-3.1-8B-Instruct", + "provider": "azure_ai", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 6.1e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Phi-3-medium-128k-instruct", + "name": "Phi-3-medium-128k-instruct", + "provider": "azure_ai", + "input_cost_per_token": 1.7e-7, + "output_cost_per_token": 6.8e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Phi-3-medium-4k-instruct", + "name": "Phi-3-medium-4k-instruct", + "provider": "azure_ai", + "input_cost_per_token": 1.7e-7, + "output_cost_per_token": 6.8e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Phi-3-mini-128k-instruct", + "name": "Phi-3-mini-128k-instruct", + "provider": "azure_ai", + "input_cost_per_token": 1.3e-7, + "output_cost_per_token": 5.2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Phi-3-mini-4k-instruct", + "name": "Phi-3-mini-4k-instruct", + "provider": "azure_ai", + "input_cost_per_token": 1.3e-7, + "output_cost_per_token": 5.2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Phi-3-small-128k-instruct", + "name": "Phi-3-small-128k-instruct", + "provider": "azure_ai", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Phi-3-small-8k-instruct", + "name": "Phi-3-small-8k-instruct", + "provider": "azure_ai", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Phi-3.5-MoE-instruct", + "name": "Phi-3.5-MoE-instruct", + "provider": "azure_ai", + "input_cost_per_token": 1.6e-7, + "output_cost_per_token": 6.4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Phi-3.5-mini-instruct", + "name": "Phi-3.5-mini-instruct", + "provider": "azure_ai", + "input_cost_per_token": 1.3e-7, + "output_cost_per_token": 5.2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Phi-3.5-vision-instruct", + "name": "Phi-3.5-vision-instruct", + "provider": "azure_ai", + "input_cost_per_token": 1.3e-7, + "output_cost_per_token": 5.2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Phi-4", + "name": "Phi-4", + "provider": "azure_ai", + "input_cost_per_token": 1.25e-7, + "output_cost_per_token": 5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Phi-4-mini-instruct", + "name": "Phi-4-mini-instruct", + "provider": "azure_ai", + "input_cost_per_token": 7.5e-8, + "output_cost_per_token": 3e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Phi-4-multimodal-instruct", + "name": "Phi-4-multimodal-instruct", + "provider": "azure_ai", + "input_cost_per_token": 8e-8, + "output_cost_per_token": 3.2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Phi-4-mini-reasoning", + "name": "Phi-4-mini-reasoning", + "provider": "azure_ai", + "input_cost_per_token": 8e-8, + "output_cost_per_token": 3.2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/Phi-4-reasoning", + "name": "Phi-4-reasoning", + "provider": "azure_ai", + "input_cost_per_token": 1.25e-7, + "output_cost_per_token": 5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/MAI-DS-R1", + "name": "MAI-DS-R1", + "provider": "azure_ai", + "input_cost_per_token": 0.00000135, + "output_cost_per_token": 0.0000054, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/deepseek-v3.2", + "name": "deepseek-v3.2", + "provider": "azure_ai", + "input_cost_per_token": 5.8e-7, + "output_cost_per_token": 0.00000168, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/deepseek-v3.2-speciale", + "name": "deepseek-v3.2-speciale", + "provider": "azure_ai", + "input_cost_per_token": 5.8e-7, + "output_cost_per_token": 0.00000168, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/deepseek-r1", + "name": "deepseek-r1", + "provider": "azure_ai", + "input_cost_per_token": 0.00000135, + "output_cost_per_token": 0.0000054, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/deepseek-v3", + "name": "deepseek-v3", + "provider": "azure_ai", + "input_cost_per_token": 0.00000114, + "output_cost_per_token": 0.00000456, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/deepseek-v3-0324", + "name": "deepseek-v3-0324", + "provider": "azure_ai", + "input_cost_per_token": 0.00000114, + "output_cost_per_token": 0.00000456, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/deepseek-v3.1", + "name": "deepseek-v3.1", + "provider": "azure_ai", + "input_cost_per_token": 0.00000123, + "output_cost_per_token": 0.00000494, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/deepseek-v4-pro", + "name": "deepseek-v4-pro", + "provider": "azure_ai", + "input_cost_per_token": 0.00000174, + "output_cost_per_token": 0.00000348, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/deepseek-v4-flash", + "name": "deepseek-v4-flash", + "provider": "azure_ai", + "input_cost_per_token": 1.9e-7, + "output_cost_per_token": 5.1e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/global/grok-3", + "name": "global/grok-3", + "provider": "azure_ai", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/global/grok-3-mini", + "name": "global/grok-3-mini", + "provider": "azure_ai", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.00000127, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/grok-3", + "name": "grok-3", + "provider": "azure_ai", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/grok-3-mini", + "name": "grok-3-mini", + "provider": "azure_ai", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.00000127, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/grok-4", + "name": "grok-4", + "provider": "azure_ai", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/grok-4-fast-non-reasoning", + "name": "grok-4-fast-non-reasoning", + "provider": "azure_ai", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/grok-4-fast-reasoning", + "name": "grok-4-fast-reasoning", + "provider": "azure_ai", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/grok-4-1-fast-non-reasoning", + "name": "grok-4-1-fast-non-reasoning", + "provider": "azure_ai", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/grok-4-1-fast-reasoning", + "name": "grok-4-1-fast-reasoning", + "provider": "azure_ai", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/grok-code-fast-1", + "name": "grok-code-fast-1", + "provider": "azure_ai", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/jais-30b-chat", + "name": "jais-30b-chat", + "provider": "azure_ai", + "input_cost_per_token": 0.0032, + "output_cost_per_token": 0.00971, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/jamba-instruct", + "name": "jamba-instruct", + "provider": "azure_ai", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 7e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/kimi-k2.5", + "name": "kimi-k2.5", + "provider": "azure_ai", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/kimi-k2.6", + "name": "kimi-k2.6", + "provider": "azure_ai", + "input_cost_per_token": 9.5e-7, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/ministral-3b", + "name": "ministral-3b", + "provider": "azure_ai", + "input_cost_per_token": 4e-8, + "output_cost_per_token": 4e-8, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/mistral-large", + "name": "mistral-large", + "provider": "azure_ai", + "input_cost_per_token": 0.000004, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/mistral-large-2407", + "name": "mistral-large-2407", + "provider": "azure_ai", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/mistral-large-latest", + "name": "mistral-large-latest", + "provider": "azure_ai", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/mistral-large-3", + "name": "mistral-large-3", + "provider": "azure_ai", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/mistral-medium-2505", + "name": "mistral-medium-2505", + "provider": "azure_ai", + "input_cost_per_token": 4e-7, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/mistral-nemo", + "name": "mistral-nemo", + "provider": "azure_ai", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 1.5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/mistral-small", + "name": "mistral-small", + "provider": "azure_ai", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure_ai/mistral-small-2503", + "name": "mistral-small-2503", + "provider": "azure_ai", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 3e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-northeast-1/anthropic.claude-instant-v1", + "name": "ap-northeast-1/anthropic.claude-instant-v1", + "provider": "bedrock", + "input_cost_per_token": 0.00000223, + "output_cost_per_token": 0.00000755, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-northeast-1/anthropic.claude-v1", + "name": "ap-northeast-1/anthropic.claude-v1", + "provider": "bedrock", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-northeast-1/anthropic.claude-v2:1", + "name": "ap-northeast-1/anthropic.claude-v2:1", + "provider": "bedrock", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-northeast-1/deepseek.v3.2", + "name": "ap-northeast-1/deepseek.v3.2", + "provider": "bedrock", + "input_cost_per_token": 7.4e-7, + "output_cost_per_token": 0.00000222, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-northeast-1/minimax.minimax-m2.1", + "name": "ap-northeast-1/minimax.minimax-m2.1", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-northeast-1/minimax.minimax-m2.5", + "name": "ap-northeast-1/minimax.minimax-m2.5", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-northeast-1/moonshotai.kimi-k2-thinking", + "name": "ap-northeast-1/moonshotai.kimi-k2-thinking", + "provider": "bedrock", + "input_cost_per_token": 7.3e-7, + "output_cost_per_token": 0.00000303, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-northeast-1/moonshotai.kimi-k2.5", + "name": "ap-northeast-1/moonshotai.kimi-k2.5", + "provider": "bedrock", + "input_cost_per_token": 7.2e-7, + "output_cost_per_token": 0.0000036, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-northeast-1/qwen.qwen3-coder-next", + "name": "ap-northeast-1/qwen.qwen3-coder-next", + "provider": "bedrock", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/moonshotai.kimi-k2-thinking", + "name": "moonshotai.kimi-k2-thinking", + "provider": "bedrock", + "input_cost_per_token": 7.3e-7, + "output_cost_per_token": 0.00000303, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/moonshotai.kimi-k2.5", + "name": "moonshotai.kimi-k2.5", + "provider": "bedrock", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.00000303, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-south-1/meta.llama3-70b-instruct-v1:0", + "name": "ap-south-1/meta.llama3-70b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.00000318, + "output_cost_per_token": 0.0000042, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-south-1/meta.llama3-8b-instruct-v1:0", + "name": "ap-south-1/meta.llama3-8b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 7.2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-south-1/deepseek.v3.2", + "name": "ap-south-1/deepseek.v3.2", + "provider": "bedrock", + "input_cost_per_token": 7.4e-7, + "output_cost_per_token": 0.00000222, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-south-1/minimax.minimax-m2.1", + "name": "ap-south-1/minimax.minimax-m2.1", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-south-1/minimax.minimax-m2.5", + "name": "ap-south-1/minimax.minimax-m2.5", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-south-1/moonshotai.kimi-k2-thinking", + "name": "ap-south-1/moonshotai.kimi-k2-thinking", + "provider": "bedrock", + "input_cost_per_token": 7.1e-7, + "output_cost_per_token": 0.00000294, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-south-1/moonshotai.kimi-k2.5", + "name": "ap-south-1/moonshotai.kimi-k2.5", + "provider": "bedrock", + "input_cost_per_token": 7.2e-7, + "output_cost_per_token": 0.0000036, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-south-1/qwen.qwen3-coder-next", + "name": "ap-south-1/qwen.qwen3-coder-next", + "provider": "bedrock", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-southeast-2/minimax.minimax-m2.5", + "name": "ap-southeast-2/minimax.minimax-m2.5", + "provider": "bedrock", + "input_cost_per_token": 3.09e-7, + "output_cost_per_token": 0.000001236, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-southeast-3/deepseek.v3.2", + "name": "ap-southeast-3/deepseek.v3.2", + "provider": "bedrock", + "input_cost_per_token": 7.4e-7, + "output_cost_per_token": 0.00000222, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-southeast-3/minimax.minimax-m2.1", + "name": "ap-southeast-3/minimax.minimax-m2.1", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-southeast-3/minimax.minimax-m2.5", + "name": "ap-southeast-3/minimax.minimax-m2.5", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-southeast-3/moonshotai.kimi-k2.5", + "name": "ap-southeast-3/moonshotai.kimi-k2.5", + "provider": "bedrock", + "input_cost_per_token": 7.2e-7, + "output_cost_per_token": 0.0000036, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ap-southeast-3/qwen.qwen3-coder-next", + "name": "ap-southeast-3/qwen.qwen3-coder-next", + "provider": "bedrock", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ca-central-1/meta.llama3-70b-instruct-v1:0", + "name": "ca-central-1/meta.llama3-70b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.00000305, + "output_cost_per_token": 0.00000403, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/ca-central-1/meta.llama3-8b-instruct-v1:0", + "name": "ca-central-1/meta.llama3-8b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 3.5e-7, + "output_cost_per_token": 6.9e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-north-1/deepseek.v3.2", + "name": "eu-north-1/deepseek.v3.2", + "provider": "bedrock", + "input_cost_per_token": 7.4e-7, + "output_cost_per_token": 0.00000222, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-north-1/minimax.minimax-m2.1", + "name": "eu-north-1/minimax.minimax-m2.1", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-north-1/minimax.minimax-m2.5", + "name": "eu-north-1/minimax.minimax-m2.5", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-north-1/moonshotai.kimi-k2.5", + "name": "eu-north-1/moonshotai.kimi-k2.5", + "provider": "bedrock", + "input_cost_per_token": 7.2e-7, + "output_cost_per_token": 0.0000036, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-central-1/anthropic.claude-instant-v1", + "name": "eu-central-1/anthropic.claude-instant-v1", + "provider": "bedrock", + "input_cost_per_token": 0.00000248, + "output_cost_per_token": 0.00000838, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-central-1/anthropic.claude-v1", + "name": "eu-central-1/anthropic.claude-v1", + "provider": "bedrock", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-central-1/anthropic.claude-v2:1", + "name": "eu-central-1/anthropic.claude-v2:1", + "provider": "bedrock", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-central-1/minimax.minimax-m2.1", + "name": "eu-central-1/minimax.minimax-m2.1", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-central-1/minimax.minimax-m2.5", + "name": "eu-central-1/minimax.minimax-m2.5", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-central-1/qwen.qwen3-coder-next", + "name": "eu-central-1/qwen.qwen3-coder-next", + "provider": "bedrock", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-west-1/meta.llama3-70b-instruct-v1:0", + "name": "eu-west-1/meta.llama3-70b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.00000286, + "output_cost_per_token": 0.00000378, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-west-1/meta.llama3-8b-instruct-v1:0", + "name": "eu-west-1/meta.llama3-8b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 3.2e-7, + "output_cost_per_token": 6.5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-west-1/minimax.minimax-m2.1", + "name": "eu-west-1/minimax.minimax-m2.1", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-west-1/minimax.minimax-m2.5", + "name": "eu-west-1/minimax.minimax-m2.5", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-west-1/qwen.qwen3-coder-next", + "name": "eu-west-1/qwen.qwen3-coder-next", + "provider": "bedrock", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-west-2/meta.llama3-70b-instruct-v1:0", + "name": "eu-west-2/meta.llama3-70b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.00000345, + "output_cost_per_token": 0.00000455, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-west-2/meta.llama3-8b-instruct-v1:0", + "name": "eu-west-2/meta.llama3-8b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 3.9e-7, + "output_cost_per_token": 7.8e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-west-2/minimax.minimax-m2.1", + "name": "eu-west-2/minimax.minimax-m2.1", + "provider": "bedrock", + "input_cost_per_token": 4.7e-7, + "output_cost_per_token": 0.00000186, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-west-2/minimax.minimax-m2.5", + "name": "eu-west-2/minimax.minimax-m2.5", + "provider": "bedrock", + "input_cost_per_token": 4.7e-7, + "output_cost_per_token": 0.00000186, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-west-2/qwen.qwen3-coder-next", + "name": "eu-west-2/qwen.qwen3-coder-next", + "provider": "bedrock", + "input_cost_per_token": 7.8e-7, + "output_cost_per_token": 0.00000186, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-west-3/mistral.mistral-7b-instruct-v0:2", + "name": "eu-west-3/mistral.mistral-7b-instruct-v0:2", + "provider": "bedrock", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 2.6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-west-3/mistral.mistral-large-2402-v1:0", + "name": "eu-west-3/mistral.mistral-large-2402-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.0000104, + "output_cost_per_token": 0.0000312, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-west-3/mistral.mixtral-8x7b-instruct-v0:1", + "name": "eu-west-3/mistral.mixtral-8x7b-instruct-v0:1", + "provider": "bedrock", + "input_cost_per_token": 5.9e-7, + "output_cost_per_token": 9.1e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-south-1/minimax.minimax-m2.1", + "name": "eu-south-1/minimax.minimax-m2.1", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-south-1/minimax.minimax-m2.5", + "name": "eu-south-1/minimax.minimax-m2.5", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/eu-south-1/qwen.qwen3-coder-next", + "name": "eu-south-1/qwen.qwen3-coder-next", + "provider": "bedrock", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/invoke/anthropic.claude-3-5-sonnet-20240620-v1:0", + "name": "invoke/anthropic.claude-3-5-sonnet-20240620-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/sa-east-1/meta.llama3-70b-instruct-v1:0", + "name": "sa-east-1/meta.llama3-70b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.00000445, + "output_cost_per_token": 0.00000588, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/sa-east-1/meta.llama3-8b-instruct-v1:0", + "name": "sa-east-1/meta.llama3-8b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.00000101, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/sa-east-1/deepseek.v3.2", + "name": "sa-east-1/deepseek.v3.2", + "provider": "bedrock", + "input_cost_per_token": 7.4e-7, + "output_cost_per_token": 0.00000222, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/sa-east-1/minimax.minimax-m2.1", + "name": "sa-east-1/minimax.minimax-m2.1", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/sa-east-1/minimax.minimax-m2.5", + "name": "sa-east-1/minimax.minimax-m2.5", + "provider": "bedrock", + "input_cost_per_token": 3.6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/sa-east-1/moonshotai.kimi-k2-thinking", + "name": "sa-east-1/moonshotai.kimi-k2-thinking", + "provider": "bedrock", + "input_cost_per_token": 7.3e-7, + "output_cost_per_token": 0.00000303, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/sa-east-1/moonshotai.kimi-k2.5", + "name": "sa-east-1/moonshotai.kimi-k2.5", + "provider": "bedrock", + "input_cost_per_token": 7.2e-7, + "output_cost_per_token": 0.0000036, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/sa-east-1/qwen.qwen3-coder-next", + "name": "sa-east-1/qwen.qwen3-coder-next", + "provider": "bedrock", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.00000144, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/anthropic.claude-instant-v1", + "name": "us-east-1/anthropic.claude-instant-v1", + "provider": "bedrock", + "input_cost_per_token": 8e-7, + "output_cost_per_token": 0.0000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/anthropic.claude-v1", + "name": "us-east-1/anthropic.claude-v1", + "provider": "bedrock", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/anthropic.claude-v2:1", + "name": "us-east-1/anthropic.claude-v2:1", + "provider": "bedrock", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/meta.llama3-70b-instruct-v1:0", + "name": "us-east-1/meta.llama3-70b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.00000265, + "output_cost_per_token": 0.0000035, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/meta.llama3-8b-instruct-v1:0", + "name": "us-east-1/meta.llama3-8b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/mistral.mistral-7b-instruct-v0:2", + "name": "us-east-1/mistral.mistral-7b-instruct-v0:2", + "provider": "bedrock", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/mistral.mistral-large-2402-v1:0", + "name": "us-east-1/mistral.mistral-large-2402-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/mistral.mixtral-8x7b-instruct-v0:1", + "name": "us-east-1/mistral.mixtral-8x7b-instruct-v0:1", + "provider": "bedrock", + "input_cost_per_token": 4.5e-7, + "output_cost_per_token": 7e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/deepseek.v3.2", + "name": "us-east-1/deepseek.v3.2", + "provider": "bedrock", + "input_cost_per_token": 6.2e-7, + "output_cost_per_token": 0.00000185, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/minimax.minimax-m2.1", + "name": "us-east-1/minimax.minimax-m2.1", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/minimax.minimax-m2.5", + "name": "us-east-1/minimax.minimax-m2.5", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/moonshotai.kimi-k2-thinking", + "name": "us-east-1/moonshotai.kimi-k2-thinking", + "provider": "bedrock", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/moonshotai.kimi-k2.5", + "name": "us-east-1/moonshotai.kimi-k2.5", + "provider": "bedrock", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/qwen.qwen3-coder-next", + "name": "us-east-1/qwen.qwen3-coder-next", + "provider": "bedrock", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-2/deepseek.v3.2", + "name": "us-east-2/deepseek.v3.2", + "provider": "bedrock", + "input_cost_per_token": 6.2e-7, + "output_cost_per_token": 0.00000185, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-2/minimax.minimax-m2.1", + "name": "us-east-2/minimax.minimax-m2.1", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-2/minimax.minimax-m2.5", + "name": "us-east-2/minimax.minimax-m2.5", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-2/moonshotai.kimi-k2-thinking", + "name": "us-east-2/moonshotai.kimi-k2-thinking", + "provider": "bedrock", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-2/moonshotai.kimi-k2.5", + "name": "us-east-2/moonshotai.kimi-k2.5", + "provider": "bedrock", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-2/qwen.qwen3-coder-next", + "name": "us-east-2/qwen.qwen3-coder-next", + "provider": "bedrock", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-east-1/amazon.nova-pro-v1:0", + "name": "us-gov-east-1/amazon.nova-pro-v1:0", + "provider": "bedrock", + "input_cost_per_token": 9.6e-7, + "output_cost_per_token": 0.00000384, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-east-1/amazon.titan-text-express-v1", + "name": "us-gov-east-1/amazon.titan-text-express-v1", + "provider": "bedrock", + "input_cost_per_token": 0.0000013, + "output_cost_per_token": 0.0000017, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-east-1/amazon.titan-text-lite-v1", + "name": "us-gov-east-1/amazon.titan-text-lite-v1", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-east-1/amazon.titan-text-premier-v1:0", + "name": "us-gov-east-1/amazon.titan-text-premier-v1:0", + "provider": "bedrock", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0", + "name": "us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.0000036, + "output_cost_per_token": 0.000018, + "cache_read_input_token_cost": 3.6e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0", + "name": "us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": 3e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0", + "name": "us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.0000036, + "output_cost_per_token": 0.000018, + "cache_read_input_token_cost": 3.6e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0", + "name": "us-gov-east-1/claude-sonnet-4-5-20250929-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.0000036, + "output_cost_per_token": 0.000018, + "cache_read_input_token_cost": 3.6e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0", + "name": "us-gov-east-1/meta.llama3-70b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.00000265, + "output_cost_per_token": 0.0000035, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-east-1/meta.llama3-8b-instruct-v1:0", + "name": "us-gov-east-1/meta.llama3-8b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.00000265, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-west-1/amazon.nova-pro-v1:0", + "name": "us-gov-west-1/amazon.nova-pro-v1:0", + "provider": "bedrock", + "input_cost_per_token": 9.6e-7, + "output_cost_per_token": 0.00000384, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-west-1/amazon.titan-text-express-v1", + "name": "us-gov-west-1/amazon.titan-text-express-v1", + "provider": "bedrock", + "input_cost_per_token": 0.0000013, + "output_cost_per_token": 0.0000017, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-west-1/amazon.titan-text-lite-v1", + "name": "us-gov-west-1/amazon.titan-text-lite-v1", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-west-1/amazon.titan-text-premier-v1:0", + "name": "us-gov-west-1/amazon.titan-text-premier-v1:0", + "provider": "bedrock", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0", + "name": "us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.0000036, + "output_cost_per_token": 0.000018, + "cache_read_input_token_cost": 3.6e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0", + "name": "us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.0000036, + "output_cost_per_token": 0.000018, + "cache_read_input_token_cost": 3.6e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0", + "name": "us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": 3e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0", + "name": "us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.0000036, + "output_cost_per_token": 0.000018, + "cache_read_input_token_cost": 3.6e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0", + "name": "us-gov-west-1/claude-sonnet-4-5-20250929-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.0000036, + "output_cost_per_token": 0.000018, + "cache_read_input_token_cost": 3.6e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0", + "name": "us-gov-west-1/meta.llama3-70b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.00000265, + "output_cost_per_token": 0.0000035, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-west-1/meta.llama3-8b-instruct-v1:0", + "name": "us-gov-west-1/meta.llama3-8b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.00000265, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-1/meta.llama3-70b-instruct-v1:0", + "name": "us-west-1/meta.llama3-70b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.00000265, + "output_cost_per_token": 0.0000035, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-1/meta.llama3-8b-instruct-v1:0", + "name": "us-west-1/meta.llama3-8b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-2/anthropic.claude-instant-v1", + "name": "us-west-2/anthropic.claude-instant-v1", + "provider": "bedrock", + "input_cost_per_token": 8e-7, + "output_cost_per_token": 0.0000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-2/anthropic.claude-v1", + "name": "us-west-2/anthropic.claude-v1", + "provider": "bedrock", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-2/anthropic.claude-v2:1", + "name": "us-west-2/anthropic.claude-v2:1", + "provider": "bedrock", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-2/mistral.mistral-7b-instruct-v0:2", + "name": "us-west-2/mistral.mistral-7b-instruct-v0:2", + "provider": "bedrock", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-2/mistral.mistral-large-2402-v1:0", + "name": "us-west-2/mistral.mistral-large-2402-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-2/mistral.mixtral-8x7b-instruct-v0:1", + "name": "us-west-2/mistral.mixtral-8x7b-instruct-v0:1", + "provider": "bedrock", + "input_cost_per_token": 4.5e-7, + "output_cost_per_token": 7e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-2/deepseek.v3.2", + "name": "us-west-2/deepseek.v3.2", + "provider": "bedrock", + "input_cost_per_token": 6.2e-7, + "output_cost_per_token": 0.00000185, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-2/minimax.minimax-m2.1", + "name": "us-west-2/minimax.minimax-m2.1", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-2/minimax.minimax-m2.5", + "name": "us-west-2/minimax.minimax-m2.5", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-2/moonshotai.kimi-k2-thinking", + "name": "us-west-2/moonshotai.kimi-k2-thinking", + "provider": "bedrock", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-2/moonshotai.kimi-k2.5", + "name": "us-west-2/moonshotai.kimi-k2.5", + "provider": "bedrock", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-2/qwen.qwen3-coder-next", + "name": "us-west-2/qwen.qwen3-coder-next", + "provider": "bedrock", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0", + "name": "us.anthropic.claude-3-5-haiku-20241022-v1:0", + "provider": "bedrock", + "input_cost_per_token": 8e-7, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": 8e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "chatgpt-4o-latest", + "name": "chatgpt-4o-latest", + "provider": "openai", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-haiku-4-5-20251001", + "name": "claude-haiku-4-5-20251001", + "provider": "anthropic", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "cache_read_input_token_cost": 1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-haiku-4-5", + "name": "claude-haiku-4-5", + "provider": "anthropic", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "cache_read_input_token_cost": 1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-3-7-sonnet-20250219", + "name": "claude-3-7-sonnet-20250219", + "provider": "anthropic", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-3-haiku-20240307", + "name": "claude-3-haiku-20240307", + "provider": "anthropic", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.00000125, + "cache_read_input_token_cost": 3e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-3-opus-20240229", + "name": "claude-3-opus-20240229", + "provider": "anthropic", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-4-opus-20250514", + "name": "claude-4-opus-20250514", + "provider": "anthropic", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-4-sonnet-20250514", + "name": "claude-4-sonnet-20250514", + "provider": "anthropic", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-sonnet-4-5", + "name": "claude-sonnet-4-5", + "provider": "anthropic", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-sonnet-4-5-20250929", + "name": "claude-sonnet-4-5-20250929", + "provider": "anthropic", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-sonnet-5", + "name": "claude-sonnet-5", + "provider": "anthropic", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-sonnet-4-6", + "name": "claude-sonnet-4-6", + "provider": "anthropic", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-sonnet-4-5-20250929-v1:0", + "name": "claude-sonnet-4-5-20250929-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-opus-4-1", + "name": "claude-opus-4-1", + "provider": "anthropic", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-opus-4-1-20250805", + "name": "claude-opus-4-1-20250805", + "provider": "anthropic", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-opus-4-20250514", + "name": "claude-opus-4-20250514", + "provider": "anthropic", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-opus-4-5-20251101", + "name": "claude-opus-4-5-20251101", + "provider": "anthropic", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-opus-4-5", + "name": "claude-opus-4-5", + "provider": "anthropic", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-opus-4-6", + "name": "claude-opus-4-6", + "provider": "anthropic", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-opus-4-6-20260205", + "name": "claude-opus-4-6-20260205", + "provider": "anthropic", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-opus-4-7", + "name": "claude-opus-4-7", + "provider": "anthropic", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-opus-4-7-20260416", + "name": "claude-opus-4-7-20260416", + "provider": "anthropic", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-fable-5", + "name": "claude-fable-5", + "provider": "anthropic", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00005, + "cache_read_input_token_cost": 0.000001, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-opus-5", + "name": "claude-opus-5", + "provider": "anthropic", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-opus-4-8", + "name": "claude-opus-4-8", + "provider": "anthropic", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "claude-sonnet-4-20250514", + "name": "claude-sonnet-4-20250514", + "provider": "anthropic", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "cohere.command-light-text-v14", + "name": "cohere.command-light-text-v14", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "cohere.command-r-plus-v1:0", + "name": "cohere.command-r-plus-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "cohere.command-r-v1:0", + "name": "cohere.command-r-v1:0", + "provider": "bedrock", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "cohere.command-text-v14", + "name": "cohere.command-text-v14", + "provider": "bedrock", + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "computer-use-preview", + "name": "computer-use-preview", + "provider": "azure", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "deepseek.v3-v1:0", + "name": "deepseek.v3-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 5.8e-7, + "output_cost_per_token": 0.00000168, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "deepseek.v3.2", + "name": "deepseek.v3.2", + "provider": "bedrock_converse", + "input_cost_per_token": 6.2e-7, + "output_cost_per_token": 0.00000185, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.amazon.nova-lite-v1:0", + "name": "eu.amazon.nova-lite-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 7.8e-8, + "output_cost_per_token": 3.12e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.amazon.nova-micro-v1:0", + "name": "eu.amazon.nova-micro-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 4.6e-8, + "output_cost_per_token": 1.84e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.amazon.nova-pro-v1:0", + "name": "eu.amazon.nova-pro-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.00000105, + "output_cost_per_token": 0.0000042, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-3-5-haiku-20241022-v1:0", + "name": "eu.anthropic.claude-3-5-haiku-20241022-v1:0", + "provider": "bedrock", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.00000125, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "name": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000055, + "cache_read_input_token_cost": 1.1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-3-5-sonnet-20240620-v1:0", + "name": "eu.anthropic.claude-3-5-sonnet-20240620-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-3-5-sonnet-20241022-v2:0", + "name": "eu.anthropic.claude-3-5-sonnet-20241022-v2:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-3-7-sonnet-20250219-v1:0", + "name": "eu.anthropic.claude-3-7-sonnet-20250219-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-3-haiku-20240307-v1:0", + "name": "eu.anthropic.claude-3-haiku-20240307-v1:0", + "provider": "bedrock", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.00000125, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-3-opus-20240229-v1:0", + "name": "eu.anthropic.claude-3-opus-20240229-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-3-sonnet-20240229-v1:0", + "name": "eu.anthropic.claude-3-sonnet-20240229-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-opus-4-1-20250805-v1:0", + "name": "eu.anthropic.claude-opus-4-1-20250805-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-opus-4-20250514-v1:0", + "name": "eu.anthropic.claude-opus-4-20250514-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-sonnet-4-20250514-v1:0", + "name": "eu.anthropic.claude-sonnet-4-20250514-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "name": "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000033, + "output_cost_per_token": 0.0000165, + "cache_read_input_token_cost": 3.3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.meta.llama3-2-1b-instruct-v1:0", + "name": "eu.meta.llama3-2-1b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 1.3e-7, + "output_cost_per_token": 1.3e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.meta.llama3-2-3b-instruct-v1:0", + "name": "eu.meta.llama3-2-3b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 1.9e-7, + "output_cost_per_token": 1.9e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.mistral.pixtral-large-2502-v1:0", + "name": "eu.mistral.pixtral-large-2502-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "ft:gpt-3.5-turbo", + "name": "ft:gpt-3.5-turbo", + "provider": "openai", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "ft:gpt-3.5-turbo-0125", + "name": "ft:gpt-3.5-turbo-0125", + "provider": "openai", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "ft:gpt-3.5-turbo-0613", + "name": "ft:gpt-3.5-turbo-0613", + "provider": "openai", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "ft:gpt-3.5-turbo-1106", + "name": "ft:gpt-3.5-turbo-1106", + "provider": "openai", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "ft:gpt-4-0613", + "name": "ft:gpt-4-0613", + "provider": "openai", + "input_cost_per_token": 0.00003, + "output_cost_per_token": 0.00006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "ft:gpt-4o-2024-08-06", + "name": "ft:gpt-4o-2024-08-06", + "provider": "openai", + "input_cost_per_token": 0.00000375, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 0.000001875, + "output_cost_per_reasoning_token": null + }, + { + "id": "ft:gpt-4o-2024-11-20", + "name": "ft:gpt-4o-2024-11-20", + "provider": "openai", + "input_cost_per_token": 0.00000375, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "ft:gpt-4o-mini-2024-07-18", + "name": "ft:gpt-4o-mini-2024-07-18", + "provider": "openai", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": 1.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "ft:gpt-4.1-2025-04-14", + "name": "ft:gpt-4.1-2025-04-14", + "provider": "openai", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 7.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "ft:gpt-4.1-mini-2025-04-14", + "name": "ft:gpt-4.1-mini-2025-04-14", + "provider": "openai", + "input_cost_per_token": 8e-7, + "output_cost_per_token": 0.0000032, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "ft:gpt-4.1-nano-2025-04-14", + "name": "ft:gpt-4.1-nano-2025-04-14", + "provider": "openai", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 8e-7, + "cache_read_input_token_cost": 5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "ft:o4-mini-2025-04-16", + "name": "ft:o4-mini-2025-04-16", + "provider": "openai", + "input_cost_per_token": 0.000004, + "output_cost_per_token": 0.000016, + "cache_read_input_token_cost": 0.000001, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-2.0-flash", + "name": "gemini-2.0-flash", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-2.0-flash-001", + "name": "gemini-2.0-flash-001", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": 3.75e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-2.0-flash-lite", + "name": "gemini-2.0-flash-lite", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 7.5e-8, + "output_cost_per_token": 3e-7, + "cache_read_input_token_cost": 1.875e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-2.0-flash-lite-001", + "name": "gemini-2.0-flash-lite-001", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 7.5e-8, + "output_cost_per_token": 3e-7, + "cache_read_input_token_cost": 1.875e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-2.5-flash", + "name": "gemini-2.5-flash", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 3e-8, + "output_cost_per_reasoning_token": 0.0000025 + }, + { + "id": "gemini-3.1-flash-lite-preview", + "name": "gemini-3.1-flash-lite-preview", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": 0.0000015 + }, + { + "id": "gemini-3.1-flash-lite", + "name": "gemini-3.1-flash-lite", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": 0.0000015 + }, + { + "id": "gemini-3.5-flash-lite", + "name": "gemini-3.5-flash-lite", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 3e-8, + "output_cost_per_reasoning_token": 0.0000025 + }, + { + "id": "gemini-2.5-flash-lite", + "name": "gemini-2.5-flash-lite", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 1e-8, + "output_cost_per_reasoning_token": 4e-7 + }, + { + "id": "gemini-2.5-flash-lite-preview-09-2025", + "name": "gemini-2.5-flash-lite-preview-09-2025", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 1e-8, + "output_cost_per_reasoning_token": 4e-7 + }, + { + "id": "gemini-2.5-flash-preview-09-2025", + "name": "gemini-2.5-flash-preview-09-2025", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": 0.0000025 + }, + { + "id": "gemini-2.5-flash-lite-preview-06-17", + "name": "gemini-2.5-flash-lite-preview-06-17", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": 4e-7 + }, + { + "id": "gemini-2.5-pro", + "name": "gemini-2.5-pro", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-3-pro-preview", + "name": "gemini-3-pro-preview", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-3.1-pro-preview", + "name": "gemini-3.1-pro-preview", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-3.1-pro-preview-customtools", + "name": "gemini-3.1-pro-preview-customtools", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/gemini-3-pro-preview", + "name": "gemini-3-pro-preview", + "provider": "vertex_ai", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/gemini-3-flash-preview", + "name": "gemini-3-flash-preview", + "provider": "vertex_ai", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": 5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/gemini-3.5-flash", + "name": "gemini-3.5-flash", + "provider": "vertex_ai", + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000009, + "cache_read_input_token_cost": 1.5e-7, + "output_cost_per_reasoning_token": 0.000009 + }, + { + "id": "vertex_ai/gemini-3.6-flash", + "name": "gemini-3.6-flash", + "provider": "vertex_ai", + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.0000075, + "cache_read_input_token_cost": 1.5e-7, + "output_cost_per_reasoning_token": 0.0000075 + }, + { + "id": "vertex_ai/gemini-3.1-pro-preview", + "name": "gemini-3.1-pro-preview", + "provider": "vertex_ai", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/gemini-3.1-pro-preview-customtools", + "name": "gemini-3.1-pro-preview-customtools", + "provider": "vertex_ai", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-2.5-pro-preview-tts", + "name": "gemini-2.5-pro-preview-tts", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-robotics-er-1.5-preview", + "name": "gemini-robotics-er-1.5-preview", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 0, + "output_cost_per_reasoning_token": 0.0000025 + }, + { + "id": "gemini/gemini-robotics-er-1.5-preview", + "name": "gemini-robotics-er-1.5-preview", + "provider": "gemini", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 0, + "output_cost_per_reasoning_token": 0.0000025 + }, + { + "id": "gemini/gemini-robotics-er-2-preview", + "name": "gemini-robotics-er-2-preview", + "provider": "gemini", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": 0.00001 + }, + { + "id": "gemini/gemini-robotics-er-1.6-preview", + "name": "gemini-robotics-er-1.6-preview", + "provider": "gemini", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": 0.000005 + }, + { + "id": "gemini-2.5-computer-use-preview-10-2025", + "name": "gemini-2.5-computer-use-preview-10-2025", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-2.0-flash", + "name": "gemini-2.0-flash", + "provider": "gemini", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-2.0-flash-001", + "name": "gemini-2.0-flash-001", + "provider": "gemini", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-2.0-flash-lite", + "name": "gemini-2.0-flash-lite", + "provider": "gemini", + "input_cost_per_token": 7.5e-8, + "output_cost_per_token": 3e-7, + "cache_read_input_token_cost": 1.875e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-2.5-flash", + "name": "gemini-2.5-flash", + "provider": "gemini", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 3e-8, + "output_cost_per_reasoning_token": 0.0000025 + }, + { + "id": "gemini/gemini-2.5-flash-lite", + "name": "gemini-2.5-flash-lite", + "provider": "gemini", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 1e-8, + "output_cost_per_reasoning_token": 4e-7 + }, + { + "id": "gemini/gemini-2.5-flash-lite-preview-09-2025", + "name": "gemini-2.5-flash-lite-preview-09-2025", + "provider": "gemini", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 1e-8, + "output_cost_per_reasoning_token": 4e-7 + }, + { + "id": "gemini/gemini-2.5-flash-preview-09-2025", + "name": "gemini-2.5-flash-preview-09-2025", + "provider": "gemini", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": 0.0000025 + }, + { + "id": "gemini/gemini-flash-latest", + "name": "gemini-flash-latest", + "provider": "gemini", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": 0.0000025 + }, + { + "id": "gemini/gemini-flash-lite-latest", + "name": "gemini-flash-lite-latest", + "provider": "gemini", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": 4e-7 + }, + { + "id": "gemini/gemini-2.5-flash-lite-preview-06-17", + "name": "gemini-2.5-flash-lite-preview-06-17", + "provider": "gemini", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": 4e-7 + }, + { + "id": "gemini/gemini-2.5-pro", + "name": "gemini-2.5-pro", + "provider": "gemini", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-2.5-computer-use-preview-10-2025", + "name": "gemini-2.5-computer-use-preview-10-2025", + "provider": "gemini", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-3-pro-preview", + "name": "gemini-3-pro-preview", + "provider": "gemini", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-3.1-flash-lite-preview", + "name": "gemini-3.1-flash-lite-preview", + "provider": "gemini", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": 0.0000015 + }, + { + "id": "gemini/gemini-3.1-flash-lite", + "name": "gemini-3.1-flash-lite", + "provider": "gemini", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": 0.0000015 + }, + { + "id": "gemini/gemini-3.5-flash-lite", + "name": "gemini-3.5-flash-lite", + "provider": "gemini", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 3e-8, + "output_cost_per_reasoning_token": 0.0000025 + }, + { + "id": "gemini/gemini-3-flash-preview", + "name": "gemini-3-flash-preview", + "provider": "gemini", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": 5e-8, + "output_cost_per_reasoning_token": 0.000003 + }, + { + "id": "gemini/gemini-3.5-flash", + "name": "gemini-3.5-flash", + "provider": "gemini", + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000009, + "cache_read_input_token_cost": 1.5e-7, + "output_cost_per_reasoning_token": 0.000009 + }, + { + "id": "gemini/gemini-3.6-flash", + "name": "gemini-3.6-flash", + "provider": "gemini", + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.0000075, + "cache_read_input_token_cost": 1.5e-7, + "output_cost_per_reasoning_token": 0.0000075 + }, + { + "id": "gemini/gemini-omni-flash-preview", + "name": "gemini-omni-flash-preview", + "provider": "gemini", + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000009, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": 0.000009 + }, + { + "id": "gemini/gemini-3.1-pro-preview", + "name": "gemini-3.1-pro-preview", + "provider": "gemini", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-3.1-pro-preview-customtools", + "name": "gemini-3.1-pro-preview-customtools", + "provider": "gemini", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-3-flash-preview", + "name": "gemini-3-flash-preview", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": 5e-8, + "output_cost_per_reasoning_token": 0.000003 + }, + { + "id": "gemini-omni-flash-preview", + "name": "gemini-omni-flash-preview", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000009, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": 0.000009 + }, + { + "id": "gemini-3.5-flash", + "name": "gemini-3.5-flash", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000009, + "cache_read_input_token_cost": 1.5e-7, + "output_cost_per_reasoning_token": 0.000009 + }, + { + "id": "gemini-3.6-flash", + "name": "gemini-3.6-flash", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.0000075, + "cache_read_input_token_cost": 1.5e-7, + "output_cost_per_reasoning_token": 0.0000075 + }, + { + "id": "gemini/gemini-2.5-pro-preview-tts", + "name": "gemini-2.5-pro-preview-tts", + "provider": "gemini", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-exp-1114", + "name": "gemini-exp-1114", + "provider": "gemini", + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-exp-1206", + "name": "gemini-exp-1206", + "provider": "gemini", + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-gemma-2-27b-it", + "name": "gemini-gemma-2-27b-it", + "provider": "gemini", + "input_cost_per_token": 3.5e-7, + "output_cost_per_token": 0.00000105, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-gemma-2-9b-it", + "name": "gemini-gemma-2-9b-it", + "provider": "gemini", + "input_cost_per_token": 3.5e-7, + "output_cost_per_token": 0.00000105, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemma-3-27b-it", + "name": "gemma-3-27b-it", + "provider": "gemini", + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/learnlm-1.5-pro-experimental", + "name": "learnlm-1.5-pro-experimental", + "provider": "gemini", + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/lyria-3-clip-preview", + "name": "lyria-3-clip-preview", + "provider": "gemini", + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/lyria-3-pro-preview", + "name": "lyria-3-pro-preview", + "provider": "gemini", + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "google.gemma-3-12b-it", + "name": "google.gemma-3-12b-it", + "provider": "bedrock_converse", + "input_cost_per_token": 9e-8, + "output_cost_per_token": 2.9e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "google.gemma-3-27b-it", + "name": "google.gemma-3-27b-it", + "provider": "bedrock_converse", + "input_cost_per_token": 2.3e-7, + "output_cost_per_token": 3.8e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "google.gemma-3-4b-it", + "name": "google.gemma-3-4b-it", + "provider": "bedrock_converse", + "input_cost_per_token": 4e-8, + "output_cost_per_token": 8e-8, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "global.anthropic.claude-sonnet-4-5-20250929-v1:0", + "name": "global.anthropic.claude-sonnet-4-5-20250929-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "global.anthropic.claude-sonnet-4-20250514-v1:0", + "name": "global.anthropic.claude-sonnet-4-20250514-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "global.anthropic.claude-haiku-4-5-20251001-v1:0", + "name": "global.anthropic.claude-haiku-4-5-20251001-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "cache_read_input_token_cost": 1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "global.amazon.nova-2-lite-v1:0", + "name": "global.amazon.nova-2-lite-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-3.5-turbo", + "name": "gpt-3.5-turbo", + "provider": "openai", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-3.5-turbo-0125", + "name": "gpt-3.5-turbo-0125", + "provider": "openai", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-3.5-turbo-1106", + "name": "gpt-3.5-turbo-1106", + "provider": "openai", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-3.5-turbo-16k", + "name": "gpt-3.5-turbo-16k", + "provider": "openai", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4", + "name": "gpt-4", + "provider": "openai", + "input_cost_per_token": 0.00003, + "output_cost_per_token": 0.00006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4-0125-preview", + "name": "gpt-4-0125-preview", + "provider": "openai", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4-0314", + "name": "gpt-4-0314", + "provider": "openai", + "input_cost_per_token": 0.00003, + "output_cost_per_token": 0.00006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4-0613", + "name": "gpt-4-0613", + "provider": "openai", + "input_cost_per_token": 0.00003, + "output_cost_per_token": 0.00006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4-1106-preview", + "name": "gpt-4-1106-preview", + "provider": "openai", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4-turbo", + "name": "gpt-4-turbo", + "provider": "openai", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4-turbo-2024-04-09", + "name": "gpt-4-turbo-2024-04-09", + "provider": "openai", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4-turbo-preview", + "name": "gpt-4-turbo-preview", + "provider": "openai", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4.1", + "name": "gpt-4.1", + "provider": "openai", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000008, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4.1-2025-04-14", + "name": "gpt-4.1-2025-04-14", + "provider": "openai", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000008, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4.1-mini", + "name": "gpt-4.1-mini", + "provider": "openai", + "input_cost_per_token": 4e-7, + "output_cost_per_token": 0.0000016, + "cache_read_input_token_cost": 1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4.1-mini-2025-04-14", + "name": "gpt-4.1-mini-2025-04-14", + "provider": "openai", + "input_cost_per_token": 4e-7, + "output_cost_per_token": 0.0000016, + "cache_read_input_token_cost": 1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4.1-nano", + "name": "gpt-4.1-nano", + "provider": "openai", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4.1-nano-2025-04-14", + "name": "gpt-4.1-nano-2025-04-14", + "provider": "openai", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o", + "name": "gpt-4o", + "provider": "openai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 0.00000125, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o-2024-05-13", + "name": "gpt-4o-2024-05-13", + "provider": "openai", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o-2024-08-06", + "name": "gpt-4o-2024-08-06", + "provider": "openai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 0.00000125, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o-2024-11-20", + "name": "gpt-4o-2024-11-20", + "provider": "openai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 0.00000125, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o-audio-preview", + "name": "gpt-4o-audio-preview", + "provider": "openai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o-audio-preview-2024-12-17", + "name": "gpt-4o-audio-preview-2024-12-17", + "provider": "openai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o-audio-preview-2025-06-03", + "name": "gpt-4o-audio-preview-2025-06-03", + "provider": "openai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-audio", + "name": "gpt-audio", + "provider": "openai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-audio-1.5", + "name": "gpt-audio-1.5", + "provider": "openai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-audio-2025-08-28", + "name": "gpt-audio-2025-08-28", + "provider": "openai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-audio-mini", + "name": "gpt-audio-mini", + "provider": "openai", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-audio-mini-2025-10-06", + "name": "gpt-audio-mini-2025-10-06", + "provider": "openai", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-audio-mini-2025-12-15", + "name": "gpt-audio-mini-2025-12-15", + "provider": "openai", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o-mini", + "name": "gpt-4o-mini", + "provider": "openai", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o-mini-2024-07-18", + "name": "gpt-4o-mini-2024-07-18", + "provider": "openai", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o-mini-audio-preview", + "name": "gpt-4o-mini-audio-preview", + "provider": "openai", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o-mini-audio-preview-2024-12-17", + "name": "gpt-4o-mini-audio-preview-2024-12-17", + "provider": "openai", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o-mini-search-preview", + "name": "gpt-4o-mini-search-preview", + "provider": "openai", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o-mini-search-preview-2025-03-11", + "name": "gpt-4o-mini-search-preview-2025-03-11", + "provider": "openai", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o-search-preview", + "name": "gpt-4o-search-preview", + "provider": "openai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 0.00000125, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-4o-search-preview-2025-03-11", + "name": "gpt-4o-search-preview-2025-03-11", + "provider": "openai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 0.00000125, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5", + "name": "gpt-5", + "provider": "openai", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.1", + "name": "gpt-5.1", + "provider": "openai", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.1-2025-11-13", + "name": "gpt-5.1-2025-11-13", + "provider": "openai", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.1-chat-latest", + "name": "gpt-5.1-chat-latest", + "provider": "openai", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.2", + "name": "gpt-5.2", + "provider": "openai", + "input_cost_per_token": 0.00000175, + "output_cost_per_token": 0.000014, + "cache_read_input_token_cost": 1.75e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.2-2025-12-11", + "name": "gpt-5.2-2025-12-11", + "provider": "openai", + "input_cost_per_token": 0.00000175, + "output_cost_per_token": 0.000014, + "cache_read_input_token_cost": 1.75e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.2-chat-latest", + "name": "gpt-5.2-chat-latest", + "provider": "openai", + "input_cost_per_token": 0.00000175, + "output_cost_per_token": 0.000014, + "cache_read_input_token_cost": 1.75e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.3-chat-latest", + "name": "gpt-5.3-chat-latest", + "provider": "openai", + "input_cost_per_token": 0.00000175, + "output_cost_per_token": 0.000014, + "cache_read_input_token_cost": 1.75e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.6", + "name": "gpt-5.6", + "provider": "openai", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.6-sol", + "name": "gpt-5.6-sol", + "provider": "openai", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.6-terra", + "name": "gpt-5.6-terra", + "provider": "openai", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000012, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.6-luna", + "name": "gpt-5.6-luna", + "provider": "openai", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": 2e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.5", + "name": "gpt-5.5", + "provider": "openai", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.5-2026-04-23", + "name": "gpt-5.5-2026-04-23", + "provider": "openai", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.00003, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.4", + "name": "gpt-5.4", + "provider": "openai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 2.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.4-2026-03-05", + "name": "gpt-5.4-2026-03-05", + "provider": "openai", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 2.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.4-mini", + "name": "gpt-5.4-mini", + "provider": "openai", + "input_cost_per_token": 7.5e-7, + "output_cost_per_token": 0.0000045, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.4-mini-2026-03-17", + "name": "gpt-5.4-mini-2026-03-17", + "provider": "openai", + "input_cost_per_token": 7.5e-7, + "output_cost_per_token": 0.0000045, + "cache_read_input_token_cost": 7.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.4-nano", + "name": "gpt-5.4-nano", + "provider": "openai", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 0.00000125, + "cache_read_input_token_cost": 2e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5.4-nano-2026-03-17", + "name": "gpt-5.4-nano-2026-03-17", + "provider": "openai", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 0.00000125, + "cache_read_input_token_cost": 2e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5-2025-08-07", + "name": "gpt-5-2025-08-07", + "provider": "openai", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5-chat", + "name": "gpt-5-chat", + "provider": "openai", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5-chat-latest", + "name": "gpt-5-chat-latest", + "provider": "openai", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5-mini", + "name": "gpt-5-mini", + "provider": "openai", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5-mini-2025-08-07", + "name": "gpt-5-mini-2025-08-07", + "provider": "openai", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5-nano", + "name": "gpt-5-nano", + "provider": "openai", + "input_cost_per_token": 5e-8, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 5e-9, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5-nano-2025-08-07", + "name": "gpt-5-nano-2025-08-07", + "provider": "openai", + "input_cost_per_token": 5e-8, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 5e-9, + "output_cost_per_reasoning_token": null + }, + { + "id": "jp.anthropic.claude-sonnet-4-5-20250929-v1:0", + "name": "jp.anthropic.claude-sonnet-4-5-20250929-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000033, + "output_cost_per_token": 0.0000165, + "cache_read_input_token_cost": 3.3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "jp.anthropic.claude-haiku-4-5-20251001-v1:0", + "name": "jp.anthropic.claude-haiku-4-5-20251001-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000055, + "cache_read_input_token_cost": 1.1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "meta.llama2-13b-chat-v1", + "name": "meta.llama2-13b-chat-v1", + "provider": "bedrock", + "input_cost_per_token": 7.5e-7, + "output_cost_per_token": 0.000001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "meta.llama2-70b-chat-v1", + "name": "meta.llama2-70b-chat-v1", + "provider": "bedrock", + "input_cost_per_token": 0.00000195, + "output_cost_per_token": 0.00000256, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "meta.llama3-1-405b-instruct-v1:0", + "name": "meta.llama3-1-405b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.00000532, + "output_cost_per_token": 0.000016, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "meta.llama3-1-70b-instruct-v1:0", + "name": "meta.llama3-1-70b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 9.9e-7, + "output_cost_per_token": 9.9e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "meta.llama3-1-8b-instruct-v1:0", + "name": "meta.llama3-1-8b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 2.2e-7, + "output_cost_per_token": 2.2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "meta.llama3-2-11b-instruct-v1:0", + "name": "meta.llama3-2-11b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 3.5e-7, + "output_cost_per_token": 3.5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "meta.llama3-2-1b-instruct-v1:0", + "name": "meta.llama3-2-1b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 1e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "meta.llama3-2-3b-instruct-v1:0", + "name": "meta.llama3-2-3b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 1.5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "meta.llama3-2-90b-instruct-v1:0", + "name": "meta.llama3-2-90b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "meta.llama3-3-70b-instruct-v1:0", + "name": "meta.llama3-3-70b-instruct-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 7.2e-7, + "output_cost_per_token": 7.2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "meta.llama3-70b-instruct-v1:0", + "name": "meta.llama3-70b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.00000265, + "output_cost_per_token": 0.0000035, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "meta.llama3-8b-instruct-v1:0", + "name": "meta.llama3-8b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "meta.llama4-maverick-17b-instruct-v1:0", + "name": "meta.llama4-maverick-17b-instruct-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 2.4e-7, + "output_cost_per_token": 9.7e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "meta.llama4-scout-17b-instruct-v1:0", + "name": "meta.llama4-scout-17b-instruct-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 1.7e-7, + "output_cost_per_token": 6.6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "minimax.minimax-m2", + "name": "minimax.minimax-m2", + "provider": "bedrock_converse", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "minimax.minimax-m2.1", + "name": "minimax.minimax-m2.1", + "provider": "bedrock_converse", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "minimax.minimax-m2.5", + "name": "minimax.minimax-m2.5", + "provider": "bedrock_converse", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "mistral.devstral-2-123b", + "name": "mistral.devstral-2-123b", + "provider": "bedrock_converse", + "input_cost_per_token": 4e-7, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "mistral.magistral-small-2509", + "name": "mistral.magistral-small-2509", + "provider": "bedrock_converse", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "mistral.ministral-3-14b-instruct", + "name": "mistral.ministral-3-14b-instruct", + "provider": "bedrock_converse", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "mistral.ministral-3-3b-instruct", + "name": "mistral.ministral-3-3b-instruct", + "provider": "bedrock_converse", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 1e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "mistral.ministral-3-8b-instruct", + "name": "mistral.ministral-3-8b-instruct", + "provider": "bedrock_converse", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 1.5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "mistral.mistral-7b-instruct-v0:2", + "name": "mistral.mistral-7b-instruct-v0:2", + "provider": "bedrock", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "mistral.mistral-large-2402-v1:0", + "name": "mistral.mistral-large-2402-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000008, + "output_cost_per_token": 0.000024, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "mistral.mistral-large-2407-v1:0", + "name": "mistral.mistral-large-2407-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000009, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "mistral.mistral-large-3-675b-instruct", + "name": "mistral.mistral-large-3-675b-instruct", + "provider": "bedrock_converse", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "mistral.mistral-small-2402-v1:0", + "name": "mistral.mistral-small-2402-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "mistral.mixtral-8x7b-instruct-v0:1", + "name": "mistral.mixtral-8x7b-instruct-v0:1", + "provider": "bedrock", + "input_cost_per_token": 4.5e-7, + "output_cost_per_token": 7e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "mistral.voxtral-mini-3b-2507", + "name": "mistral.voxtral-mini-3b-2507", + "provider": "bedrock_converse", + "input_cost_per_token": 4e-8, + "output_cost_per_token": 4e-8, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "mistral.voxtral-small-24b-2507", + "name": "mistral.voxtral-small-24b-2507", + "provider": "bedrock_converse", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 3e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "moonshot.kimi-k2-thinking", + "name": "moonshot.kimi-k2-thinking", + "provider": "bedrock_converse", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "moonshotai.kimi-k2.5", + "name": "moonshotai.kimi-k2.5", + "provider": "bedrock_converse", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "nvidia.nemotron-nano-12b-v2", + "name": "nvidia.nemotron-nano-12b-v2", + "provider": "bedrock_converse", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "nvidia.nemotron-nano-9b-v2", + "name": "nvidia.nemotron-nano-9b-v2", + "provider": "bedrock_converse", + "input_cost_per_token": 6e-8, + "output_cost_per_token": 2.3e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "nvidia.nemotron-nano-3-30b", + "name": "nvidia.nemotron-nano-3-30b", + "provider": "bedrock_converse", + "input_cost_per_token": 6e-8, + "output_cost_per_token": 2.4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "nvidia.nemotron-super-3-120b", + "name": "nvidia.nemotron-super-3-120b", + "provider": "bedrock_converse", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6.5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "o1", + "name": "o1", + "provider": "openai", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.00006, + "cache_read_input_token_cost": 0.0000075, + "output_cost_per_reasoning_token": null + }, + { + "id": "o1-2024-12-17", + "name": "o1-2024-12-17", + "provider": "openai", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.00006, + "cache_read_input_token_cost": 0.0000075, + "output_cost_per_reasoning_token": null + }, + { + "id": "o3", + "name": "o3", + "provider": "openai", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000008, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "o3-2025-04-16", + "name": "o3-2025-04-16", + "provider": "openai", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000008, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "o3-mini", + "name": "o3-mini", + "provider": "openai", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000044, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "o3-mini-2025-01-31", + "name": "o3-mini-2025-01-31", + "provider": "openai", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000044, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "o4-mini", + "name": "o4-mini", + "provider": "openai", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000044, + "cache_read_input_token_cost": 2.75e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "o4-mini-2025-04-16", + "name": "o4-mini-2025-04-16", + "provider": "openai", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000044, + "cache_read_input_token_cost": 2.75e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "openai.gpt-oss-120b-1:0", + "name": "openai.gpt-oss-120b-1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "openai.gpt-oss-20b-1:0", + "name": "openai.gpt-oss-20b-1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 7e-8, + "output_cost_per_token": 3e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "openai.gpt-oss-safeguard-120b", + "name": "openai.gpt-oss-safeguard-120b", + "provider": "bedrock_converse", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "openai.gpt-oss-safeguard-20b", + "name": "openai.gpt-oss-safeguard-20b", + "provider": "bedrock_converse", + "input_cost_per_token": 7e-8, + "output_cost_per_token": 2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "qwen.qwen3-coder-480b-a35b-v1:0", + "name": "qwen.qwen3-coder-480b-a35b-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 2.2e-7, + "output_cost_per_token": 0.0000018, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "qwen.qwen3-235b-a22b-2507-v1:0", + "name": "qwen.qwen3-235b-a22b-2507-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 2.2e-7, + "output_cost_per_token": 8.8e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "qwen.qwen3-coder-30b-a3b-v1:0", + "name": "qwen.qwen3-coder-30b-a3b-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "qwen.qwen3-32b-v1:0", + "name": "qwen.qwen3-32b-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "qwen.qwen3-next-80b-a3b", + "name": "qwen.qwen3-next-80b-a3b", + "provider": "bedrock_converse", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "qwen.qwen3-vl-235b-a22b", + "name": "qwen.qwen3-vl-235b-a22b", + "provider": "bedrock_converse", + "input_cost_per_token": 5.3e-7, + "output_cost_per_token": 0.00000266, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "qwen.qwen3-coder-next", + "name": "qwen.qwen3-coder-next", + "provider": "bedrock_converse", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.amazon.nova-lite-v1:0", + "name": "us.amazon.nova-lite-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 6e-8, + "output_cost_per_token": 2.4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.amazon.nova-micro-v1:0", + "name": "us.amazon.nova-micro-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 3.5e-8, + "output_cost_per_token": 1.4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.amazon.nova-premier-v1:0", + "name": "us.amazon.nova-premier-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.0000125, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.amazon.nova-pro-v1:0", + "name": "us.amazon.nova-pro-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 8e-7, + "output_cost_per_token": 0.0000032, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-3-5-haiku-20241022-v1:0", + "name": "us.anthropic.claude-3-5-haiku-20241022-v1:0", + "provider": "bedrock", + "input_cost_per_token": 8e-7, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": 8e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "name": "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000055, + "cache_read_input_token_cost": 1.1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-3-5-sonnet-20240620-v1:0", + "name": "us.anthropic.claude-3-5-sonnet-20240620-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-3-5-sonnet-20241022-v2:0", + "name": "us.anthropic.claude-3-5-sonnet-20241022-v2:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-3-7-sonnet-20250219-v1:0", + "name": "us.anthropic.claude-3-7-sonnet-20250219-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-3-haiku-20240307-v1:0", + "name": "us.anthropic.claude-3-haiku-20240307-v1:0", + "provider": "bedrock", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.00000125, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-3-opus-20240229-v1:0", + "name": "us.anthropic.claude-3-opus-20240229-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-3-sonnet-20240229-v1:0", + "name": "us.anthropic.claude-3-sonnet-20240229-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-opus-4-1-20250805-v1:0", + "name": "us.anthropic.claude-opus-4-1-20250805-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "name": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000033, + "output_cost_per_token": 0.0000165, + "cache_read_input_token_cost": 3.3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0", + "name": "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000036, + "output_cost_per_token": 0.000018, + "cache_read_input_token_cost": 3.6e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "au.anthropic.claude-haiku-4-5-20251001-v1:0", + "name": "au.anthropic.claude-haiku-4-5-20251001-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000011, + "output_cost_per_token": 0.0000055, + "cache_read_input_token_cost": 1.1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-opus-4-20250514-v1:0", + "name": "us.anthropic.claude-opus-4-20250514-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-opus-4-5-20251101-v1:0", + "name": "us.anthropic.claude-opus-4-5-20251101-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.0000055, + "output_cost_per_token": 0.0000275, + "cache_read_input_token_cost": 5.5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "global.anthropic.claude-opus-4-5-20251101-v1:0", + "name": "global.anthropic.claude-opus-4-5-20251101-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.anthropic.claude-opus-4-5-20251101-v1:0", + "name": "eu.anthropic.claude-opus-4-5-20251101-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.anthropic.claude-sonnet-4-20250514-v1:0", + "name": "us.anthropic.claude-sonnet-4-20250514-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.deepseek.r1-v1:0", + "name": "us.deepseek.r1-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.00000135, + "output_cost_per_token": 0.0000054, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.deepseek.v3.2", + "name": "us.deepseek.v3.2", + "provider": "bedrock_converse", + "input_cost_per_token": 6.2e-7, + "output_cost_per_token": 0.00000185, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "eu.deepseek.v3.2", + "name": "eu.deepseek.v3.2", + "provider": "bedrock_converse", + "input_cost_per_token": 7.4e-7, + "output_cost_per_token": 0.00000222, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.meta.llama3-1-405b-instruct-v1:0", + "name": "us.meta.llama3-1-405b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.00000532, + "output_cost_per_token": 0.000016, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.meta.llama3-1-70b-instruct-v1:0", + "name": "us.meta.llama3-1-70b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 9.9e-7, + "output_cost_per_token": 9.9e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.meta.llama3-1-8b-instruct-v1:0", + "name": "us.meta.llama3-1-8b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 2.2e-7, + "output_cost_per_token": 2.2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.meta.llama3-2-11b-instruct-v1:0", + "name": "us.meta.llama3-2-11b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 3.5e-7, + "output_cost_per_token": 3.5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.meta.llama3-2-1b-instruct-v1:0", + "name": "us.meta.llama3-2-1b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 1e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.meta.llama3-2-3b-instruct-v1:0", + "name": "us.meta.llama3-2-3b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 1.5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.meta.llama3-2-90b-instruct-v1:0", + "name": "us.meta.llama3-2-90b-instruct-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.meta.llama3-3-70b-instruct-v1:0", + "name": "us.meta.llama3-3-70b-instruct-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 7.2e-7, + "output_cost_per_token": 7.2e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.meta.llama4-maverick-17b-instruct-v1:0", + "name": "us.meta.llama4-maverick-17b-instruct-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 2.4e-7, + "output_cost_per_token": 9.7e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.meta.llama4-scout-17b-instruct-v1:0", + "name": "us.meta.llama4-scout-17b-instruct-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 1.7e-7, + "output_cost_per_token": 6.6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "us.mistral.pixtral-large-2502-v1:0", + "name": "us.mistral.pixtral-large-2502-v1:0", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-3-5-haiku", + "name": "claude-3-5-haiku", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-3-5-haiku@20241022", + "name": "claude-3-5-haiku@20241022", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-haiku-4-5", + "name": "claude-haiku-4-5", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "cache_read_input_token_cost": 1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-haiku-4-5@20251001", + "name": "claude-haiku-4-5@20251001", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "cache_read_input_token_cost": 1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-3-5-sonnet", + "name": "claude-3-5-sonnet", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-3-5-sonnet@20240620", + "name": "claude-3-5-sonnet@20240620", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-3-7-sonnet@20250219", + "name": "claude-3-7-sonnet@20250219", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-3-haiku", + "name": "claude-3-haiku", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.00000125, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-3-haiku@20240307", + "name": "claude-3-haiku@20240307", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.00000125, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-3-opus", + "name": "claude-3-opus", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-3-opus@20240229", + "name": "claude-3-opus@20240229", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-3-sonnet", + "name": "claude-3-sonnet", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-3-sonnet@20240229", + "name": "claude-3-sonnet@20240229", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-opus-4", + "name": "claude-opus-4", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-opus-4-1", + "name": "claude-opus-4-1", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-opus-4-1@20250805", + "name": "claude-opus-4-1@20250805", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-opus-4-5", + "name": "claude-opus-4-5", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-opus-4-5@20251101", + "name": "claude-opus-4-5@20251101", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-opus-4-6", + "name": "claude-opus-4-6", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-opus-4-6@default", + "name": "claude-opus-4-6@default", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-opus-4-7", + "name": "claude-opus-4-7", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-opus-4-7@default", + "name": "claude-opus-4-7@default", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-fable-5", + "name": "claude-fable-5", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00005, + "cache_read_input_token_cost": 0.000001, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-fable-5@default", + "name": "claude-fable-5@default", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00005, + "cache_read_input_token_cost": 0.000001, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-opus-5", + "name": "claude-opus-5", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-opus-5@default", + "name": "claude-opus-5@default", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-opus-4-8", + "name": "claude-opus-4-8", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-opus-4-8@default", + "name": "claude-opus-4-8@default", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-sonnet-4-5", + "name": "claude-sonnet-4-5", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-sonnet-5", + "name": "claude-sonnet-5", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-sonnet-4-6", + "name": "claude-sonnet-4-6", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-sonnet-4-5@20250929", + "name": "claude-sonnet-4-5@20250929", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-opus-4@20250514", + "name": "claude-opus-4@20250514", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "cache_read_input_token_cost": 0.0000015, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-sonnet-4", + "name": "claude-sonnet-4", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-sonnet-4@20250514", + "name": "claude-sonnet-4@20250514", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/mistralai/codestral-2@001", + "name": "mistralai/codestral-2@001", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 9e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/codestral-2", + "name": "codestral-2", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 9e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/codestral-2@001", + "name": "codestral-2@001", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 9e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/mistralai/codestral-2", + "name": "mistralai/codestral-2", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 9e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/codestral-2501", + "name": "codestral-2501", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/codestral@2405", + "name": "codestral@2405", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/codestral@latest", + "name": "codestral@latest", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/deepseek-ai/deepseek-v3.1-maas", + "name": "deepseek-ai/deepseek-v3.1-maas", + "provider": "vertex_ai-deepseek_models", + "input_cost_per_token": 0.00000135, + "output_cost_per_token": 0.0000054, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/deepseek-ai/deepseek-v3.2-maas", + "name": "deepseek-ai/deepseek-v3.2-maas", + "provider": "vertex_ai-deepseek_models", + "input_cost_per_token": 5.6e-7, + "output_cost_per_token": 0.00000168, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/deepseek-ai/deepseek-r1-0528-maas", + "name": "deepseek-ai/deepseek-r1-0528-maas", + "provider": "vertex_ai-deepseek_models", + "input_cost_per_token": 0.00000135, + "output_cost_per_token": 0.0000054, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/gemini-3.1-flash-lite-preview", + "name": "gemini-3.1-flash-lite-preview", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": 0.0000015 + }, + { + "id": "vertex_ai/gemini-3.1-flash-lite", + "name": "gemini-3.1-flash-lite", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.0000015, + "cache_read_input_token_cost": 2.5e-8, + "output_cost_per_reasoning_token": 0.0000015 + }, + { + "id": "vertex_ai/gemini-3.5-flash-lite", + "name": "gemini-3.5-flash-lite", + "provider": "vertex_ai-language-models", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 3e-8, + "output_cost_per_reasoning_token": 0.0000025 + }, + { + "id": "vertex_ai/jamba-1.5", + "name": "jamba-1.5", + "provider": "vertex_ai-ai21_models", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/jamba-1.5-large", + "name": "jamba-1.5-large", + "provider": "vertex_ai-ai21_models", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000008, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/jamba-1.5-large@001", + "name": "jamba-1.5-large@001", + "provider": "vertex_ai-ai21_models", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000008, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/jamba-1.5-mini", + "name": "jamba-1.5-mini", + "provider": "vertex_ai-ai21_models", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/jamba-1.5-mini@001", + "name": "jamba-1.5-mini@001", + "provider": "vertex_ai-ai21_models", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/meta/llama-3.1-405b-instruct-maas", + "name": "meta/llama-3.1-405b-instruct-maas", + "provider": "vertex_ai-llama_models", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000016, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/meta/llama-3.1-70b-instruct-maas", + "name": "meta/llama-3.1-70b-instruct-maas", + "provider": "vertex_ai-llama_models", + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/meta/llama-3.1-8b-instruct-maas", + "name": "meta/llama-3.1-8b-instruct-maas", + "provider": "vertex_ai-llama_models", + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/meta/llama-3.2-90b-vision-instruct-maas", + "name": "meta/llama-3.2-90b-vision-instruct-maas", + "provider": "vertex_ai-llama_models", + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/meta/llama-4-maverick-17b-128e-instruct-maas", + "name": "meta/llama-4-maverick-17b-128e-instruct-maas", + "provider": "vertex_ai-llama_models", + "input_cost_per_token": 3.5e-7, + "output_cost_per_token": 0.00000115, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/meta/llama-4-maverick-17b-16e-instruct-maas", + "name": "meta/llama-4-maverick-17b-16e-instruct-maas", + "provider": "vertex_ai-llama_models", + "input_cost_per_token": 3.5e-7, + "output_cost_per_token": 0.00000115, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/meta/llama-4-scout-17b-128e-instruct-maas", + "name": "meta/llama-4-scout-17b-128e-instruct-maas", + "provider": "vertex_ai-llama_models", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 7e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/meta/llama-4-scout-17b-16e-instruct-maas", + "name": "meta/llama-4-scout-17b-16e-instruct-maas", + "provider": "vertex_ai-llama_models", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 7e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/meta/llama3-405b-instruct-maas", + "name": "meta/llama3-405b-instruct-maas", + "provider": "vertex_ai-llama_models", + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/meta/llama3-70b-instruct-maas", + "name": "meta/llama3-70b-instruct-maas", + "provider": "vertex_ai-llama_models", + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/meta/llama3-8b-instruct-maas", + "name": "meta/llama3-8b-instruct-maas", + "provider": "vertex_ai-llama_models", + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/minimaxai/minimax-m2-maas", + "name": "minimaxai/minimax-m2-maas", + "provider": "vertex_ai-minimax_models", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/moonshotai/kimi-k2-thinking-maas", + "name": "moonshotai/kimi-k2-thinking-maas", + "provider": "vertex_ai-moonshot_models", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/zai-org/glm-4.7-maas", + "name": "zai-org/glm-4.7-maas", + "provider": "vertex_ai-zai_models", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000022, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/zai-org/glm-5-maas", + "name": "zai-org/glm-5-maas", + "provider": "vertex_ai-zai_models", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.0000032, + "cache_read_input_token_cost": 1e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/mistral-medium-3", + "name": "mistral-medium-3", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 4e-7, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/mistral-medium-3@001", + "name": "mistral-medium-3@001", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 4e-7, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/mistralai/mistral-medium-3", + "name": "mistralai/mistral-medium-3", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 4e-7, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/mistralai/mistral-medium-3@001", + "name": "mistralai/mistral-medium-3@001", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 4e-7, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/mistral-large-2411", + "name": "mistral-large-2411", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/mistral-large@2407", + "name": "mistral-large@2407", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/mistral-large@2411-001", + "name": "mistral-large@2411-001", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/mistral-large@latest", + "name": "mistral-large@latest", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/mistral-nemo@2407", + "name": "mistral-nemo@2407", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/mistral-nemo@latest", + "name": "mistral-nemo@latest", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 1.5e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/mistral-small-2503", + "name": "mistral-small-2503", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/mistral-small-2503@001", + "name": "mistral-small-2503@001", + "provider": "vertex_ai-mistral_models", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000003, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/google/gemma-4-26b-a4b-it-maas", + "name": "google/gemma-4-26b-a4b-it-maas", + "provider": "vertex_ai-openai_models", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/openai/gpt-oss-120b-maas", + "name": "openai/gpt-oss-120b-maas", + "provider": "vertex_ai-openai_models", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/openai/gpt-oss-20b-maas", + "name": "openai/gpt-oss-20b-maas", + "provider": "vertex_ai-openai_models", + "input_cost_per_token": 7.5e-8, + "output_cost_per_token": 3e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/xai/grok-4.1-fast-non-reasoning", + "name": "xai/grok-4.1-fast-non-reasoning", + "provider": "vertex_ai", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 5e-7, + "cache_read_input_token_cost": 5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/xai/grok-4.1-fast-reasoning", + "name": "xai/grok-4.1-fast-reasoning", + "provider": "vertex_ai", + "input_cost_per_token": 2e-7, + "output_cost_per_token": 5e-7, + "cache_read_input_token_cost": 5e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/xai/grok-4.20-non-reasoning", + "name": "xai/grok-4.20-non-reasoning", + "provider": "vertex_ai", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/xai/grok-4.20-reasoning", + "name": "xai/grok-4.20-reasoning", + "provider": "vertex_ai", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/qwen/qwen3-235b-a22b-instruct-2507-maas", + "name": "qwen/qwen3-235b-a22b-instruct-2507-maas", + "provider": "vertex_ai-qwen_models", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 0.000001, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas", + "name": "qwen/qwen3-coder-480b-a35b-instruct-maas", + "provider": "vertex_ai-qwen_models", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas", + "name": "qwen/qwen3-next-80b-a3b-instruct-maas", + "provider": "vertex_ai-qwen_models", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas", + "name": "qwen/qwen3-next-80b-a3b-thinking-maas", + "provider": "vertex_ai-qwen_models", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 0.0000012, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "zai.glm-4.7", + "name": "zai.glm-4.7", + "provider": "bedrock_converse", + "input_cost_per_token": 6e-7, + "output_cost_per_token": 0.0000022, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "zai.glm-5", + "name": "zai.glm-5", + "provider": "bedrock_converse", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.0000032, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "zai.glm-4.7-flash", + "name": "zai.glm-4.7-flash", + "provider": "bedrock_converse", + "input_cost_per_token": 7e-8, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5-search-api", + "name": "gpt-5-search-api", + "provider": "openai", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gpt-5-search-api-2025-10-14", + "name": "gpt-5-search-api-2025-10-14", + "provider": "openai", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-2.0-flash-lite-001", + "name": "gemini-2.0-flash-lite-001", + "provider": "gemini", + "input_cost_per_token": 7.5e-8, + "output_cost_per_token": 3e-7, + "cache_read_input_token_cost": 1.875e-8, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-2.5-flash-native-audio-latest", + "name": "gemini-2.5-flash-native-audio-latest", + "provider": "gemini", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-2.5-flash-native-audio-preview-09-2025", + "name": "gemini-2.5-flash-native-audio-preview-09-2025", + "provider": "gemini", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-2.5-flash-native-audio-preview-12-2025", + "name": "gemini-2.5-flash-native-audio-preview-12-2025", + "provider": "gemini", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-3.1-flash-live-preview", + "name": "gemini-3.1-flash-live-preview", + "provider": "gemini", + "input_cost_per_token": 7.5e-7, + "output_cost_per_token": 0.0000045, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-2.5-flash-native-audio-latest", + "name": "gemini-2.5-flash-native-audio-latest", + "provider": "gemini", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-2.5-flash-native-audio-preview-09-2025", + "name": "gemini-2.5-flash-native-audio-preview-09-2025", + "provider": "gemini", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-2.5-flash-native-audio-preview-12-2025", + "name": "gemini-2.5-flash-native-audio-preview-12-2025", + "provider": "gemini", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-3.1-flash-live-preview", + "name": "gemini-3.1-flash-live-preview", + "provider": "gemini", + "input_cost_per_token": 7.5e-7, + "output_cost_per_token": 0.0000045, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-flash-latest", + "name": "gemini-flash-latest", + "provider": "gemini", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 3e-8, + "output_cost_per_reasoning_token": 0.0000025 + }, + { + "id": "gemini-flash-lite-latest", + "name": "gemini-flash-lite-latest", + "provider": "gemini", + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": 1e-8, + "output_cost_per_reasoning_token": 4e-7 + }, + { + "id": "gemini-pro-latest", + "name": "gemini-pro-latest", + "provider": "gemini", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini/gemini-pro-latest", + "name": "gemini-pro-latest", + "provider": "gemini", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 1.25e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "gemini-exp-1206", + "name": "gemini-exp-1206", + "provider": "gemini", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 3e-8, + "output_cost_per_reasoning_token": 0.0000025 + }, + { + "id": "vertex_ai/claude-sonnet-5@default", + "name": "claude-sonnet-5@default", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "vertex_ai/claude-sonnet-4-6@default", + "name": "claude-sonnet-4-6@default", + "provider": "vertex_ai-anthropic_models", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 3e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock_mantle/openai.gpt-oss-120b", + "name": "openai.gpt-oss-120b", + "provider": "bedrock_mantle", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock_mantle/openai.gpt-oss-20b", + "name": "openai.gpt-oss-20b", + "provider": "bedrock_mantle", + "input_cost_per_token": 7.5e-8, + "output_cost_per_token": 3e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock_mantle/openai.gpt-oss-safeguard-120b", + "name": "openai.gpt-oss-safeguard-120b", + "provider": "bedrock_mantle", + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock_mantle/openai.gpt-oss-safeguard-20b", + "name": "openai.gpt-oss-safeguard-20b", + "provider": "bedrock_mantle", + "input_cost_per_token": 7.5e-8, + "output_cost_per_token": 3e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock_mantle/google.gemma-4-31b", + "name": "google.gemma-4-31b", + "provider": "bedrock_mantle", + "input_cost_per_token": 1.4e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock_mantle/google.gemma-4-26b-a4b", + "name": "google.gemma-4-26b-a4b", + "provider": "bedrock_mantle", + "input_cost_per_token": 1.3e-7, + "output_cost_per_token": 4e-7, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock_mantle/google.gemma-4-e2b", + "name": "google.gemma-4-e2b", + "provider": "bedrock_mantle", + "input_cost_per_token": 4e-8, + "output_cost_per_token": 8e-8, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock_mantle/xai.grok-4.3", + "name": "xai.grok-4.3", + "provider": "bedrock_mantle", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.0000025, + "cache_read_input_token_cost": 2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-east-1/zai.glm-5", + "name": "us-east-1/zai.glm-5", + "provider": "bedrock", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.0000032, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-west-2/zai.glm-5", + "name": "us-west-2/zai.glm-5", + "provider": "bedrock", + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.0000032, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0", + "name": "us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.0000012, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": 1.2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "bedrock/us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0", + "name": "us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0", + "provider": "bedrock", + "input_cost_per_token": 0.0000012, + "output_cost_per_token": 0.000006, + "cache_read_input_token_cost": 1.2e-7, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-3.5-turbo-instruct-0914", + "name": "gpt-3.5-turbo-instruct-0914", + "provider": "azure_text", + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-35-turbo-instruct", + "name": "gpt-35-turbo-instruct", + "provider": "azure_text", + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "azure/gpt-35-turbo-instruct-0914", + "name": "gpt-35-turbo-instruct-0914", + "provider": "azure_text", + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "text-unicorn", + "name": "text-unicorn", + "provider": "vertex_ai-text-models", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.000028, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + }, + { + "id": "text-unicorn@001", + "name": "text-unicorn@001", + "provider": "vertex_ai-text-models", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.000028, + "cache_read_input_token_cost": null, + "output_cost_per_reasoning_token": null + } + ], + "success": true, + "error": false, + "message": "Model catalog retrieved successfully", + "status": 200 +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 975d49ed8..507268725 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,7 @@ "express": "5.2.1", "express-rate-limit": "8.5.2", "glob": "13.0.6", + "gpt-tokenizer": "^3.4.0", "helmet": "8.2.0", "http-errors": "2.0.1", "input-otp": "1.4.2", @@ -12760,6 +12761,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gpt-tokenizer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-3.4.0.tgz", + "integrity": "sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==", + "license": "MIT" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", diff --git a/package.json b/package.json index 058314d9f..e94b2fda3 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,9 @@ }, "dependencies": { "@asteasolutions/zod-to-openapi": "8.5.0", + "@aws-sdk/client-s3": "3.1056.0", "@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", "@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz", - "@aws-sdk/client-s3": "3.1056.0", "@headlessui/react": "2.2.10", "@hookform/resolvers": "5.4.0", "@monaco-editor/react": "4.7.0", @@ -88,6 +88,7 @@ "express": "5.2.1", "express-rate-limit": "8.5.2", "glob": "13.0.6", + "gpt-tokenizer": "^3.4.0", "helmet": "8.2.0", "http-errors": "2.0.1", "input-otp": "1.4.2", diff --git a/server/lib/aiModelPricing.ts b/server/lib/aiModelPricing.ts new file mode 100644 index 000000000..3edf4e9f4 --- /dev/null +++ b/server/lib/aiModelPricing.ts @@ -0,0 +1,230 @@ +import fs from "node:fs"; +import path from "node:path"; +import { APP_PATH } from "@server/lib/consts"; +import type { AiProviderType } from "@server/lib/aiProviderDefaults"; +import type { AiUsage } from "@server/lib/aiUsageExtraction"; +import logger from "@server/logger"; + +// config/models.json is a runtime asset (same category as config.yml or the +// MaxMind DBs) - not part of the source tree. Its shape mirrors litellm's +// public model_prices_and_context_window.json: a flat list of +// { id, name, provider, input_cost_per_token, output_cost_per_token, +// cache_read_input_token_cost, output_cost_per_reasoning_token }, where +// `provider` is litellm's provider bucket, not our AiProviderType. +const MODELS_JSON_PATH = path.join(APP_PATH, "models.json"); + +export type AiModelPricingEntry = { + id: string; + name: string; + provider: string; + input_cost_per_token: number | null; + output_cost_per_token: number | null; + cache_read_input_token_cost: number | null; + output_cost_per_reasoning_token: number | null; +}; + +export type AiModelPricing = { + inputCostPerToken: number | null; + outputCostPerToken: number | null; + cacheReadInputTokenCost: number | null; + outputCostPerReasoningToken: number | null; + // True when the match came from a different provider bucket than the one + // mapped to this provider's type (e.g. an openRouter/custom model id that + // only matched by stripping a "vendor/" prefix against the whole table). + // Costs found this way are a best-effort approximation, not a guarantee + // the upstream provider bills at the same rate. + approximate: boolean; +}; + +// Which litellm provider buckets to search for each of our provider types. +// Several of our provider types (openRouter, vercelAiGateway, custom) proxy +// arbitrary underlying models and have no dedicated bucket in the pricing +// data, so they fall back to a global search across all buckets. +const PROVIDER_PRICING_BUCKETS: Record< + Exclude, + string[] +> = { + openai: ["openai"], + anthropic: ["anthropic"], + googleGemini: ["gemini"], + vertexAi: [ + "vertex_ai-language-models", + "vertex_ai", + "vertex_ai-anthropic_models", + "vertex_ai-mistral_models", + "vertex_ai-deepseek_models", + "vertex_ai-ai21_models", + "vertex_ai-llama_models", + "vertex_ai-minimax_models", + "vertex_ai-moonshot_models", + "vertex_ai-zai_models", + "vertex_ai-openai_models", + "vertex_ai-qwen_models", + "vertex_ai-text-models" + ], + bedrock: ["bedrock_converse", "bedrock", "bedrock_mantle"], + microsoftFoundry: ["azure", "azure_ai", "azure_text"], + openRouter: [], + vercelAiGateway: [] +}; + +let modelsById: Map | null = null; + +function loadModels(): Map { + if (modelsById) { + return modelsById; + } + + const byId = new Map(); + try { + if (fs.existsSync(MODELS_JSON_PATH)) { + const raw = fs.readFileSync(MODELS_JSON_PATH, "utf-8"); + const parsed = JSON.parse(raw) as { data: AiModelPricingEntry[] }; + for (const entry of parsed.data ?? []) { + for (const key of [entry.id, entry.name]) { + if (!key) continue; + const list = byId.get(key) ?? []; + list.push(entry); + byId.set(key, list); + } + } + } else { + logger.debug( + `AI model pricing file not found at ${MODELS_JSON_PATH}; cost calculation will fall back to unknown pricing` + ); + } + } catch (error) { + logger.warn("Failed to load AI model pricing file", { error }); + } + + modelsById = byId; + return byId; +} + +function stripVendorPrefix(modelId: string): string | null { + const idx = modelId.indexOf("/"); + if (idx === -1 || idx === modelId.length - 1) { + return null; + } + return modelId.slice(idx + 1); +} + +function toPricing( + entry: AiModelPricingEntry, + approximate: boolean +): AiModelPricing { + return { + inputCostPerToken: entry.input_cost_per_token, + outputCostPerToken: entry.output_cost_per_token, + cacheReadInputTokenCost: entry.cache_read_input_token_cost, + outputCostPerReasoningToken: entry.output_cost_per_reasoning_token, + approximate + }; +} + +function findInBuckets( + byId: Map, + modelId: string, + buckets: string[] | null +): AiModelPricingEntry | null { + const candidates = [modelId, stripVendorPrefix(modelId)].filter( + (v): v is string => v != null + ); + + for (const key of candidates) { + const entries = byId.get(key); + if (!entries) continue; + const match = buckets + ? entries.find((e) => buckets.includes(e.provider)) + : entries[0]; + if (match) { + return match; + } + } + return null; +} + +/** + * Looks up per-token pricing for a model, scoped first to the litellm + * provider bucket(s) that correspond to our provider type, then falling + * back to a global search across all buckets (marked `approximate`) for + * provider types that proxy arbitrary underlying models. + */ +export function getModelPricing( + providerType: AiProviderType, + modelId: string | undefined +): AiModelPricing | null { + if (!modelId) { + return null; + } + + const byId = loadModels(); + const buckets = + providerType === "custom" + ? [] + : PROVIDER_PRICING_BUCKETS[providerType]; + + if (buckets && buckets.length > 0) { + const scoped = findInBuckets(byId, modelId, buckets); + if (scoped) { + return toPricing(scoped, false); + } + } + + const fallback = findInBuckets(byId, modelId, null); + if (fallback) { + return toPricing(fallback, true); + } + + return null; +} + +export type AiCostBreakdown = { + promptCost: number; + cacheReadCost: number; + cacheWriteCost: number; + completionCost: number; + reasoningCost: number; + totalCost: number; +}; + +/** + * Computes a $ cost breakdown for a usage record given a model's pricing. + * Cache writes and reasoning tokens fall back to the normal input/output + * rate respectively when the pricing data has no dedicated rate for them + * (the models.json schema here has no cache-write field at all, and only + * some models report a distinct reasoning rate). + */ +export function calculateAiCost( + pricing: AiModelPricing | null, + usage: AiUsage +): AiCostBreakdown | null { + if (!pricing) { + return null; + } + + const inputRate = pricing.inputCostPerToken ?? 0; + const outputRate = pricing.outputCostPerToken ?? 0; + const cacheReadRate = pricing.cacheReadInputTokenCost ?? inputRate; + const reasoningRate = pricing.outputCostPerReasoningToken ?? outputRate; + + const promptCost = usage.promptTokens * inputRate; + const cacheReadCost = usage.cacheReadTokens * cacheReadRate; + const cacheWriteCost = usage.cacheWriteTokens * inputRate; + const completionCost = usage.completionTokens * outputRate; + const reasoningCost = usage.reasoningTokens * reasoningRate; + + return { + promptCost, + cacheReadCost, + cacheWriteCost, + completionCost, + reasoningCost, + totalCost: + promptCost + + cacheReadCost + + cacheWriteCost + + completionCost + + reasoningCost + }; +} diff --git a/server/lib/aiUsageExtraction.ts b/server/lib/aiUsageExtraction.ts new file mode 100644 index 000000000..f3fc92580 --- /dev/null +++ b/server/lib/aiUsageExtraction.ts @@ -0,0 +1,468 @@ +import { encode } from "gpt-tokenizer"; +import type { AiCapability } from "@server/lib/aiCapabilities"; +import logger from "@server/logger"; + +export type AiUsage = { + // Input tokens billed at the normal input rate (i.e. NOT already + // covered by cacheReadTokens/cacheWriteTokens below). + promptTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + // Output tokens billed at the normal output rate (i.e. NOT already + // covered by reasoningTokens below). + completionTokens: number; + reasoningTokens: number; + // True when these numbers are our own best-guess estimate (the upstream + // response didn't report usage), rather than provider-reported figures. + estimated: boolean; +}; + +function emptyUsage(): AiUsage { + return { + promptTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + completionTokens: 0, + reasoningTokens: 0, + estimated: false + }; +} + +/** + * Scans raw (possibly binary-framed, e.g. Bedrock's vnd.amazon.eventstream) + * text for `"fieldName":123` occurrences and returns the last value seen for + * each field. Used as a best-effort fallback for response shapes we can't + * fully parse as JSON/SSE (streaming Bedrock, raw predict passthroughs). + */ +function scanNumericFields( + text: string, + fields: string[] +): Record { + const out: Record = {}; + for (const field of fields) { + const re = new RegExp(`"${field}"\\s*:\\s*(\\d+)`, "g"); + let match: RegExpExecArray | null; + while ((match = re.exec(text)) !== null) { + out[field] = Number(match[1]); + } + } + return out; +} + +function sseDataFrames(text: string): string[] { + const frames: string[] = []; + for (const rawFrame of text.split(/\r?\n\r?\n/)) { + for (const line of rawFrame.split(/\r?\n/)) { + if (!line.startsWith("data:")) continue; + const data = line.slice("data:".length).trim(); + if (data && data !== "[DONE]") { + frames.push(data); + } + } + } + return frames; +} + +function tryParseJson(text: string): any | null { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function extractOpenAiChat(text: string, isStream: boolean): AiUsage | null { + let usage: any = null; + + if (isStream) { + for (const frame of sseDataFrames(text)) { + const parsed = tryParseJson(frame); + if (parsed?.usage) { + usage = parsed.usage; + } + } + } else { + usage = tryParseJson(text)?.usage ?? null; + } + + if (!usage) { + return null; + } + + const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0; + const reasoningTokens = + usage.completion_tokens_details?.reasoning_tokens ?? 0; + + return { + promptTokens: Math.max(0, (usage.prompt_tokens ?? 0) - cacheReadTokens), + cacheReadTokens, + cacheWriteTokens: 0, + completionTokens: Math.max( + 0, + (usage.completion_tokens ?? 0) - reasoningTokens + ), + reasoningTokens, + estimated: false + }; +} + +function extractOpenAiResponses( + text: string, + isStream: boolean +): AiUsage | null { + let usage: any = null; + + if (isStream) { + for (const frame of sseDataFrames(text)) { + const parsed = tryParseJson(frame); + if (parsed?.type === "response.completed" && parsed?.response?.usage) { + usage = parsed.response.usage; + } else if (parsed?.usage) { + usage = parsed.usage; + } + } + } else { + const parsed = tryParseJson(text); + usage = parsed?.usage ?? parsed?.response?.usage ?? null; + } + + if (!usage) { + return null; + } + + const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? 0; + const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? 0; + + return { + promptTokens: Math.max(0, (usage.input_tokens ?? 0) - cacheReadTokens), + cacheReadTokens, + cacheWriteTokens: 0, + completionTokens: Math.max( + 0, + (usage.output_tokens ?? 0) - reasoningTokens + ), + reasoningTokens, + estimated: false + }; +} + +function extractAnthropicMessages( + text: string, + isStream: boolean +): AiUsage | null { + let inputTokens = 0; + let cacheReadTokens = 0; + let cacheWriteTokens = 0; + let outputTokens = 0; + let found = false; + + const applyUsage = (usage: any) => { + if (!usage) return; + found = true; + if (typeof usage.input_tokens === "number") { + inputTokens = usage.input_tokens; + } + if (typeof usage.cache_read_input_tokens === "number") { + cacheReadTokens = usage.cache_read_input_tokens; + } + if (typeof usage.cache_creation_input_tokens === "number") { + cacheWriteTokens = usage.cache_creation_input_tokens; + } + if (typeof usage.output_tokens === "number") { + outputTokens = usage.output_tokens; + } + }; + + if (isStream) { + for (const frame of sseDataFrames(text)) { + const parsed = tryParseJson(frame); + if (!parsed) continue; + applyUsage(parsed.message?.usage); + applyUsage(parsed.usage); + } + } else { + applyUsage(tryParseJson(text)?.usage); + } + + if (!found) { + return null; + } + + return { + promptTokens: inputTokens, + cacheReadTokens, + cacheWriteTokens, + completionTokens: outputTokens, + // Anthropic bills extended-thinking output at the normal output + // rate, so there's no separate reasoning bucket to report. + reasoningTokens: 0, + estimated: false + }; +} + +function extractGoogleGenerateContent( + text: string, + _isStream: boolean +): AiUsage | null { + // Both the plain-JSON-array stream format and the SSE (?alt=sse) format + // repeat a cumulative `usageMetadata` object per chunk; the regex scan + // below naturally picks up the last (most complete) one either way. + const fields = scanNumericFields(text, [ + "promptTokenCount", + "candidatesTokenCount", + "cachedContentTokenCount", + "thoughtsTokenCount" + ]); + + if (fields.promptTokenCount === undefined) { + return null; + } + + const cacheReadTokens = fields.cachedContentTokenCount ?? 0; + const reasoningTokens = fields.thoughtsTokenCount ?? 0; + + return { + promptTokens: Math.max(0, fields.promptTokenCount - cacheReadTokens), + cacheReadTokens, + cacheWriteTokens: 0, + completionTokens: fields.candidatesTokenCount ?? 0, + reasoningTokens, + estimated: false + }; +} + +function extractBedrockConverse( + text: string, + _isStream: boolean +): AiUsage | null { + // Non-streaming responses are plain JSON; converse-stream frames the + // final `metadata` event's usage object inside binary event-stream + // framing, but the JSON text survives intact inside that binary + // envelope, so the same field scan works for both. + const parsed = tryParseJson(text); + const usage = parsed?.usage; + if (usage) { + const cacheReadTokens = usage.cacheReadInputTokens ?? 0; + return { + promptTokens: Math.max(0, (usage.inputTokens ?? 0) - cacheReadTokens), + cacheReadTokens, + cacheWriteTokens: usage.cacheWriteInputTokens ?? 0, + completionTokens: usage.outputTokens ?? 0, + reasoningTokens: 0, + estimated: false + }; + } + + const fields = scanNumericFields(text, [ + "inputTokens", + "outputTokens", + "cacheReadInputTokens", + "cacheWriteInputTokens" + ]); + if (fields.inputTokens === undefined) { + return null; + } + const cacheReadTokens = fields.cacheReadInputTokens ?? 0; + return { + promptTokens: Math.max(0, fields.inputTokens - cacheReadTokens), + cacheReadTokens, + cacheWriteTokens: fields.cacheWriteInputTokens ?? 0, + completionTokens: fields.outputTokens ?? 0, + reasoningTokens: 0, + estimated: false + }; +} + +function extractBedrockModelInvoke( + text: string, + _isStream: boolean, + headers: Headers +): AiUsage | null { + // Non-streaming invoke reports counts via response headers regardless + // of the underlying model's payload format. + const headerInput = headers.get("x-amzn-bedrock-input-token-count"); + const headerOutput = headers.get("x-amzn-bedrock-output-token-count"); + if (headerInput !== null || headerOutput !== null) { + return { + promptTokens: Number(headerInput ?? 0), + cacheReadTokens: 0, + cacheWriteTokens: 0, + completionTokens: Number(headerOutput ?? 0), + reasoningTokens: 0, + estimated: false + }; + } + + // invoke-with-response-stream has no equivalent headers; the model's + // own usage shape (frequently Anthropic-style on Bedrock) is embedded + // inside binary event-stream framing, so fall back to a couple of + // known field-name shapes via regex. + const anthropicStyle = extractAnthropicMessages(text, true); + if (anthropicStyle) { + return anthropicStyle; + } + + const fields = scanNumericFields(text, [ + "inputTokenCount", + "outputTokenCount" + ]); + if (fields.inputTokenCount === undefined) { + return null; + } + return { + promptTokens: fields.inputTokenCount, + cacheReadTokens: 0, + cacheWriteTokens: 0, + completionTokens: fields.outputTokenCount ?? 0, + reasoningTokens: 0, + estimated: false + }; +} + +const EXTRACTORS: Record< + AiCapability, + (text: string, isStream: boolean, headers: Headers) => AiUsage | null +> = { + openai_chat: extractOpenAiChat, + openai_responses: extractOpenAiResponses, + anthropic_messages: extractAnthropicMessages, + gemini_generate_content: extractGoogleGenerateContent, + google_generate_content: extractGoogleGenerateContent, + // rawPredict is a passthrough to whatever the underlying publisher + // model speaks (often Anthropic-shaped on Vertex); try that, then give + // up to the token-count estimate. + google_raw_predict: (text, isStream) => + extractAnthropicMessages(text, isStream), + bedrock_model_invoke: extractBedrockModelInvoke, + bedrock_converse: extractBedrockConverse +}; + +/** + * Attempts to pull provider-reported token usage out of an upstream AI + * gateway response. Returns null if the response didn't contain (or we + * couldn't find) usage data, in which case callers should fall back to + * `estimateUsage`. + */ +export function extractUsage( + capability: AiCapability, + responseText: string, + isStream: boolean, + headers: Headers +): AiUsage | null { + try { + return EXTRACTORS[capability](responseText, isStream, headers); + } catch (error) { + logger.debug("Failed to extract AI usage from response", { + capability, + error + }); + return null; + } +} + +/** + * Best-guess token estimate for when the provider doesn't report usage. + * Uses OpenAI's BPE tokenizer as a stand-in for whatever tokenizer the + * actual model uses - close enough for an approximate cost figure, not + * exact for non-OpenAI models. + */ +export function estimateUsage( + promptText: string, + completionText: string +): AiUsage { + const usage = emptyUsage(); + usage.estimated = true; + try { + usage.promptTokens = promptText ? encode(promptText).length : 0; + } catch (error) { + logger.debug("Failed to estimate prompt tokens", { error }); + } + try { + usage.completionTokens = completionText + ? encode(completionText).length + : 0; + } catch (error) { + logger.debug("Failed to estimate completion tokens", { error }); + } + return usage; +} + +/** + * OpenAI's Chat Completions API only includes a `usage` field in a + * streaming response when the request opts in via `stream_options: + * {include_usage: true}` - unlike the Responses API, Anthropic, Gemini and + * Bedrock, which report usage in a streaming response by default. Returns + * whether we need to inject that option ourselves to be able to track cost. + */ +export function needsStreamUsageInjection( + capability: AiCapability, + body: any +): boolean { + return ( + capability === "openai_chat" && + body?.stream === true && + body?.stream_options?.include_usage !== true + ); +} + +/** + * Returns a shallow-cloned body with `stream_options.include_usage` + * injected, for capabilities/requests where `needsStreamUsageInjection` + * is true. Leaves the original body untouched. + */ +export function withStreamUsageOption(body: any): any { + return { + ...body, + stream_options: { ...body.stream_options, include_usage: true } + }; +} + +/** + * When we injected stream_options.include_usage ourselves (the caller + * didn't ask for it), OpenAI appends an extra terminal SSE frame with an + * empty `choices: []` array carrying only the usage data. Callers that + * don't expect that shape (most minimal SSE parsers assume a non-empty + * choices array) shouldn't see it, so it's stripped back out of the bytes + * forwarded to the client. + */ +export function stripInjectedUsageFrame(sseText: string): string { + const parts = sseText.split(/(\r?\n\r?\n)/); + let out = ""; + for (let i = 0; i < parts.length; i += 2) { + const frame = parts[i]; + const separator = parts[i + 1] ?? ""; + const dataLine = frame + .split(/\r?\n/) + .find((line) => line.startsWith("data:")); + if (dataLine) { + const data = dataLine.slice("data:".length).trim(); + const parsed = data !== "[DONE]" ? tryParseJson(data) : null; + if (parsed && Array.isArray(parsed.choices) && parsed.choices.length === 0 && parsed.usage) { + continue; + } + } + out += frame + separator; + } + return out; +} + +/** + * Best-effort extraction of the model the upstream provider actually + * served, which some gateways/routers echo back and which may differ from + * the model the caller requested (e.g. an alias resolving to a dated + * snapshot). Falls back to the caller's requested model when absent. + */ +export function extractResponseModel(responseText: string): string | null { + const match = responseText.match(/"model"\s*:\s*"([^"]+)"/); + return match ? match[1] : null; +} + +export function isUsageEmpty(usage: AiUsage): boolean { + return ( + usage.promptTokens === 0 && + usage.cacheReadTokens === 0 && + usage.cacheWriteTokens === 0 && + usage.completionTokens === 0 && + usage.reasoningTokens === 0 + ); +} diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index d881b27ca..dbc3c22be 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -19,6 +19,7 @@ import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { AiProviderAuthType, + AiProviderType, applyAiProviderAuthHeaders, applyAiProviderCustomHeaders, authTypeRequiresApiKey @@ -49,6 +50,17 @@ import { mostSpecificMatchingAllow } from "@server/lib/aiModelKeyMatch"; import { aiGatewayUpstreamFetch } from "@server/lib/aiGatewayUpstreamFetch"; +import { getModelPricing, calculateAiCost } from "@server/lib/aiModelPricing"; +import { + extractUsage, + estimateUsage, + isUsageEmpty, + needsStreamUsageInjection, + withStreamUsageOption, + stripInjectedUsageFrame, + extractResponseModel, + type AiUsage +} from "@server/lib/aiUsageExtraction"; // Short-lived local caches so a burst of requests from the same IP/user // doesn't hit the database on every single request. None of this is @@ -500,6 +512,48 @@ async function selectProvider( }; } +function logAiUsageAndCost(args: { + capability: AiCapability; + provider: AiProvider; + requestedModel: string | undefined; + requestBody: unknown; + responseText: string; + isStream: boolean; + headers: Headers; +}): void { + const { capability, provider, requestedModel, requestBody, responseText, isStream, headers } = + args; + + let usage: AiUsage | null = extractUsage( + capability, + responseText, + isStream, + headers + ); + if (!usage || isUsageEmpty(usage)) { + usage = estimateUsage(JSON.stringify(requestBody ?? ""), responseText); + } + + const model = extractResponseModel(responseText) ?? requestedModel; + const pricing = getModelPricing(provider.type as AiProviderType, model); + const cost = calculateAiCost(pricing, usage); + + logger.info("AI gateway request usage", { + capability, + providerId: provider.providerId, + providerType: provider.type, + model, + estimated: usage.estimated, + promptTokens: usage.promptTokens, + cacheReadTokens: usage.cacheReadTokens, + cacheWriteTokens: usage.cacheWriteTokens, + completionTokens: usage.completionTokens, + reasoningTokens: usage.reasoningTokens, + pricingApproximate: pricing?.approximate ?? null, + totalCostUsd: cost?.totalCost ?? null + }); +} + export async function handleAiGatewayProxy( req: Request, res: Response, @@ -636,14 +690,25 @@ export async function handleAiGatewayProxy( applyAiProviderAuthHeaders(headers, authType, apiKey); applyRequestUserHeaders(headers, requestUser); - const body = JSON.stringify(req.body); + // OpenAI's Chat Completions API only reports usage in a streaming + // response when asked to via stream_options.include_usage - inject + // it ourselves when the caller didn't, so we can still track cost, + // and strip the extra frame it adds back out of what we forward. + const injectedUsageOurselves = needsStreamUsageInjection( + capability, + req.body + ); + const outboundBody = injectedUsageOurselves + ? withStreamUsageOption(req.body) + : req.body; + const body = JSON.stringify(outboundBody); logger.debug("AI gateway upstream request", { capability, url: targetUrl, method: "POST", headers, - body: req.body, + body: outboundBody, skipTlsVerification: provider.skipTlsVerification }); @@ -701,11 +766,31 @@ export async function handleAiGatewayProxy( if (isStream && upstreamRes.body) { res.flushHeaders(); const reader = upstreamRes.body.getReader(); + const decoder = new TextDecoder(); + let fullText = ""; + // Frame-boundary buffer, only used when we need to filter the + // usage-only frame we injected out of what reaches the client. + let sseCarry = ""; try { while (!abortController.signal.aborted) { const { done, value } = await reader.read(); if (done) break; - res.write(value); + const chunkText = decoder.decode(value, { stream: true }); + fullText += chunkText; + if (injectedUsageOurselves) { + sseCarry += chunkText; + const lastBoundary = sseCarry.lastIndexOf("\n\n"); + if (lastBoundary !== -1) { + const toEmit = sseCarry.slice(0, lastBoundary + 2); + sseCarry = sseCarry.slice(lastBoundary + 2); + res.write(stripInjectedUsageFrame(toEmit)); + } + } else { + res.write(value); + } + } + if (injectedUsageOurselves && sseCarry) { + res.write(stripInjectedUsageFrame(sseCarry)); } } finally { await reader.cancel().catch(() => {}); @@ -714,11 +799,31 @@ export async function handleAiGatewayProxy( if (!res.writableEnded) { res.end(); } + if (!abortController.signal.aborted) { + logAiUsageAndCost({ + capability, + provider, + requestedModel, + requestBody: req.body, + responseText: fullText, + isStream: true, + headers: upstreamRes.headers + }); + } return; } res.off("close", onClientClose); const text = await upstreamRes.text(); + logAiUsageAndCost({ + capability, + provider, + requestedModel, + requestBody: req.body, + responseText: text, + isStream: false, + headers: upstreamRes.headers + }); return res.send(text); } catch (error) { logger.error(error); From bc7a883f6ce3fc252d3fd1caf569ae5af42c9ffb Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 7 Aug 2026 14:21:13 -0400 Subject: [PATCH 247/494] add some parallelization to ai gateway pipeline --- server/routers/aiGateway/pipeline.ts | 238 ++++++++++++++------------- 1 file changed, 125 insertions(+), 113 deletions(-) diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index dbc3c22be..724102f07 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -242,137 +242,146 @@ async function resolveRequestUser( } async function resolveTarget(host: string): Promise { - const [resourceRow] = await db - .select({ - resourceId: resources.resourceId, - orgId: resources.orgId - }) - .from(resources) - .where( - and( - eq(resources.fullDomain, host), - eq(resources.mode, "inference"), - eq(resources.enabled, true) - ) - ) - .limit(1); - - if (resourceRow) { - const attachmentRows = await db + const [[resourceRow], [siteResourceRow]] = await Promise.all([ + db .select({ - provider: aiProviders, - accessMode: resourceAiProviders.accessMode + resourceId: resources.resourceId, + orgId: resources.orgId }) - .from(resourceAiProviders) - .innerJoin( - aiProviders, - eq(resourceAiProviders.providerId, aiProviders.providerId) - ) + .from(resources) .where( and( - eq(resourceAiProviders.resourceId, resourceRow.resourceId), - eq(aiProviders.enabled, true) + eq(resources.fullDomain, host), + eq(resources.mode, "inference"), + eq(resources.enabled, true) ) - ); + ) + .limit(1), + db + .select({ + siteResourceId: siteResources.siteResourceId, + orgId: siteResources.orgId + }) + .from(siteResources) + .where( + and( + eq(siteResources.fullDomain, host), + eq(siteResources.mode, "inference"), + eq(siteResources.enabled, true) + ) + ) + .limit(1) + ]); + + // Prefer public inference resources when both match the same host. + if (resourceRow) { + const [attachmentRows, resourcePatterns] = await Promise.all([ + db + .select({ + provider: aiProviders, + accessMode: resourceAiProviders.accessMode + }) + .from(resourceAiProviders) + .innerJoin( + aiProviders, + eq(resourceAiProviders.providerId, aiProviders.providerId) + ) + .where( + and( + eq( + resourceAiProviders.resourceId, + resourceRow.resourceId + ), + eq(aiProviders.enabled, true) + ) + ), + db + .select({ + providerId: aiModels.providerId, + modelKey: aiModels.modelKey, + listType: resourceAiModels.listType, + enabled: aiModels.enabled + }) + .from(resourceAiModels) + .innerJoin( + aiModels, + eq(resourceAiModels.modelId, aiModels.modelId) + ) + .where(eq(resourceAiModels.resourceId, resourceRow.resourceId)) + ]); if (attachmentRows.length === 0) { return null; } - const attachments: ProviderAttachment[] = attachmentRows.map((a) => ({ - provider: a.provider, - accessMode: a.accessMode - })); - - const resourcePatterns = await db - .select({ - providerId: aiModels.providerId, - modelKey: aiModels.modelKey, - listType: resourceAiModels.listType, - enabled: aiModels.enabled - }) - .from(resourceAiModels) - .innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId)) - .where(eq(resourceAiModels.resourceId, resourceRow.resourceId)); - return { resourceId: resourceRow.resourceId, siteResourceId: null, orgId: resourceRow.orgId, - attachments, + attachments: attachmentRows.map((a) => ({ + provider: a.provider, + accessMode: a.accessMode + })), resourceListsByProvider: groupPatternsByProvider(resourcePatterns) }; } - const [siteResourceRow] = await db - .select({ - siteResourceId: siteResources.siteResourceId, - orgId: siteResources.orgId - }) - .from(siteResources) - .where( - and( - eq(siteResources.fullDomain, host), - eq(siteResources.mode, "inference"), - eq(siteResources.enabled, true) - ) - ) - .limit(1); - if (siteResourceRow) { - const attachmentRows = await db - .select({ - provider: aiProviders, - accessMode: siteResourceAiProviders.accessMode - }) - .from(siteResourceAiProviders) - .innerJoin( - aiProviders, - eq(siteResourceAiProviders.providerId, aiProviders.providerId) - ) - .where( - and( + const [attachmentRows, resourcePatterns] = await Promise.all([ + db + .select({ + provider: aiProviders, + accessMode: siteResourceAiProviders.accessMode + }) + .from(siteResourceAiProviders) + .innerJoin( + aiProviders, eq( - siteResourceAiProviders.siteResourceId, - siteResourceRow.siteResourceId - ), - eq(aiProviders.enabled, true) + siteResourceAiProviders.providerId, + aiProviders.providerId + ) ) - ); + .where( + and( + eq( + siteResourceAiProviders.siteResourceId, + siteResourceRow.siteResourceId + ), + eq(aiProviders.enabled, true) + ) + ), + db + .select({ + providerId: aiModels.providerId, + modelKey: aiModels.modelKey, + listType: siteResourceAiModels.listType, + enabled: aiModels.enabled + }) + .from(siteResourceAiModels) + .innerJoin( + aiModels, + eq(siteResourceAiModels.modelId, aiModels.modelId) + ) + .where( + eq( + siteResourceAiModels.siteResourceId, + siteResourceRow.siteResourceId + ) + ) + ]); if (attachmentRows.length === 0) { return null; } - const attachments: ProviderAttachment[] = attachmentRows.map((a) => ({ - provider: a.provider, - accessMode: a.accessMode - })); - - const resourcePatterns = await db - .select({ - providerId: aiModels.providerId, - modelKey: aiModels.modelKey, - listType: siteResourceAiModels.listType, - enabled: aiModels.enabled - }) - .from(siteResourceAiModels) - .innerJoin( - aiModels, - eq(siteResourceAiModels.modelId, aiModels.modelId) - ) - .where( - eq( - siteResourceAiModels.siteResourceId, - siteResourceRow.siteResourceId - ) - ); - return { resourceId: null, siteResourceId: siteResourceRow.siteResourceId, orgId: siteResourceRow.orgId, - attachments, + attachments: attachmentRows.map((a) => ({ + provider: a.provider, + accessMode: a.accessMode + })), resourceListsByProvider: groupPatternsByProvider(resourcePatterns) }; } @@ -587,13 +596,6 @@ export async function handleAiGatewayProxy( const { attachments, resourceListsByProvider, resourceId, orgId } = target; - const requestUser = await resolveRequestUser(req, resourceId, orgId); - if (requestUser) { - logger.debug( - `AI gateway request from user ${requestUser.userId} (${requestUser.username})` - ); - } - const capableAttachments = attachments.filter((a) => providerHasCapability(a.provider.capabilities, capability) ); @@ -608,11 +610,21 @@ export async function handleAiGatewayProxy( const requestedModel = def.extractModel(req); - const selection = await selectProvider( - capableAttachments, - resourceListsByProvider, - requestedModel - ); + const [requestUser, selection] = await Promise.all([ + resolveRequestUser(req, resourceId, orgId), + selectProvider( + capableAttachments, + resourceListsByProvider, + requestedModel + ) + ]); + + if (requestUser) { + logger.debug( + `AI gateway request from user ${requestUser.userId} (${requestUser.username})` + ); + } + if (!selection.ok) { return res.status(selection.status).json({ error: { message: selection.message } From 5e5e172d3959a279c5662441abd5763e54b3f199 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 7 Aug 2026 14:36:15 -0400 Subject: [PATCH 248/494] add streaming function to capability --- server/lib/aiCapabilities.ts | 68 ++++++++++++++++++----- server/routers/aiGateway/pipeline.ts | 20 +------ server/routers/aiGateway/targetRouting.ts | 21 +++---- 3 files changed, 68 insertions(+), 41 deletions(-) diff --git a/server/lib/aiCapabilities.ts b/server/lib/aiCapabilities.ts index 2b3691493..89fbd3d75 100644 --- a/server/lib/aiCapabilities.ts +++ b/server/lib/aiCapabilities.ts @@ -27,6 +27,7 @@ export type AiCapabilityDefinition = { req: Request, model: string ) => string; + isStreaming: (req: Request, contentType: string) => boolean; }; function bodyModel(req: Request): string | undefined { @@ -38,9 +39,6 @@ function paramModel(req: Request): string | undefined { return typeof model === "string" && model.length > 0 ? model : undefined; } -/** - * Join a provider base URL with an inbound request path. - */ export function joinUpstreamUrl(baseUrl: string, path: string): string { const base = baseUrl.replace(/\/+$/, ""); let suffix = path.startsWith("/") ? path : `/${path}`; @@ -80,13 +78,38 @@ export function joinUpstreamUrl(baseUrl: string, path: string): string { } function pathFromRequest(req: Request): string { - // Prefer originalUrl (includes mounted path) over req.url when available. - // Query string is preserved - some providers use it to select the - // streaming response format (e.g. Gemini's `?alt=sse`). const raw = req.originalUrl || req.url || req.path; return raw.startsWith("/") ? raw : `/${raw}`; } +function bodyRequestsStream(req: Request): boolean { + return req.body?.stream === true; +} + +function contentTypeIsSse(contentType: string): boolean { + return contentType.includes("text/event-stream"); +} + +function contentTypeIsAmazonEventStream(contentType: string): boolean { + return contentType.includes("application/vnd.amazon.eventstream"); +} + +function pathIncludes(req: Request, fragment: string): boolean { + return pathFromRequest(req).includes(fragment); +} + +function isBodyOrSseStreaming(req: Request, contentType: string): boolean { + return bodyRequestsStream(req) || contentTypeIsSse(contentType); +} + +function isGeminiStyleStreaming(req: Request, contentType: string): boolean { + return ( + pathIncludes(req, "streamGenerateContent") || + pathIncludes(req, "alt=sse") || + contentTypeIsSse(contentType) + ); +} + export const AI_CAPABILITY_DEFS: Record = { openai_chat: { @@ -97,21 +120,24 @@ export const AI_CAPABILITY_DEFS: Record = ], extractModel: bodyModel, resolveUpstreamUrl: (base, req) => - joinUpstreamUrl(base, pathFromRequest(req)) + joinUpstreamUrl(base, pathFromRequest(req)), + isStreaming: isBodyOrSseStreaming }, openai_responses: { id: "openai_responses", routes: [{ method: "POST", path: "/v1/responses" }], extractModel: bodyModel, resolveUpstreamUrl: (base, req) => - joinUpstreamUrl(base, pathFromRequest(req)) + joinUpstreamUrl(base, pathFromRequest(req)), + isStreaming: isBodyOrSseStreaming }, anthropic_messages: { id: "anthropic_messages", routes: [{ method: "POST", path: "/v1/messages" }], extractModel: bodyModel, resolveUpstreamUrl: (base, req) => - joinUpstreamUrl(base, pathFromRequest(req)) + joinUpstreamUrl(base, pathFromRequest(req)), + isStreaming: isBodyOrSseStreaming }, gemini_generate_content: { id: "gemini_generate_content", @@ -127,7 +153,8 @@ export const AI_CAPABILITY_DEFS: Record = ], extractModel: paramModel, resolveUpstreamUrl: (base, req) => - joinUpstreamUrl(base, pathFromRequest(req)) + joinUpstreamUrl(base, pathFromRequest(req)), + isStreaming: isGeminiStyleStreaming }, google_generate_content: { id: "google_generate_content", @@ -144,7 +171,8 @@ export const AI_CAPABILITY_DEFS: Record = ], extractModel: paramModel, resolveUpstreamUrl: (base, req) => - joinUpstreamUrl(base, pathFromRequest(req)) + joinUpstreamUrl(base, pathFromRequest(req)), + isStreaming: isGeminiStyleStreaming }, google_raw_predict: { id: "google_raw_predict", @@ -160,7 +188,11 @@ export const AI_CAPABILITY_DEFS: Record = ], extractModel: paramModel, resolveUpstreamUrl: (base, req) => - joinUpstreamUrl(base, pathFromRequest(req)) + joinUpstreamUrl(base, pathFromRequest(req)), + isStreaming: (req, contentType) => + pathIncludes(req, "streamRawPredict") || + pathIncludes(req, "alt=sse") || + contentTypeIsSse(contentType) }, bedrock_model_invoke: { id: "bedrock_model_invoke", @@ -173,7 +205,11 @@ export const AI_CAPABILITY_DEFS: Record = ], extractModel: paramModel, resolveUpstreamUrl: (base, req) => - joinUpstreamUrl(base, pathFromRequest(req)) + joinUpstreamUrl(base, pathFromRequest(req)), + isStreaming: (req, contentType) => + pathIncludes(req, "invoke-with-response-stream") || + contentTypeIsAmazonEventStream(contentType) || + contentTypeIsSse(contentType) }, bedrock_converse: { id: "bedrock_converse", @@ -183,7 +219,11 @@ export const AI_CAPABILITY_DEFS: Record = ], extractModel: paramModel, resolveUpstreamUrl: (base, req) => - joinUpstreamUrl(base, pathFromRequest(req)) + joinUpstreamUrl(base, pathFromRequest(req)), + isStreaming: (req, contentType) => + pathIncludes(req, "converse-stream") || + contentTypeIsAmazonEventStream(contentType) || + contentTypeIsSse(contentType) } }; diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 724102f07..e84165620 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -62,11 +62,6 @@ import { type AiUsage } from "@server/lib/aiUsageExtraction"; -// Short-lived local caches so a burst of requests from the same IP/user -// doesn't hit the database on every single request. None of this is -// security-critical to cache aggressively (identity is re-derived from the -// session cookie or from a client's exit-node-scoped subnet each time), so -// a small TTL is just an efficiency win, not a trust boundary. const EXIT_NODE_RANGES_CACHE_KEY = "aiGateway:exitNodeRanges"; const EXIT_NODE_RANGES_TTL_SEC = 6000; const CLIENT_BY_IP_TTL_SEC = 30; @@ -638,7 +633,8 @@ export async function handleAiGatewayProxy( req, res, provider, - requestUser + requestUser, + capability ); } @@ -724,10 +720,6 @@ export async function handleAiGatewayProxy( skipTlsVerification: provider.skipTlsVerification }); - // Cancel the upstream request (and, transitively, anything it fans - // out to) if the client goes away before we're done - otherwise a - // client-cancelled streaming chat completion keeps running upstream - // to completion, wasting the connection and any per-token billing. const abortController = new AbortController(); const onClientClose = () => { if (!res.writableEnded) { @@ -764,13 +756,7 @@ export async function handleAiGatewayProxy( } const contentType = upstreamRes.headers.get("content-type") || ""; - const isStream = - req.body?.stream === true || - contentType.includes("text/event-stream") || - req.path.includes("streamGenerateContent") || - req.path.includes("streamRawPredict") || - req.path.includes("converse-stream") || - req.path.includes("invoke-with-response-stream"); + const isStream = def.isStreaming(req, contentType); res.status(upstreamRes.status); res.setHeader("Content-Type", contentType || "application/json"); diff --git a/server/routers/aiGateway/targetRouting.ts b/server/routers/aiGateway/targetRouting.ts index b091bf8f5..a6d08d925 100644 --- a/server/routers/aiGateway/targetRouting.ts +++ b/server/routers/aiGateway/targetRouting.ts @@ -10,6 +10,10 @@ import { applyAiProviderCustomHeaders, authTypeRequiresApiKey } from "@server/lib/aiProviderDefaults"; +import { + AI_CAPABILITY_DEFS, + type AiCapability +} from "@server/lib/aiCapabilities"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { @@ -152,14 +156,14 @@ export async function proxyAiGatewayToSiteTarget( req: Request, res: Response, provider: AiProvider, - requestUser: RequestUser | null + requestUser: RequestUser | null, + capability: AiCapability ): Promise { const providerTargets = await getProviderTargets(provider.providerId); if (providerTargets.length === 0) { res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ error: { - message: - "AI provider has no reachable site targets configured" + message: "AI provider has no reachable site targets configured" } }); return; @@ -256,13 +260,10 @@ export async function proxyAiGatewayToSiteTarget( } const contentType = upstreamRes.headers.get("content-type") || ""; - const isStream = - req.body?.stream === true || - contentType.includes("text/event-stream") || - pathFromRequest(req).includes("streamGenerateContent") || - pathFromRequest(req).includes("streamRawPredict") || - pathFromRequest(req).includes("converse-stream") || - pathFromRequest(req).includes("invoke-with-response-stream"); + const isStream = AI_CAPABILITY_DEFS[capability].isStreaming( + req, + contentType + ); res.status(upstreamRes.status); res.setHeader("Content-Type", contentType || "application/json"); From 3f305e4d5cb7b30bbeb386584e3040239f31c05f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 7 Aug 2026 20:52:25 +0200 Subject: [PATCH 249/494] =?UTF-8?q?=F0=9F=9A=A7=20process=20test=20alert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../private/lib/alerts/processTestAlerts.ts | 22 +++++++++ server/private/routers/alertRule/index.ts | 2 +- ...{testSiteAlertRule.ts => testAlertRule.ts} | 48 ++++++++++++++++--- server/private/routers/external.ts | 6 +-- server/routers/alertRule/types.ts | 22 +++++++++ 5 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 server/private/lib/alerts/processTestAlerts.ts rename server/private/routers/alertRule/{testSiteAlertRule.ts => testAlertRule.ts} (60%) diff --git a/server/private/lib/alerts/processTestAlerts.ts b/server/private/lib/alerts/processTestAlerts.ts new file mode 100644 index 000000000..acbd6fa5e --- /dev/null +++ b/server/private/lib/alerts/processTestAlerts.ts @@ -0,0 +1,22 @@ +import logger from "@server/logger"; +import type { TestAlertContext } from "@server/routers/alertRule/types"; +import { sendAlertEmail } from "./sendAlertEmail"; + +export async function processTestAlerts(context: TestAlertContext) { + const emailActions = context.actions.filter( + (action) => action.type === "email" + ); + // Process email actions + for (const action of emailActions) { + try { + const recipients = await resolveEmailRecipients( + action.emailActionId + ); + if (recipients.length > 0) { + await sendAlertEmail(recipients, context); + } + } catch (err) { + logger.error(`processAlerts: failed to send alert email`, err); + } + } +} diff --git a/server/private/routers/alertRule/index.ts b/server/private/routers/alertRule/index.ts index e80a9ba16..762f707f8 100644 --- a/server/private/routers/alertRule/index.ts +++ b/server/private/routers/alertRule/index.ts @@ -16,4 +16,4 @@ export * from "./updateAlertRule"; export * from "./deleteAlertRule"; export * from "./listAlertRules"; export * from "./getAlertRule"; -export * from "./testSiteAlertRule"; +export * from "./testAlertRule"; diff --git a/server/private/routers/alertRule/testSiteAlertRule.ts b/server/private/routers/alertRule/testAlertRule.ts similarity index 60% rename from server/private/routers/alertRule/testSiteAlertRule.ts rename to server/private/routers/alertRule/testAlertRule.ts index 149ac9313..39a5c28a1 100644 --- a/server/private/routers/alertRule/testSiteAlertRule.ts +++ b/server/private/routers/alertRule/testAlertRule.ts @@ -33,11 +33,44 @@ const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); -const querySchema = z.strictObject({ - event: z.enum(["site_offline", "site_online", "site_toggle"]) +export const SITE_EVENT_TYPES = [ + "site_online", + "site_offline", + "site_toggle" +] as const; +export const HC_EVENT_TYPES = [ + "health_check_healthy", + "health_check_unhealthy", + "health_check_toggle" +] as const; +export const RESOURCE_EVENT_TYPES = [ + "resource_healthy", + "resource_unhealthy", + "resource_degraded", + "resource_toggle" +] as const; + +const webhookActionSchema = z.strictObject({ + webhookUrl: z.string().url(), + config: z.string().optional(), + enabled: z.boolean().optional().default(true) }); -export async function testSiteAlertRule( +const bodySchema = z.strictObject({ + eventType: z.enum([ + ...HC_EVENT_TYPES, + ...SITE_EVENT_TYPES, + ...RESOURCE_EVENT_TYPES + ]), + // Email recipients (flat) + userIds: z.array(z.string().nonempty()).optional().default([]), + roleIds: z.array(z.number()).optional().default([]), + emails: z.array(z.email()).optional().default([]), + // Webhook actions + webhookActions: z.array(webhookActionSchema).optional().default([]) +}); + +export async function testAlertRule( req: Request, res: Response, next: NextFunction @@ -54,16 +87,17 @@ export async function testSiteAlertRule( } const { orgId } = parsedParams.data; - const parsedQuery = querySchema.safeParse(req.query); - if (!parsedQuery.success) { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedQuery.error).toString() + fromError(parsedBody.error).toString() ) ); } - const { event } = parsedQuery.data; + + // TODO: process alert rule } catch (error) { logger.error(error); return next( diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index c94bce1df..0fd4cc023 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -808,12 +808,12 @@ authenticated.get( alertRule.listAlertRules ); -authenticated.get( - "/org/:orgId/test-site-alert-rule/:alertRuleId", +authenticated.post( + "/org/:orgId/alert-rule/test", verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.testAlertRule), - alertRule.testSiteAlertRule + alertRule.testAlertRule ); authenticated.get( diff --git a/server/routers/alertRule/types.ts b/server/routers/alertRule/types.ts index ebffd3c5b..90c4e3163 100644 --- a/server/routers/alertRule/types.ts +++ b/server/routers/alertRule/types.ts @@ -124,3 +124,25 @@ export interface AlertContext { /** Human-readable context data included in emails and webhook payloads */ data: Record; } + +type EmailAlertAction = { + type: "email"; + userIds?: string[]; + roleIds?: string[]; + emails?: string[]; +}; + +type WebhookAlertAction = { + type: "webhook"; + webhookUrl: string; + enabled: boolean; + config?: string | undefined; +}; + +type AlertAction = EmailAlertAction | WebhookAlertAction; +export interface TestAlertContext { + eventType: AlertEventType; + actions: AlertAction[]; + /** Human-readable context data included in emails and webhook payloads */ + data: Record; +} From 403b8a12e4c6f5205d32b10f77d5269de109898b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 7 Aug 2026 20:57:54 +0200 Subject: [PATCH 250/494] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../private/lib/alerts/processTestAlerts.ts | 59 +++++++++++++++++-- server/routers/alertRule/types.ts | 5 +- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/server/private/lib/alerts/processTestAlerts.ts b/server/private/lib/alerts/processTestAlerts.ts index acbd6fa5e..f598dc633 100644 --- a/server/private/lib/alerts/processTestAlerts.ts +++ b/server/private/lib/alerts/processTestAlerts.ts @@ -1,6 +1,11 @@ import logger from "@server/logger"; -import type { TestAlertContext } from "@server/routers/alertRule/types"; +import type { + EmailAlertAction, + TestAlertContext +} from "@server/routers/alertRule/types"; import { sendAlertEmail } from "./sendAlertEmail"; +import type { db, alertEmailRecipients, users, userOrgRoles } from "@server/db"; +import type { eq } from "drizzle-orm"; export async function processTestAlerts(context: TestAlertContext) { const emailActions = context.actions.filter( @@ -9,9 +14,7 @@ export async function processTestAlerts(context: TestAlertContext) { // Process email actions for (const action of emailActions) { try { - const recipients = await resolveEmailRecipients( - action.emailActionId - ); + const recipients = await resolveEmailRecipients(action); if (recipients.length > 0) { await sendAlertEmail(recipients, context); } @@ -20,3 +23,51 @@ export async function processTestAlerts(context: TestAlertContext) { } } } + +/** + * Resolves all email addresses for a given `emailActionId`. + * + * Recipients may be: + * - Direct users (by `userId`) + * - All users in a role (by `roleId`, resolved via `userOrgRoles`) + * - Direct external email addresses + */ +async function resolveEmailRecipients( + action: EmailAlertAction +): Promise { + const emailSet = new Set(); + + // for (const row of rows) { + // if (row.email) { + // emailSet.add(row.email); + // } + + // if (row.userId) { + // const [user] = await db + // .select({ email: users.email }) + // .from(users) + // .where(eq(users.userId, row.userId)) + // .limit(1); + // if (user?.email) { + // emailSet.add(user.email); + // } + // } + + // if (row.roleId) { + // // Find all users with this role via userOrgRoles + // const roleUsers = await db + // .select({ email: users.email }) + // .from(userOrgRoles) + // .innerJoin(users, eq(userOrgRoles.userId, users.userId)) + // .where(eq(userOrgRoles.roleId, Number(row.roleId))); + + // for (const u of roleUsers) { + // if (u.email) { + // emailSet.add(u.email); + // } + // } + // } + // } + + return Array.from(emailSet); +} diff --git a/server/routers/alertRule/types.ts b/server/routers/alertRule/types.ts index 90c4e3163..99057b312 100644 --- a/server/routers/alertRule/types.ts +++ b/server/routers/alertRule/types.ts @@ -125,14 +125,14 @@ export interface AlertContext { data: Record; } -type EmailAlertAction = { +export type EmailAlertAction = { type: "email"; userIds?: string[]; roleIds?: string[]; emails?: string[]; }; -type WebhookAlertAction = { +export type WebhookAlertAction = { type: "webhook"; webhookUrl: string; enabled: boolean; @@ -143,6 +143,7 @@ type AlertAction = EmailAlertAction | WebhookAlertAction; export interface TestAlertContext { eventType: AlertEventType; actions: AlertAction[]; + orgId: string; /** Human-readable context data included in emails and webhook payloads */ data: Record; } From 9eafa067b9db4abf1f55f2150ebee146a3d735b5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 7 Aug 2026 16:13:51 -0400 Subject: [PATCH 251/494] improved provider picker ui on resource --- messages/en-US.json | 19 +- server/db/pg/schema/schema.ts | 6 +- server/db/sqlite/schema/schema.ts | 6 +- server/lib/aiInferenceResource.ts | 51 +- server/routers/aiGateway/pipeline.ts | 17 +- .../resource/addAiProviderToResource.ts | 9 +- server/routers/resource/createResource.ts | 3 +- .../resource/removeAiProviderFromResource.ts | 3 +- .../addAiProviderToSiteResource.ts | 9 +- .../siteResource/createSiteResource.ts | 3 +- .../removeAiProviderFromSiteResource.ts | 3 +- .../private/[niceId]/inference/page.tsx | 139 +++-- .../resources/private/create/page.tsx | 5 - .../public/[niceId]/inference/page.tsx | 140 +++-- .../settings/resources/public/create/page.tsx | 5 - src/components/AiProviderAttachments.tsx | 581 ++++++++++++++++++ src/lib/queries.ts | 24 + 17 files changed, 874 insertions(+), 149 deletions(-) create mode 100644 src/components/AiProviderAttachments.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 2d77bb365..f18f99133 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1772,11 +1772,28 @@ "aiProviderModelsErrorUpdate": "Failed to update models", "aiResourceProviders": "Providers", "aiResourceProvidersDescription": "Choose which AI providers this inference resource can use", - "aiResourceProvidersHelp": "Each attached provider uses its own allow and block lists. Allow patterns that conflict across attached providers are not allowed.", + "aiResourceProvidersHelp": "Attach providers and choose inherit (use each provider's lists) or select (pick an allow list for this resource). Allow patterns that conflict across attached providers are not allowed.", "aiResourceProvidersSelect": "Select providers", "aiResourceProvidersEmpty": "No AI providers found", + "aiResourceProvidersNoneAttached": "No providers attached yet.", + "aiResourceProvidersAdd": "Add provider", + "aiResourceProvidersRemove": "Remove provider", + "aiResourceProviderToggleEnabled": "Enable or disable this provider on the resource", + "aiResourceProviderDisabled": "Disabled", "aiResourceProvidersUpdated": "Providers updated", "aiResourceProvidersErrorUpdate": "Failed to update providers", + "aiResourceProviderEditDescription": "Choose how this provider's models are exposed on this resource.", + "aiResourceProviderMode": "Access mode", + "aiResourceProviderModeInherit": "Inherit", + "aiResourceProviderModeSelect": "Select", + "aiResourceProviderModeSelectSummary": "Select · {count} models", + "aiResourceProviderModeInheritHelp": "Use this provider's allow and block lists as configured on the provider.", + "aiResourceProviderModeSelectHelp": "Choose a subset of this provider's allow-list models for this resource.", + "aiResourceProviderAllowModels": "Allow list", + "aiResourceProviderAllowModelsSelect": "Select models", + "aiResourceProviderAllowModelsSearch": "Search models...", + "aiResourceProviderAllowModelsEmpty": "No models found", + "aiResourceProviderAllowModelsHelp": "Only models from this provider's allow list can be selected.", "aiResourceAliasRequired": "Alias is required for inference resources", "aiResourceDomainConfiguration": "Domain configuration", "aiResourceDomainConfigurationDescription": "Choose the domain clients will use to reach this inference resource.", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 90dd5abd9..1c3d21b10 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -234,7 +234,8 @@ export const resourceAiProviders = pgTable( accessMode: varchar("accessMode") .$type<"inherit" | "select">() .notNull() - .default("inherit") + .default("inherit"), + enabled: boolean("enabled").notNull().default(true) }, (t) => [primaryKey({ columns: [t.resourceId, t.providerId] })] ); @@ -529,7 +530,8 @@ export const siteResourceAiProviders = pgTable( accessMode: varchar("accessMode") .$type<"inherit" | "select">() .notNull() - .default("inherit") + .default("inherit"), + enabled: boolean("enabled").notNull().default(true) }, (t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })] ); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index dab445282..71fd197e7 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -231,7 +231,8 @@ export const resourceAiProviders = sqliteTable( accessMode: text("accessMode") .$type<"inherit" | "select">() .notNull() - .default("inherit") + .default("inherit"), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true) }, (t) => [primaryKey({ columns: [t.resourceId, t.providerId] })] ); @@ -514,7 +515,8 @@ export const siteResourceAiProviders = sqliteTable( accessMode: text("accessMode") .$type<"inherit" | "select">() .notNull() - .default("inherit") + .default("inherit"), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true) }, (t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })] ); diff --git a/server/lib/aiInferenceResource.ts b/server/lib/aiInferenceResource.ts index 51333473a..92a438ac2 100644 --- a/server/lib/aiInferenceResource.ts +++ b/server/lib/aiInferenceResource.ts @@ -24,7 +24,8 @@ export type AccessMode = z.infer; export const resourceAiProviderAttachmentSchema = z.strictObject({ providerId: z.number().int().positive(), - accessMode: accessModeSchema.optional().default("inherit") + accessMode: accessModeSchema.optional().default("inherit"), + enabled: z.boolean().optional().default(true) }); export type ResourceAiProviderInput = z.infer< @@ -34,6 +35,7 @@ export type ResourceAiProviderInput = z.infer< export type ResourceAiProviderAttachment = { providerId: number; accessMode: AccessMode; + enabled: boolean; }; export const resourceAiModelEntrySchema = z.strictObject({ @@ -79,14 +81,23 @@ export function resolveEffectiveLists(input: { function normalizeAttachments( inputs: ResourceAiProviderInput[] ): ResourceAiProviderAttachment[] { - const byProviderId = new Map(); + const byProviderId = new Map< + number, + { accessMode: AccessMode; enabled: boolean } + >(); for (const input of inputs) { - byProviderId.set(input.providerId, input.accessMode ?? "inherit"); + byProviderId.set(input.providerId, { + accessMode: input.accessMode ?? "inherit", + enabled: input.enabled ?? true + }); } - return [...byProviderId.entries()].map(([providerId, accessMode]) => ({ - providerId, - accessMode - })); + return [...byProviderId.entries()].map( + ([providerId, { accessMode, enabled }]) => ({ + providerId, + accessMode, + enabled + }) + ); } type EffectiveAllowRow = { @@ -110,14 +121,16 @@ export async function assertNoOverlappingModelKeys( ): Promise { const trx = options.trx ?? db; - if (attachments.length < 2) { + const activeAttachments = attachments.filter((a) => a.enabled); + + if (activeAttachments.length < 2) { return null; } - const inheritProviderIds = attachments + const inheritProviderIds = activeAttachments .filter((a) => a.accessMode === "inherit") .map((a) => a.providerId); - const selectProviderIds = attachments + const selectProviderIds = activeAttachments .filter((a) => a.accessMode === "select") .map((a) => a.providerId); @@ -318,7 +331,8 @@ export async function setPublicResourceAiProviders( attachments.map((a) => ({ resourceId, providerId: a.providerId, - accessMode: a.accessMode + accessMode: a.accessMode, + enabled: a.enabled })) ); } @@ -344,7 +358,8 @@ export async function setSiteResourceAiProviders( attachments.map((a) => ({ siteResourceId, providerId: a.providerId, - accessMode: a.accessMode + accessMode: a.accessMode, + enabled: a.enabled })) ); } @@ -473,7 +488,8 @@ export async function listPublicResourceAiProviders(resourceId: number) { providerId: resourceAiProviders.providerId, name: aiProviders.name, type: aiProviders.type, - enabled: aiProviders.enabled, + enabled: resourceAiProviders.enabled, + providerEnabled: aiProviders.enabled, accessMode: resourceAiProviders.accessMode }) .from(resourceAiProviders) @@ -490,7 +506,8 @@ export async function listSiteResourceAiProviders(siteResourceId: number) { providerId: siteResourceAiProviders.providerId, name: aiProviders.name, type: aiProviders.type, - enabled: aiProviders.enabled, + enabled: siteResourceAiProviders.enabled, + providerEnabled: aiProviders.enabled, accessMode: siteResourceAiProviders.accessMode }) .from(siteResourceAiProviders) @@ -575,7 +592,8 @@ export async function assertPublicResourceModelEntriesValid(input: { const attachments = await db .select({ providerId: resourceAiProviders.providerId, - accessMode: resourceAiProviders.accessMode + accessMode: resourceAiProviders.accessMode, + enabled: resourceAiProviders.enabled }) .from(resourceAiProviders) .innerJoin( @@ -610,7 +628,8 @@ export async function assertSiteResourceModelEntriesValid(input: { const attachments = await db .select({ providerId: siteResourceAiProviders.providerId, - accessMode: siteResourceAiProviders.accessMode + accessMode: siteResourceAiProviders.accessMode, + enabled: siteResourceAiProviders.enabled }) .from(siteResourceAiProviders) .innerJoin( diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index e84165620..bcff1a501 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -287,7 +287,8 @@ async function resolveTarget(host: string): Promise { resourceAiProviders.resourceId, resourceRow.resourceId ), - eq(aiProviders.enabled, true) + eq(aiProviders.enabled, true), + eq(resourceAiProviders.enabled, true) ) ), db @@ -342,7 +343,8 @@ async function resolveTarget(host: string): Promise { siteResourceAiProviders.siteResourceId, siteResourceRow.siteResourceId ), - eq(aiProviders.enabled, true) + eq(aiProviders.enabled, true), + eq(siteResourceAiProviders.enabled, true) ) ), db @@ -525,8 +527,15 @@ function logAiUsageAndCost(args: { isStream: boolean; headers: Headers; }): void { - const { capability, provider, requestedModel, requestBody, responseText, isStream, headers } = - args; + const { + capability, + provider, + requestedModel, + requestBody, + responseText, + isStream, + headers + } = args; let usage: AiUsage | null = extractUsage( capability, diff --git a/server/routers/resource/addAiProviderToResource.ts b/server/routers/resource/addAiProviderToResource.ts index bcc175c18..f2477d89d 100644 --- a/server/routers/resource/addAiProviderToResource.ts +++ b/server/routers/resource/addAiProviderToResource.ts @@ -118,9 +118,14 @@ export async function addAiProviderToResource( .filter((a) => a.providerId !== providerId) .map((a) => ({ providerId: a.providerId, - accessMode: a.accessMode + accessMode: a.accessMode, + enabled: a.enabled })), - { providerId, accessMode: "inherit" as const } + { + providerId, + accessMode: "inherit" as const, + enabled: true as const + } ]; const attachments = await resolveProviderAttachments({ diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index e225458d1..45e66c2c3 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -397,7 +397,8 @@ async function createHttpResource( orgId, attachments: (aiProviderInputs ?? []).map((p) => ({ providerId: p.providerId, - accessMode: "inherit" as const + accessMode: "inherit" as const, + enabled: true as const })), requireAtLeastOne: false }); diff --git a/server/routers/resource/removeAiProviderFromResource.ts b/server/routers/resource/removeAiProviderFromResource.ts index a7f9298ee..5b99ff089 100644 --- a/server/routers/resource/removeAiProviderFromResource.ts +++ b/server/routers/resource/removeAiProviderFromResource.ts @@ -127,7 +127,8 @@ export async function removeAiProviderFromResource( .filter((a) => a.providerId !== providerId) .map((a) => ({ providerId: a.providerId, - accessMode: a.accessMode + accessMode: a.accessMode, + enabled: a.enabled })); const attachments = await resolveProviderAttachments({ diff --git a/server/routers/siteResource/addAiProviderToSiteResource.ts b/server/routers/siteResource/addAiProviderToSiteResource.ts index 79a0ae093..18e8cd74d 100644 --- a/server/routers/siteResource/addAiProviderToSiteResource.ts +++ b/server/routers/siteResource/addAiProviderToSiteResource.ts @@ -118,9 +118,14 @@ export async function addAiProviderToSiteResource( .filter((a) => a.providerId !== providerId) .map((a) => ({ providerId: a.providerId, - accessMode: a.accessMode + accessMode: a.accessMode, + enabled: a.enabled })), - { providerId, accessMode: "inherit" as const } + { + providerId, + accessMode: "inherit" as const, + enabled: true as const + } ]; const attachments = await resolveProviderAttachments({ diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 15b82cb56..2038f9ce7 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -356,7 +356,8 @@ export async function createSiteResource( orgId, attachments: (aiProviderInputs ?? []).map((p) => ({ providerId: p.providerId, - accessMode: "inherit" as const + accessMode: "inherit" as const, + enabled: true as const })), requireAtLeastOne: false }); diff --git a/server/routers/siteResource/removeAiProviderFromSiteResource.ts b/server/routers/siteResource/removeAiProviderFromSiteResource.ts index ffb4f1ff8..29facaece 100644 --- a/server/routers/siteResource/removeAiProviderFromSiteResource.ts +++ b/server/routers/siteResource/removeAiProviderFromSiteResource.ts @@ -126,7 +126,8 @@ export async function removeAiProviderFromSiteResource( .filter((a) => a.providerId !== providerId) .map((a) => ({ providerId: a.providerId, - accessMode: a.accessMode + accessMode: a.accessMode, + enabled: a.enabled })); const attachments = await resolveProviderAttachments({ diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx index f9ad664a0..e824cc105 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/inference/page.tsx @@ -16,9 +16,9 @@ import { SettingsSubsectionTitle } from "@app/components/Settings"; import { - AiProvidersSelector, - type SelectedAiProvider -} from "@app/components/AiProvidersSelector"; + AiProviderAttachments, + type AiProviderAttachmentValue +} from "@app/components/AiProviderAttachments"; import DomainPicker from "@app/components/DomainPicker"; import { SwitchInput } from "@app/components/SwitchInput"; import { Button } from "@app/components/ui/button"; @@ -41,7 +41,7 @@ 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 { useActionState, useEffect, useMemo } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -65,7 +65,15 @@ export default function PrivateResourceInferencePage() { const formSchema = useMemo( () => z.object({ - providerIds: z.array(z.number().int().positive()), + providers: z.array( + z.object({ + providerId: z.number().int().positive(), + name: z.string(), + accessMode: z.enum(["inherit", "select"]), + enabled: z.boolean(), + selectedModelIds: z.array(z.number().int().positive()) + }) + ), httpConfigSubdomain: z.string().nullish(), httpConfigDomainId: z.string().nullish(), httpConfigFullDomain: z.string().nullish(), @@ -75,10 +83,6 @@ export default function PrivateResourceInferencePage() { ); type FormValues = z.infer; - const [selectedProviders, setSelectedProviders] = useState< - SelectedAiProvider[] - >([]); - const attachedQuery = useQuery({ ...resourceQueries.siteResourceAiProviders({ siteResourceId: siteResource.id @@ -86,10 +90,17 @@ export default function PrivateResourceInferencePage() { enabled: siteResource.mode === "inference" }); + const modelsQuery = useQuery({ + ...resourceQueries.siteResourceAiModels({ + siteResourceId: siteResource.id + }), + enabled: siteResource.mode === "inference" + }); + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { - providerIds: [], + providers: [], httpConfigSubdomain: siteResource.subdomain ?? null, httpConfigDomainId: siteResource.domainId ?? null, httpConfigFullDomain: siteResource.fullDomain ?? null, @@ -103,16 +114,33 @@ export default function PrivateResourceInferencePage() { useEffect(() => { if (!attachedQuery.data) return; - const providers = attachedQuery.data.map((provider) => ({ - id: String(provider.providerId), - text: provider.name - })); - setSelectedProviders(providers); - form.setValue( - "providerIds", - attachedQuery.data.map((p) => p.providerId) + const hasSelect = attachedQuery.data.some( + (provider) => provider.accessMode === "select" ); - }, [attachedQuery.data, form]); + if (hasSelect && modelsQuery.isLoading) return; + + const modelsByProvider = new Map(); + for (const model of modelsQuery.data ?? []) { + if (model.listType !== "allow") continue; + const existing = modelsByProvider.get(model.providerId) ?? []; + existing.push(model.modelId); + modelsByProvider.set(model.providerId, existing); + } + + form.setValue( + "providers", + attachedQuery.data.map((provider) => ({ + providerId: provider.providerId, + name: provider.name, + accessMode: provider.accessMode, + enabled: provider.enabled, + selectedModelIds: + provider.accessMode === "select" + ? (modelsByProvider.get(provider.providerId) ?? []) + : [] + })) + ); + }, [attachedQuery.data, modelsQuery.data, modelsQuery.isLoading, form]); const [, formAction, saveLoading] = useActionState(async () => { const isValid = await form.trigger(); @@ -129,16 +157,37 @@ export default function PrivateResourceInferencePage() { }); await api.post(`/site-resource/${siteResource.id}/ai-providers`, { - providers: data.providerIds.map((providerId) => ({ - providerId + providers: data.providers.map((provider) => ({ + providerId: provider.providerId, + accessMode: provider.accessMode, + enabled: provider.enabled })) }); + const selectProviders = data.providers.filter( + (provider) => provider.accessMode === "select" + ); + if (selectProviders.length > 0) { + await api.post(`/site-resource/${siteResource.id}/ai-models`, { + models: selectProviders.flatMap((provider) => + provider.selectedModelIds.map((modelId) => ({ + modelId, + listType: "allow" as const + })) + ) + }); + } + await queryClient.invalidateQueries( resourceQueries.siteResourceAiProviders({ siteResourceId: siteResource.id }) ); + await queryClient.invalidateQueries( + resourceQueries.siteResourceAiModels({ + siteResourceId: siteResource.id + }) + ); toast({ title: t("success"), @@ -160,6 +209,11 @@ export default function PrivateResourceInferencePage() { return null; } + const providersLoading = + attachedQuery.isLoading || + (attachedQuery.data?.some((p) => p.accessMode === "select") && + modelsQuery.isLoading); + return ( @@ -183,8 +237,8 @@ export default function PrivateResourceInferencePage() { ( + name="providers" + render={({ field }) => ( {t( @@ -192,44 +246,21 @@ export default function PrivateResourceInferencePage() { )} - { - setSelectedProviders( - providers - ); - form.setValue( - "providerIds", - providers.map( - (p) => - parseInt( - p.id, - 10 - ) - ), - { - shouldValidate: true - } - ); - }} /> - - {t( - "aiResourceProvidersHelp" - )} - )} @@ -335,7 +366,7 @@ export default function PrivateResourceInferencePage() { type="submit" form="private-resource-providers-form" loading={saveLoading} - disabled={attachedQuery.isLoading} + disabled={providersLoading || saveLoading} > {t("saveSettings")} diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index da3c968b6..a1b04f5e4 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -710,11 +710,6 @@ export default function CreatePrivateResourcePage() { }} /> - - {t( - "aiResourceProvidersHelp" - )} - )} diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx index ad7230591..7de441995 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx @@ -13,9 +13,9 @@ import { SettingsSectionTitle } from "@app/components/Settings"; import { - AiProvidersSelector, - type SelectedAiProvider -} from "@app/components/AiProvidersSelector"; + AiProviderAttachments, + type AiProviderAttachmentValue +} from "@app/components/AiProviderAttachments"; import { Button } from "@app/components/ui/button"; import { Form, @@ -35,7 +35,7 @@ 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 { useActionState, useEffect, useMemo } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -58,16 +58,20 @@ export default function PublicResourceInferencePage() { const formSchema = useMemo( () => z.object({ - providerIds: z.array(z.number().int().positive()) + providers: z.array( + z.object({ + providerId: z.number().int().positive(), + name: z.string(), + accessMode: z.enum(["inherit", "select"]), + enabled: z.boolean(), + selectedModelIds: z.array(z.number().int().positive()) + }) + ) }), [] ); type FormValues = z.infer; - const [selectedProviders, setSelectedProviders] = useState< - SelectedAiProvider[] - >([]); - const attachedQuery = useQuery({ ...resourceQueries.resourceAiProviders({ resourceId: resource.resourceId @@ -75,24 +79,53 @@ export default function PublicResourceInferencePage() { enabled: resource.mode === "inference" }); + const modelsQuery = useQuery({ + ...resourceQueries.resourceAiModels({ + resourceId: resource.resourceId + }), + enabled: resource.mode === "inference" + }); + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { - providerIds: [] + providers: [] } }); useEffect(() => { if (!attachedQuery.data) return; - const providers = attachedQuery.data.map((provider) => ({ - id: String(provider.providerId), - text: provider.name - })); - setSelectedProviders(providers); + const hasSelect = attachedQuery.data.some( + (provider) => provider.accessMode === "select" + ); + if (hasSelect && modelsQuery.isLoading) return; + + const modelsByProvider = new Map(); + for (const model of modelsQuery.data ?? []) { + if (model.listType !== "allow") continue; + const existing = modelsByProvider.get(model.providerId) ?? []; + existing.push(model.modelId); + modelsByProvider.set(model.providerId, existing); + } + form.reset({ - providerIds: attachedQuery.data.map((p) => p.providerId) + providers: attachedQuery.data.map((provider) => ({ + providerId: provider.providerId, + name: provider.name, + accessMode: provider.accessMode, + enabled: provider.enabled, + selectedModelIds: + provider.accessMode === "select" + ? (modelsByProvider.get(provider.providerId) ?? []) + : [] + })) }); - }, [attachedQuery.data, form]); + }, [ + attachedQuery.data, + modelsQuery.data, + modelsQuery.isLoading, + form + ]); const [, formAction, saveLoading] = useActionState(async () => { const isValid = await form.trigger(); @@ -101,16 +134,37 @@ export default function PublicResourceInferencePage() { const data = form.getValues(); try { await api.post(`/resource/${resource.resourceId}/ai-providers`, { - providers: data.providerIds.map((providerId) => ({ - providerId + providers: data.providers.map((provider) => ({ + providerId: provider.providerId, + accessMode: provider.accessMode, + enabled: provider.enabled })) }); + const selectProviders = data.providers.filter( + (provider) => provider.accessMode === "select" + ); + if (selectProviders.length > 0) { + await api.post(`/resource/${resource.resourceId}/ai-models`, { + models: selectProviders.flatMap((provider) => + provider.selectedModelIds.map((modelId) => ({ + modelId, + listType: "allow" as const + })) + ) + }); + } + await queryClient.invalidateQueries( resourceQueries.resourceAiProviders({ resourceId: resource.resourceId }) ); + await queryClient.invalidateQueries( + resourceQueries.resourceAiModels({ + resourceId: resource.resourceId + }) + ); toast({ title: t("success"), @@ -132,6 +186,11 @@ export default function PublicResourceInferencePage() { return null; } + const providersLoading = + attachedQuery.isLoading || + (attachedQuery.data?.some((p) => p.accessMode === "select") && + modelsQuery.isLoading); + return ( @@ -155,8 +214,8 @@ export default function PublicResourceInferencePage() { ( + name="providers" + render={({ field }) => ( {t( @@ -164,44 +223,21 @@ export default function PublicResourceInferencePage() { )} - { - setSelectedProviders( - providers - ); - form.setValue( - "providerIds", - providers.map( - (p) => - parseInt( - p.id, - 10 - ) - ), - { - shouldValidate: true - } - ); - }} /> - - {t( - "aiResourceProvidersHelp" - )} - )} @@ -218,7 +254,7 @@ export default function PublicResourceInferencePage() { type="submit" form="public-resource-providers-form" loading={saveLoading} - disabled={attachedQuery.isLoading} + disabled={providersLoading || saveLoading} > {t("saveSettings")} diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index 4d1c24efb..65529f74a 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -1437,11 +1437,6 @@ export default function Page() { ); }} /> -

- {t( - "aiResourceProvidersHelp" - )} -

diff --git a/src/components/AiProviderAttachments.tsx b/src/components/AiProviderAttachments.tsx new file mode 100644 index 000000000..09a4243e1 --- /dev/null +++ b/src/components/AiProviderAttachments.tsx @@ -0,0 +1,581 @@ +"use client"; + +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { type TagValue } from "@app/components/multi-select/multi-select-content"; +import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input"; +import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Switch } from "@app/components/ui/switch"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { cn } from "@app/lib/cn"; +import { aiProviderQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import { Plus, XIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +export type AiProviderAttachmentValue = { + providerId: number; + name: string; + accessMode: "inherit" | "select"; + enabled: boolean; + selectedModelIds: number[]; +}; + +export type AiProviderAttachmentsProps = { + orgId: string; + value: AiProviderAttachmentValue[]; + onChange: (value: AiProviderAttachmentValue[]) => void; + disabled?: boolean; +}; + +export function AiProviderAttachments({ + orgId, + value, + onChange, + disabled +}: AiProviderAttachmentsProps) { + const t = useTranslations(); + const [editingProviderId, setEditingProviderId] = useState( + null + ); + + const { data: providers = [] } = useQuery( + aiProviderQueries.orgProviders({ orgId }) + ); + + const attachedIds = useMemo( + () => new Set(value.map((v) => v.providerId)), + [value] + ); + + const availableProviders = providers + .filter((provider) => provider.enabled) + .filter((provider) => !attachedIds.has(provider.providerId)); + + const editing = value.find((v) => v.providerId === editingProviderId); + + function addProvider(providerId: number, name: string) { + if (value.some((v) => v.providerId === providerId)) { + return; + } + onChange([ + ...value, + { + providerId, + name, + accessMode: "inherit", + enabled: true, + selectedModelIds: [] + } + ]); + } + + function removeProvider(providerId: number) { + onChange(value.filter((v) => v.providerId !== providerId)); + } + + function updateProvider(updated: AiProviderAttachmentValue) { + onChange( + value.map((v) => + v.providerId === updated.providerId ? updated : v + ) + ); + setEditingProviderId(null); + } + + return ( +
+ {value.length === 0 ? ( +

+ {t("aiResourceProvidersNoneAttached")} +

+ ) : ( +
+ {value.map((attachment) => ( + + setEditingProviderId(attachment.providerId) + } + onRemove={() => + removeProvider(attachment.providerId) + } + onToggleEnabled={(enabled) => { + onChange( + value.map((v) => + v.providerId === attachment.providerId + ? { ...v, enabled } + : v + ) + ); + }} + /> + ))} +
+ )} + + + + + + + {availableProviders.map((provider) => ( + + addProvider(provider.providerId, provider.name) + } + > + {provider.name} + + ))} + + + + {editing && ( + { + if (!open) setEditingProviderId(null); + }} + onSave={updateProvider} + /> + )} +
+ ); +} + +function AttachmentRow({ + attachment, + disabled, + onEdit, + onRemove, + onToggleEnabled +}: { + attachment: AiProviderAttachmentValue; + disabled?: boolean; + onEdit: () => void; + onRemove: () => void; + onToggleEnabled: (enabled: boolean) => void; +}) { + const t = useTranslations(); + const summary = + attachment.accessMode === "inherit" + ? t("aiResourceProviderModeInherit") + : t("aiResourceProviderModeSelectSummary", { + count: attachment.selectedModelIds.length + }); + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onEdit(); + } + } + } + role={disabled ? undefined : "button"} + tabIndex={disabled ? undefined : 0} + > +
+ + {attachment.name} + +

+ {attachment.enabled + ? summary + : t("aiResourceProviderDisabled")} +

+
+
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + + +
+
+ ); +} + +type EditFormValues = { + accessMode: "inherit" | "select"; + selectedModels: TagValue[]; +}; + +function EditAttachmentCredenza({ + attachment, + open, + onOpenChange, + onSave +}: { + attachment: AiProviderAttachmentValue; + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (value: AiProviderAttachmentValue) => void; +}) { + const t = useTranslations(); + const [modelSearch, setModelSearch] = useState(""); + + const editSchema = useMemo( + () => + z.object({ + accessMode: z.enum(["inherit", "select"]), + selectedModels: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ) + }), + [] + ); + + const modelsQuery = useQuery({ + ...aiProviderQueries.providerModels({ + providerId: attachment.providerId + }), + enabled: open + }); + + const allowCatalog = useMemo(() => { + const models = modelsQuery.data ?? []; + return models.filter( + (model) => model.enabled && (model.listType ?? "allow") === "allow" + ); + }, [modelsQuery.data]); + + const allowOptions: TagValue[] = useMemo(() => { + const query = modelSearch.trim().toLowerCase(); + return allowCatalog + .filter((model) => { + if (!query) return true; + return ( + model.modelKey.toLowerCase().includes(query) || + model.name.toLowerCase().includes(query) + ); + }) + .map((model) => ({ + id: String(model.modelId), + text: model.modelKey + })); + }, [allowCatalog, modelSearch]); + + const form = useForm({ + resolver: zodResolver(editSchema), + defaultValues: { + accessMode: attachment.accessMode, + selectedModels: [] + } + }); + + const accessMode = form.watch("accessMode"); + const pendingSeedRef = useRef(false); + + useEffect(() => { + if (!open) return; + form.reset({ + accessMode: attachment.accessMode, + selectedModels: attachment.selectedModelIds.map((modelId) => { + const catalog = (modelsQuery.data ?? []).find( + (model) => model.modelId === modelId + ); + return { + id: String(modelId), + text: catalog?.modelKey ?? String(modelId) + }; + }) + }); + setModelSearch(""); + pendingSeedRef.current = false; + // Only re-init when opening or switching which attachment is edited. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, attachment.providerId]); + + useEffect(() => { + if (!open || allowCatalog.length === 0) return; + + const current = form.getValues("selectedModels"); + const upgraded = current.map((model) => { + const catalog = allowCatalog.find( + (entry) => String(entry.modelId) === model.id + ); + return catalog ? { id: model.id, text: catalog.modelKey } : model; + }); + const changed = upgraded.some( + (model, index) => model.text !== current[index]?.text + ); + if (changed) { + form.setValue("selectedModels", upgraded); + } + + if (pendingSeedRef.current) { + form.setValue( + "selectedModels", + allowCatalog.map((model) => ({ + id: String(model.modelId), + text: model.modelKey + })) + ); + pendingSeedRef.current = false; + } + }, [open, allowCatalog, form]); + + function handleAccessModeChange(next: "inherit" | "select") { + form.setValue("accessMode", next); + if (next === "inherit") { + form.setValue("selectedModels", []); + pendingSeedRef.current = false; + return; + } + if (attachment.accessMode === "select") { + form.setValue( + "selectedModels", + attachment.selectedModelIds.map((modelId) => { + const catalog = allowCatalog.find( + (model) => model.modelId === modelId + ); + return { + id: String(modelId), + text: catalog?.modelKey ?? String(modelId) + }; + }) + ); + pendingSeedRef.current = false; + return; + } + if (allowCatalog.length > 0) { + form.setValue( + "selectedModels", + allowCatalog.map((model) => ({ + id: String(model.modelId), + text: model.modelKey + })) + ); + pendingSeedRef.current = false; + return; + } + form.setValue("selectedModels", []); + pendingSeedRef.current = true; + } + + function onSubmit(values: EditFormValues) { + onSave({ + providerId: attachment.providerId, + name: attachment.name, + accessMode: values.accessMode, + enabled: attachment.enabled, + selectedModelIds: + values.accessMode === "select" + ? values.selectedModels.map((model) => + parseInt(model.id, 10) + ) + : [] + }); + } + + return ( + + + + {attachment.name} + + {t("aiResourceProviderEditDescription")} + + + + + + ( + + + {t("aiResourceProviderMode")} + + + + {field.value === "inherit" + ? t( + "aiResourceProviderModeInheritHelp" + ) + : t( + "aiResourceProviderModeSelectHelp" + )} + + + + )} + /> + + {accessMode === "select" && ( + ( + + + {t( + "aiResourceProviderAllowModels" + )} + + + + + + {t( + "aiResourceProviderAllowModelsHelp" + )} + + + + )} + /> + )} + + + + + + + + + + + + ); +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 6b7070f5b..e1e06f928 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -38,6 +38,7 @@ import type { GetResourcePolicyResponse } from "@server/routers/policy"; import type { GetResourcePoliciesResponse, GetResourceWhitelistResponse, + ListResourceAiModelsResponse, ListResourceNamesResponse, ListResourceRolesResponse, ListResourceRulesResponse, @@ -51,6 +52,7 @@ import type { ListRolesResponse } from "@server/routers/role"; import type { ListSitesResponse } from "@server/routers/site"; import type { ListAllSiteResourcesByOrgResponse, + ListSiteResourceAiModelsResponse, ListSiteResourceClientsResponse, ListSiteResourceRolesResponse, ListSiteResourceUsersResponse @@ -1291,6 +1293,7 @@ export const resourceQueries = { name: string; type: string; enabled: boolean; + providerEnabled: boolean; accessMode: "inherit" | "select"; }>; }> @@ -1311,6 +1314,7 @@ export const resourceQueries = { name: string; type: string; enabled: boolean; + providerEnabled: boolean; accessMode: "inherit" | "select"; }>; }> @@ -1320,6 +1324,26 @@ export const resourceQueries = { return res.data.data.providers; } }), + resourceAiModels: ({ resourceId }: { resourceId: number }) => + queryOptions({ + queryKey: ["RESOURCES", resourceId, "AI_MODELS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/resource/${resourceId}/ai-models`, { signal }); + return res.data.data.models; + } + }), + siteResourceAiModels: ({ siteResourceId }: { siteResourceId: number }) => + queryOptions({ + queryKey: ["SITE_RESOURCES", siteResourceId, "AI_MODELS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/site-resource/${siteResourceId}/ai-models`, { signal }); + return res.data.data.models; + } + }), resourceTargets: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "TARGETS"] as const, From ca79abc9d4c6f8fc9fe5a2122172a7ba93fb4b3a Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 7 Aug 2026 14:29:48 -0400 Subject: [PATCH 252/494] Update structure --- server/lib/aiModelPricing.ts | 160 ++++++++++++++++------------------- 1 file changed, 72 insertions(+), 88 deletions(-) diff --git a/server/lib/aiModelPricing.ts b/server/lib/aiModelPricing.ts index 3edf4e9f4..8c0eb6739 100644 --- a/server/lib/aiModelPricing.ts +++ b/server/lib/aiModelPricing.ts @@ -5,22 +5,25 @@ import type { AiProviderType } from "@server/lib/aiProviderDefaults"; import type { AiUsage } from "@server/lib/aiUsageExtraction"; import logger from "@server/logger"; -// config/models.json is a runtime asset (same category as config.yml or the -// MaxMind DBs) - not part of the source tree. Its shape mirrors litellm's -// public model_prices_and_context_window.json: a flat list of -// { id, name, provider, input_cost_per_token, output_cost_per_token, -// cache_read_input_token_cost, output_cost_per_reasoning_token }, where -// `provider` is litellm's provider bucket, not our AiProviderType. const MODELS_JSON_PATH = path.join(APP_PATH, "models.json"); -export type AiModelPricingEntry = { - id: string; - name: string; - provider: string; - input_cost_per_token: number | null; - output_cost_per_token: number | null; - cache_read_input_token_cost: number | null; - output_cost_per_reasoning_token: number | null; +export type CatalogProvider = + | "openai" + | "anthropic" + | "gemini" + | "vertex" + | "azure" + | "bedrock"; + +export type AiModelCatalogEntry = { + provider: CatalogProvider; + model: string; + pricing: { + input: number | null; + output: number | null; + cacheRead: number | null; + reasoningOutput: number | null; + }; }; export type AiModelPricing = { @@ -28,65 +31,48 @@ export type AiModelPricing = { outputCostPerToken: number | null; cacheReadInputTokenCost: number | null; outputCostPerReasoningToken: number | null; - // True when the match came from a different provider bucket than the one - // mapped to this provider's type (e.g. an openRouter/custom model id that - // only matched by stripping a "vendor/" prefix against the whole table). - // Costs found this way are a best-effort approximation, not a guarantee - // the upstream provider bills at the same rate. + // True when the match came from a different catalog provider than the + // one mapped to this provider's type (e.g. an openRouter/custom model + // id that only matched a global search across every provider). Costs + // found this way are a best-effort approximation, not a guarantee the + // upstream provider bills at the same rate. approximate: boolean; }; -// Which litellm provider buckets to search for each of our provider types. -// Several of our provider types (openRouter, vercelAiGateway, custom) proxy -// arbitrary underlying models and have no dedicated bucket in the pricing -// data, so they fall back to a global search across all buckets. -const PROVIDER_PRICING_BUCKETS: Record< +// Each of our provider types maps to at most one catalog provider. Provider types that proxy +// arbitrary underlying models (openRouter, vercelAiGateway, custom) have no +// mapping and always fall back to a global search. +const PROVIDER_CATALOG_MAP: Record< Exclude, - string[] + CatalogProvider | null > = { - openai: ["openai"], - anthropic: ["anthropic"], - googleGemini: ["gemini"], - vertexAi: [ - "vertex_ai-language-models", - "vertex_ai", - "vertex_ai-anthropic_models", - "vertex_ai-mistral_models", - "vertex_ai-deepseek_models", - "vertex_ai-ai21_models", - "vertex_ai-llama_models", - "vertex_ai-minimax_models", - "vertex_ai-moonshot_models", - "vertex_ai-zai_models", - "vertex_ai-openai_models", - "vertex_ai-qwen_models", - "vertex_ai-text-models" - ], - bedrock: ["bedrock_converse", "bedrock", "bedrock_mantle"], - microsoftFoundry: ["azure", "azure_ai", "azure_text"], - openRouter: [], - vercelAiGateway: [] + openai: "openai", + anthropic: "anthropic", + googleGemini: "gemini", + vertexAi: "vertex", + bedrock: "bedrock", + microsoftFoundry: "azure", + openRouter: null, + vercelAiGateway: null }; -let modelsById: Map | null = null; +let modelsByName: Map | null = null; -function loadModels(): Map { - if (modelsById) { - return modelsById; +function loadModels(): Map { + if (modelsByName) { + return modelsByName; } - const byId = new Map(); + const byName = new Map(); try { if (fs.existsSync(MODELS_JSON_PATH)) { const raw = fs.readFileSync(MODELS_JSON_PATH, "utf-8"); - const parsed = JSON.parse(raw) as { data: AiModelPricingEntry[] }; + const parsed = JSON.parse(raw) as { data: AiModelCatalogEntry[] }; for (const entry of parsed.data ?? []) { - for (const key of [entry.id, entry.name]) { - if (!key) continue; - const list = byId.get(key) ?? []; - list.push(entry); - byId.set(key, list); - } + if (!entry.model) continue; + const list = byName.get(entry.model) ?? []; + list.push(entry); + byName.set(entry.model, list); } } else { logger.debug( @@ -97,8 +83,8 @@ function loadModels(): Map { logger.warn("Failed to load AI model pricing file", { error }); } - modelsById = byId; - return byId; + modelsByName = byName; + return byName; } function stripVendorPrefix(modelId: string): string | null { @@ -110,32 +96,32 @@ function stripVendorPrefix(modelId: string): string | null { } function toPricing( - entry: AiModelPricingEntry, + entry: AiModelCatalogEntry, approximate: boolean ): AiModelPricing { return { - inputCostPerToken: entry.input_cost_per_token, - outputCostPerToken: entry.output_cost_per_token, - cacheReadInputTokenCost: entry.cache_read_input_token_cost, - outputCostPerReasoningToken: entry.output_cost_per_reasoning_token, + inputCostPerToken: entry.pricing.input, + outputCostPerToken: entry.pricing.output, + cacheReadInputTokenCost: entry.pricing.cacheRead, + outputCostPerReasoningToken: entry.pricing.reasoningOutput, approximate }; } -function findInBuckets( - byId: Map, +function findEntry( + byName: Map, modelId: string, - buckets: string[] | null -): AiModelPricingEntry | null { + provider: CatalogProvider | null +): AiModelCatalogEntry | null { const candidates = [modelId, stripVendorPrefix(modelId)].filter( (v): v is string => v != null ); for (const key of candidates) { - const entries = byId.get(key); + const entries = byName.get(key); if (!entries) continue; - const match = buckets - ? entries.find((e) => buckets.includes(e.provider)) + const match = provider + ? entries.find((e) => e.provider === provider) : entries[0]; if (match) { return match; @@ -145,10 +131,10 @@ function findInBuckets( } /** - * Looks up per-token pricing for a model, scoped first to the litellm - * provider bucket(s) that correspond to our provider type, then falling - * back to a global search across all buckets (marked `approximate`) for - * provider types that proxy arbitrary underlying models. + * Looks up per-token pricing for a model, scoped first to the catalog + * provider that corresponds to our provider type, then falling back to a + * global search across every provider (marked `approximate`) for provider + * types that proxy arbitrary underlying models. */ export function getModelPricing( providerType: AiProviderType, @@ -158,20 +144,18 @@ export function getModelPricing( return null; } - const byId = loadModels(); - const buckets = - providerType === "custom" - ? [] - : PROVIDER_PRICING_BUCKETS[providerType]; + const byName = loadModels(); + const catalogProvider = + providerType === "custom" ? null : PROVIDER_CATALOG_MAP[providerType]; - if (buckets && buckets.length > 0) { - const scoped = findInBuckets(byId, modelId, buckets); + if (catalogProvider) { + const scoped = findEntry(byName, modelId, catalogProvider); if (scoped) { return toPricing(scoped, false); } } - const fallback = findInBuckets(byId, modelId, null); + const fallback = findEntry(byName, modelId, null); if (fallback) { return toPricing(fallback, true); } @@ -191,9 +175,9 @@ export type AiCostBreakdown = { /** * Computes a $ cost breakdown for a usage record given a model's pricing. * Cache writes and reasoning tokens fall back to the normal input/output - * rate respectively when the pricing data has no dedicated rate for them - * (the models.json schema here has no cache-write field at all, and only - * some models report a distinct reasoning rate). + * rate respectively when the catalog has no dedicated rate for them (the + * catalog has no cache-write field at all, and only some models report a + * distinct reasoning rate). */ export function calculateAiCost( pricing: AiModelPricing | null, From fe5831eb48087442fbafc266c67c0ec943b38a35 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 7 Aug 2026 14:36:46 -0400 Subject: [PATCH 253/494] pull the models from the upstream server --- server/index.ts | 2 + server/lib/aiModelCatalog.ts | 111 +++++++++++++++++++++++++++++++++++ server/lib/aiModelPricing.ts | 70 +++++++--------------- server/lib/readConfigFile.ts | 30 ++++++++++ 4 files changed, 165 insertions(+), 48 deletions(-) create mode 100644 server/lib/aiModelCatalog.ts diff --git a/server/index.ts b/server/index.ts index 3d9306d2b..62ee28b0b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -28,6 +28,7 @@ import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync"; import { fetchServerIp } from "@server/lib/serverIpService"; import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations"; +import { initAiModelCatalog } from "@server/lib/aiModelCatalog"; async function startServers() { await setHostMeta(); @@ -46,6 +47,7 @@ async function startServers() { initLogCleanupInterval(); initAcmeCertSync(); startRebuildQueueProcessor(); + await initAiModelCatalog(); // Start all servers const apiServer = createApiServer(); diff --git a/server/lib/aiModelCatalog.ts b/server/lib/aiModelCatalog.ts new file mode 100644 index 000000000..39b3a70c9 --- /dev/null +++ b/server/lib/aiModelCatalog.ts @@ -0,0 +1,111 @@ +import fs from "node:fs"; +import axios from "axios"; +import config from "@server/lib/config"; +import logger from "@server/logger"; + +export type CatalogProvider = + | "openai" + | "anthropic" + | "gemini" + | "vertex" + | "azure" + | "bedrock"; + +export type AiModelCatalogEntry = { + provider: CatalogProvider; + model: string; + pricing: { + input: number | null; + output: number | null; + cacheRead: number | null; + reasoningOutput: number | null; + }; +}; + +let catalog: AiModelCatalogEntry[] = []; +let refreshTimer: NodeJS.Timeout | null = null; + +async function fetchFromFile(filePath: string): Promise { + try { + if (!fs.existsSync(filePath)) { + logger.warn( + `AI model catalog file not found at ${filePath}; cost calculation will fall back to unknown pricing` + ); + return null; + } + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as { data: AiModelCatalogEntry[] }; + return parsed.data ?? []; + } catch (error) { + logger.warn("Failed to read AI model catalog file", { error }); + return null; + } +} + +async function fetchFromUpstream( + upstreamUrl: string +): Promise { + try { + const res = await axios.get<{ data: AiModelCatalogEntry[] }>( + upstreamUrl, + { timeout: 15_000 } + ); + return res.data?.data ?? []; + } catch (error: any) { + logger.warn( + `Failed to fetch AI model catalog from ${upstreamUrl}: ${error.message || error}` + ); + return null; + } +} + +async function refreshCatalog(): Promise { + const { file, upstream_url } = config.getRawConfig().ai.model_catalog; + + const fetched = file + ? await fetchFromFile(file) + : await fetchFromUpstream(upstream_url); + + if (fetched) { + catalog = fetched; + logger.debug( + `AI model catalog refreshed: ${catalog.length} models loaded` + ); + } else { + logger.debug( + "AI model catalog refresh failed; keeping previously loaded catalog in memory" + ); + } +} + +function scheduleNextRefresh(): void { + const { refresh_interval_min_hours, refresh_interval_max_hours } = + config.getRawConfig().ai.model_catalog; + + // Jittered rather than fixed so that many self-hosted instances don't + // all hit the upstream catalog endpoint at the same moment. + const minMs = refresh_interval_min_hours * 60 * 60 * 1000; + const maxMs = refresh_interval_max_hours * 60 * 60 * 1000; + const delayMs = minMs + Math.random() * Math.max(0, maxMs - minMs); + + if (refreshTimer) { + clearTimeout(refreshTimer); + } + refreshTimer = setTimeout(async () => { + await refreshCatalog(); + scheduleNextRefresh(); + }, delayMs); +} + +/** + * Loads the AI model pricing catalog into memory and schedules periodic + * background refreshes. Call once at server startup. + */ +export async function initAiModelCatalog(): Promise { + await refreshCatalog(); + scheduleNextRefresh(); +} + +export function getAiModelCatalog(): AiModelCatalogEntry[] { + return catalog; +} diff --git a/server/lib/aiModelPricing.ts b/server/lib/aiModelPricing.ts index 8c0eb6739..873e4213a 100644 --- a/server/lib/aiModelPricing.ts +++ b/server/lib/aiModelPricing.ts @@ -1,30 +1,10 @@ -import fs from "node:fs"; -import path from "node:path"; -import { APP_PATH } from "@server/lib/consts"; import type { AiProviderType } from "@server/lib/aiProviderDefaults"; import type { AiUsage } from "@server/lib/aiUsageExtraction"; -import logger from "@server/logger"; - -const MODELS_JSON_PATH = path.join(APP_PATH, "models.json"); - -export type CatalogProvider = - | "openai" - | "anthropic" - | "gemini" - | "vertex" - | "azure" - | "bedrock"; - -export type AiModelCatalogEntry = { - provider: CatalogProvider; - model: string; - pricing: { - input: number | null; - output: number | null; - cacheRead: number | null; - reasoningOutput: number | null; - }; -}; +import { + getAiModelCatalog, + type AiModelCatalogEntry, + type CatalogProvider +} from "@server/lib/aiModelCatalog"; export type AiModelPricing = { inputCostPerToken: number | null; @@ -56,34 +36,28 @@ const PROVIDER_CATALOG_MAP: Record< vercelAiGateway: null }; -let modelsByName: Map | null = null; +// Indexed view over the in-memory catalog, rebuilt only when +// getAiModelCatalog() returns a different array instance (i.e. after a +// background refresh swaps it out), not on every lookup. +let indexedCatalog: AiModelCatalogEntry[] | null = null; +let indexedByName: Map = new Map(); -function loadModels(): Map { - if (modelsByName) { - return modelsByName; +function getIndexedCatalog(): Map { + const catalog = getAiModelCatalog(); + if (catalog === indexedCatalog) { + return indexedByName; } const byName = new Map(); - try { - if (fs.existsSync(MODELS_JSON_PATH)) { - const raw = fs.readFileSync(MODELS_JSON_PATH, "utf-8"); - const parsed = JSON.parse(raw) as { data: AiModelCatalogEntry[] }; - for (const entry of parsed.data ?? []) { - if (!entry.model) continue; - const list = byName.get(entry.model) ?? []; - list.push(entry); - byName.set(entry.model, list); - } - } else { - logger.debug( - `AI model pricing file not found at ${MODELS_JSON_PATH}; cost calculation will fall back to unknown pricing` - ); - } - } catch (error) { - logger.warn("Failed to load AI model pricing file", { error }); + for (const entry of catalog) { + if (!entry.model) continue; + const list = byName.get(entry.model) ?? []; + list.push(entry); + byName.set(entry.model, list); } - modelsByName = byName; + indexedCatalog = catalog; + indexedByName = byName; return byName; } @@ -144,7 +118,7 @@ export function getModelPricing( return null; } - const byName = loadModels(); + const byName = getIndexedCatalog(); const catalogProvider = providerType === "custom" ? null : PROVIDER_CATALOG_MAP[providerType]; diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 37418ed2f..d5c52f13a 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -401,6 +401,36 @@ export const configSchema = z disable_enterprise_features: z.boolean().optional() }) .optional(), + ai: z + .object({ + model_catalog: z + .object({ + upstream_url: z + .url() + .optional() + .default("https://api.fossorial.io/api/v1/models"), + // No default - only used when an operator wants to + // pin the catalog to a local file instead of + // fetching it from upstream_url. + file: z.string().optional(), + refresh_interval_min_hours: z + .number() + .positive() + .gt(0) + .optional() + .default(6), + refresh_interval_max_hours: z + .number() + .positive() + .gt(0) + .optional() + .default(12) + }) + .optional() + .prefault({}) + }) + .optional() + .prefault({}), dns: z .object({ nameservers: z From dc1923ab1fc603c30df4142e3a01c8fdc74f76cb Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 09:56:31 -0400 Subject: [PATCH 254/494] Remove budget periods --- server/db/pg/schema/schema.ts | 28 ++++++++++++---------------- server/db/sqlite/schema/schema.ts | 30 ++++++++++++++---------------- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 1c3d21b10..aa8b004ce 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1,3 +1,4 @@ +import { aiBudgetPeriods } from "@server/db/sqlite"; import { randomUUID } from "crypto"; import { InferSelectModel, sql } from "drizzle-orm"; import { @@ -1721,10 +1722,20 @@ export const aiBudgets = pgTable( () => siteResources.siteResourceId, { onDelete: "cascade" } ), + roleId: integer("roleId").references(() => roles.roleId, { + onDelete: "cascade" + }), amount: real("amount").notNull(), unit: varchar("unit").$type<"usd" | "tokens">().notNull(), period: varchar("period") - .$type<"monthly">() + .$type< + | "monthly" + | "yearly" + | "lifetime" + | "daily" + | "hourly" + | "weekly" + >() .notNull() .default("monthly"), enforcement: varchar("enforcement") @@ -1752,20 +1763,6 @@ export const aiBudgets = pgTable( ] ); -export const aiBudgetPeriods = pgTable( - "aiBudgetPeriods", - { - periodId: serial("periodId").primaryKey(), - budgetId: integer("budgetId") - .notNull() - .references(() => aiBudgets.budgetId, { onDelete: "cascade" }), - periodStart: bigint("periodStart", { mode: "number" }).notNull(), - periodEnd: bigint("periodEnd", { mode: "number" }).notNull(), - usedAmount: real("usedAmount").notNull().default(0) - }, - (t) => [unique("ai_budget_period_start_uniq").on(t.budgetId, t.periodStart)] -); - export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -1853,7 +1850,6 @@ export type ResourcePolicyRule = InferSelectModel; export type AiProvider = InferSelectModel; export type AiModel = InferSelectModel; export type AiBudget = InferSelectModel; -export type AiBudgetPeriod = InferSelectModel; export type ResourceAiProvider = InferSelectModel; export type SiteResourceAiProvider = InferSelectModel< typeof siteResourceAiProviders diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 71fd197e7..c8d0042df 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1705,9 +1705,22 @@ export const aiBudgets = sqliteTable( () => siteResources.siteResourceId, { onDelete: "cascade" } ), + roleId: integer("roleId").references(() => roles.roleId, { + onDelete: "cascade" + }), amount: real("amount").notNull(), unit: text("unit").$type<"usd" | "tokens">().notNull(), - period: text("period").$type<"monthly">().notNull().default("monthly"), + period: text("period") + .$type< + | "monthly" + | "yearly" + | "lifetime" + | "daily" + | "hourly" + | "weekly" + >() + .notNull() + .default("monthly"), enforcement: text("enforcement") .$type<"hard" | "soft">() .notNull() @@ -1726,20 +1739,6 @@ export const aiBudgets = sqliteTable( ] ); -export const aiBudgetPeriods = sqliteTable( - "aiBudgetPeriods", - { - periodId: integer("periodId").primaryKey({ autoIncrement: true }), - budgetId: integer("budgetId") - .notNull() - .references(() => aiBudgets.budgetId, { onDelete: "cascade" }), - periodStart: integer("periodStart").notNull(), - periodEnd: integer("periodEnd").notNull(), - usedAmount: real("usedAmount").notNull().default(0) - }, - (t) => [unique("ai_budget_period_start_uniq").on(t.budgetId, t.periodStart)] -); - export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -1825,7 +1824,6 @@ export type UserPolicy = InferSelectModel; export type AiProvider = InferSelectModel; export type AiModel = InferSelectModel; export type AiBudget = InferSelectModel; -export type AiBudgetPeriod = InferSelectModel; export type ResourceAiProvider = InferSelectModel; export type SiteResourceAiProvider = InferSelectModel< typeof siteResourceAiProviders From 996160fadcdcedf49a54676ef0721ba2586261bb Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 10 Aug 2026 10:38:40 -0400 Subject: [PATCH 255/494] add missing resource policy actions to api key closes #3542 --- messages/en-US.json | 3 +++ server/routers/integration.ts | 8 ++++---- src/components/PermissionsSelectBox.tsx | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index aff3be28b..eed3ef5b4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1449,8 +1449,11 @@ "actionSetResourcePincode": "Set Resource Pincode", "actionSetResourceEmailWhitelist": "Set Resource Email Whitelist", "actionGetResourceEmailWhitelist": "Get Resource Email Whitelist", + "actionListResourcePolicies": "List Resource Policies", + "actionCreateResourcePolicy": "Create Resource Policy", "actionGetResourcePolicy": "Get Resource Policy", "actionUpdateResourcePolicy": "Update Resource Policy", + "actionDeleteResourcePolicy": "Delete Resource Policy", "actionSetResourcePolicyUsers": "Set Resource Policy Users", "actionSetResourcePolicyRoles": "Set Resource Policy Roles", "actionSetResourcePolicyPassword": "Set Resource Policy Password", diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 13498ad25..b28c47a7a 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -726,8 +726,8 @@ authenticated.post( verifyApiKeyResourcePolicyAccess, verifyApiKeyRoleAccess, verifyLimits, - verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), - verifyUserHasAction(ActionsEnum.setResourcePolicyRoles), + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyUsers), + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRoles), logActionAudit(ActionsEnum.setResourcePolicyUsers), logActionAudit(ActionsEnum.setResourcePolicyRoles), policy.setResourcePolicyAccessControl @@ -742,8 +742,8 @@ authenticated.put( verifyApiKeyResourcePolicyAccess, verifyApiKeyRoleAccess, verifyLimits, - verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), - verifyUserHasAction(ActionsEnum.setResourcePolicyRoles), + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyUsers), + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRoles), logActionAudit(ActionsEnum.setResourcePolicyUsers), logActionAudit(ActionsEnum.setResourcePolicyRoles), policy.setResourcePolicyAccessControl diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 48868722e..486841d14 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -115,8 +115,11 @@ function getActionsCategories(root: boolean) { }, "Resource Policy": { + [t("actionListResourcePolicies")]: "listResourcePolicies", + [t("actionCreateResourcePolicy")]: "createResourcePolicy", [t("actionGetResourcePolicy")]: "getResourcePolicy", [t("actionUpdateResourcePolicy")]: "updateResourcePolicy", + [t("actionDeleteResourcePolicy")]: "deleteResourcePolicy", [t("actionSetResourcePolicyUsers")]: "setResourcePolicyUsers", [t("actionSetResourcePolicyRoles")]: "setResourcePolicyRoles", [t("actionSetResourcePolicyPassword")]: "setResourcePolicyPassword", From 02e97d6ae4f59f4b2098e70724f6db8ee729cfd6 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 10 Aug 2026 10:44:25 -0400 Subject: [PATCH 256/494] add copy button to org id --- src/components/OrgInfoCard.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/OrgInfoCard.tsx b/src/components/OrgInfoCard.tsx index 796855609..392a5f17c 100644 --- a/src/components/OrgInfoCard.tsx +++ b/src/components/OrgInfoCard.tsx @@ -8,6 +8,7 @@ import { InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; import { useTranslations } from "next-intl"; type OrgInfoCardProps = {}; @@ -26,7 +27,9 @@ export default function OrgInfoCard({}: OrgInfoCardProps) { {t("orgId")} - {org.org.orgId} + + + {t("subnet")} From 591caab45a9902df40cd0c35d8ce2573f48791ad Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 10:47:18 -0400 Subject: [PATCH 257/494] Add documentation for creating new crud endpoints --- docs/crud-endpoints.md | 347 ++++++++++++++++++++++++++++++++++ server/db/pg/schema/schema.ts | 10 - 2 files changed, 347 insertions(+), 10 deletions(-) create mode 100644 docs/crud-endpoints.md diff --git a/docs/crud-endpoints.md b/docs/crud-endpoints.md new file mode 100644 index 000000000..0436780cf --- /dev/null +++ b/docs/crud-endpoints.md @@ -0,0 +1,347 @@ +# How to build a CRUD endpoint in this repo + +Reference for adding a new CRUD entity to the server. Based on two real +examples already in the codebase — read them side by side with this doc: + +- **Public / open-source (Community Edition) pattern**: `server/routers/aiProvider/` +- **Enterprise-only pattern**: `server/private/routers/alertRule/` + +The two are structurally identical. The only difference is *where the files +live* and *which router they get wired into*. + +## 1. Decide: public or private? + +- `server/routers//` — ships in the open-source Community Edition. + Anyone running Pangolin gets this. +- `server/private/routers//` — Enterprise/SaaS only. Gated behind + `verifyValidLicense` (and often `verifyValidSubscription(tierMatrix.x)`). + Every file here starts with the Fossorial Commercial License header block + (copy it verbatim from an existing private file). + +Everything below applies to both — swap `@server/...` for `#private/...` +import paths and add license headers when building the private version. + +## 2. Directory layout + +One folder per entity, one file per operation, a barrel `index.ts`: + +``` +server/routers// + index.ts # export * from each operation file + ./types + types.ts # response payload types + row->public mapper + validation.ts # zod schemas/refinements shared by create + update (optional) + create.ts + list.ts + get.ts + update.ts + delete.ts +``` + +`index.ts` is a flat barrel: + +```ts +export * from "./createAiProvider"; +export * from "./listAiProviders"; +export * from "./getAiProvider"; +export * from "./updateAiProvider"; +export * from "./deleteAiProvider"; +export * from "./types"; +``` + +## 3. Anatomy of a single handler + +Every handler file (`create.ts`, etc.) follows the same shape: + +```ts +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { , db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq } from "drizzle-orm"; +import type { GetXResponse } from "@server/routers//types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() // or entityId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ /* ... */ }); // create/update only + +registry.registerPath({ + method: "get", // put | post | delete + path: "/org/{orgId}/x", + description: "...", + tags: [OpenAPITags.], + request: { params: paramsSchema, /* body: {...} for write ops, query: for list */ }, + responses: { 200: { description: "Successful response" } } +}); + +export async function getX(req: Request, res: Response, next: NextFunction): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next(createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString())); + } + // parse body too, if present, same pattern + + // ...business logic against db... + + if (!row) { + return next(createHttpError(HttpCode.NOT_FOUND, `X with ID ${id} not found`)); + } + + return response(res, { + data: { /* ... */ }, + success: true, + error: false, + message: "X retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")); + } +} +``` + +Rules to keep consistent with the rest of the codebase: + +- `z.strictObject` for params/body — rejects unknown keys. +- Params parsed first, then body; each on its own `safeParse` + early + `next(createHttpError(...))` — never throw raw errors. +- Every handler registers itself with the OpenAPI `registry` even if nobody + reads the spec directly — it's how `/api/v1/docs` stays accurate. +- Catch-all `try/catch` at the bottom: `logger.error(error)` + + generic `500` message. Never leak internal error details to the client. +- Use `response(res, { data, success, error, message, status })` from + `@server/lib/response` for every response, success or otherwise (errors go + through `next(createHttpError(...))` instead, not through `response`). +- If the route already ran an access-control middleware that fetched the row + (see §5), reuse it instead of re-querying: + `req.aiProvider && req.aiProvider.providerId === providerId ? [req.aiProvider] : await db.select()...` + +### List handler specifics + +Pagination is a fixed shape (`page`, `pageSize`, optional `query` for +search). See `listAiProviders.ts`: + +```ts +const listSchema = z.object({ + pageSize: z.coerce.number().int().positive().optional().catch(20).default(20), + page: z.coerce.number().int().min(0).optional().catch(1).default(1), + query: z.string().optional() +}); +``` + +Run the count query and the page query in `Promise.all`, and return +`PaginatedResponse<{ items: T[] }>` (`@server/types/Pagination`) with +`{ total, pageSize, page }`. + +### types.ts specifics + +- Define one response type per operation: `ListResponse`, + `GetResponse`, `CreateOrEditResponse` (create and update + commonly share a response shape). +- If the raw DB row needs to be shaped for clients (decrypting secrets, + parsing a serialized column, hiding a column), put a `toPublic()` + mapper here — see `toPublicAiProvider` for the pattern of stripping + `apiKey`/serialized columns and re-adding decrypted/parsed versions. + +### validation.ts specifics + +Only needed when create and update share non-trivial zod pieces (enums, +`superRefine` cross-field rules). Export the raw schemas (`z.enum([...])`) +and refinement functions, and import them into both `createX.ts` and +`updateX.ts` — see `aiProvider/validation.ts`'s +`refineProviderUpstreamFields`. + +## 4. Wire up an access-control middleware (for id-scoped routes) + +For routes scoped to a single row (`/x/:xId`, as opposed to +`/org/:orgId/x` create/list), add a `verifyAccess` middleware in +`server/middlewares/` (or `server/private/middlewares/` for enterprise-only +entities) and export it from that directory's `index.ts`. + +Pattern (`verifyAiProviderAccess.ts`): + +1. Read the id param, `Number.parseInt`/validate it. +2. Load the row by id. +3. `404` if it doesn't exist. +4. Resolve the row's `orgId`, then check/attach `req.userOrg` (query + `userOrgs` if not already on the request), `403` if the user isn't in + that org. +5. Run `checkOrgAccessPolicy` if `req.orgPolicyAllowed` hasn't been resolved + yet. +6. Set `req.userOrgId`, `req.userOrgRoleIds`, and stash the row on the + request (e.g. `req.aiProvider = provider`) so downstream handlers and + `verifyUserHasAction` don't have to refetch it. + +Org-scoped create/list routes (`/org/:orgId/x`) don't need a bespoke +middleware — they use the existing generic `verifyOrgAccess` from +`@server/middlewares`. + +## 5. Register an action + permission check + +Add one `ActionsEnum` entry per operation in `server/auth/actions.ts`, +grouped near the entity's other actions, named `create`, +`get`, `update`, `delete`, `list`: + +```ts +createAiProvider = "createAiProvider", +deleteAiProvider = "deleteAiProvider", +getAiProvider = "getAiProvider", +listAiProviders = "listAiProviders", +updateAiProvider = "updateAiProvider", +``` + +Every route uses `verifyUserHasAction(ActionsEnum.x)` to check the caller's +role/permissions for that action, and mutating routes (create/update/delete) +follow it with `logActionAudit(ActionsEnum.x)` to record the action in the +audit log. + +## 6. Register the routes + +There are four router files; which one(s) you touch depends on public vs. +private and user-facing vs. service-to-service: + +| File | Purpose | +|---|---| +| `server/routers/external.ts` | Public, user-facing API. Exports `authenticated`, `unauthenticated`, `authRouter` Express routers. | +| `server/routers/internal.ts` | Public, internal service-to-service API (gerbil, badger, traefik-config) — no user auth, exports `internalRouter`. | +| `server/private/routers/external.ts` | Enterprise-only, user-facing. Imports `authenticated`/`unauthenticated`/`authRouter` **from the public `external.ts`** and re-exports them, then adds more routes on top. | +| `server/private/routers/internal.ts` | Enterprise-only, service-to-service. Same re-export trick with `internalRouter`. | + +Private router files always start: + +```ts +import { + unauthenticated as ua, + authenticated as a, + authRouter as aa +} from "@server/routers/external"; + +export const authenticated = a; +export const unauthenticated = ua; +export const authRouter = aa; +``` + +...and then call `authenticated.get/put/post/delete(...)` to bolt on +additional, enterprise-only routes on the *same* router instances the public +build uses. This is why the private build has strictly more routes than the +public build, not a divergent copy. + +### Route registration order (mutating vs read) + +Standard middleware chain per verb, using `alertRule`'s registrations as the +template: + +```ts +// Create — org-scoped, no row exists yet +authenticated.put( + "/org/:orgId/x", + verifyValidLicense, // private/enterprise routes only + verifyOrgAccess, + verifyLimits, // if the entity counts against a plan limit + verifyUserHasAction(ActionsEnum.createX), + logActionAudit(ActionsEnum.createX), + x.createX +); + +// Update — row-scoped +authenticated.post( + "/org/:orgId/x/:xId", // or "/x/:xId" if id is globally unique + verifyValidLicense, + verifyOrgAccess, // or verifyXAccess if globally-keyed + verifyUserHasAction(ActionsEnum.updateX), + logActionAudit(ActionsEnum.updateX), + x.updateX +); + +// Delete — row-scoped +authenticated.delete( + "/org/:orgId/x/:xId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteX), + logActionAudit(ActionsEnum.deleteX), + x.deleteX +); + +// List — org-scoped, read-only, no audit log +authenticated.get( + "/org/:orgId/xs", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listXs), + x.listXs +); + +// Get one — row-scoped, read-only, no audit log +authenticated.get( + "/org/:orgId/x/:xId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getX), + x.getX +); +``` + +Notes: + +- HTTP verbs: `PUT` = create, `POST` = update, `GET` = read, `DELETE` = + delete. This repo does not use `PATCH` for entity updates (site + provisioning keys are the one exception, using `PATCH`). +- `verifyValidLicense` is only needed on private/enterprise routes; public + OSS routes skip it. +- Use `verifyValidSubscription(tierMatrix.someFeature)` right after + `verifyValidLicense` when a feature is gated to specific SaaS tiers (see + `tierMatrix` usages in `server/private/routers/external.ts`). +- `verifyLimits` goes on create routes for entities that count against a + plan/seat limit. +- For entities keyed by a globally-unique id (not nested under `/org/:orgId`), + use the dedicated `verifyAccess` middleware from §4 instead of + `verifyOrgAccess` on the row-scoped routes (see how `/ai-provider/:providerId` + uses `verifyAiProviderAccess`, while `/org/:orgId/ai-provider` create/list + use plain `verifyOrgAccess`). +- Read-only routes (`get`, `list`) skip `logActionAudit` — only mutations are + audited. +- `internal*.ts` routes are for trusted internal callers (gerbil/badger + sidecars) and generally skip user-facing auth entirely, using + `verifySessionUserMiddleware` / `verifyUserFromResourceSessionMiddleware` + instead of `verifyOrgAccess`/`verifyUserHasAction`. CRUD entities almost + never need internal router entries — only add one if a sidecar process + needs direct access to the resource. + +## 7. The `#dynamic` alias (advanced — most CRUD work can ignore this) + +Some middleware (e.g. `logActionAudit`) needs a real implementation in the +enterprise/SaaS build but a no-op stub in the open-source build, while +being imported by identical code in `server/routers/external.ts` in both +builds. That's done via the `#dynamic/*` import alias, which +`tsconfig.oss.json` points at `./server/*` and `tsconfig.enterprise.json` / +`tsconfig.saas.json` point at `./server/private/*`. You only need this +pattern if you're adding a genuinely dual-implementation hook; a normal +private-only CRUD entity (like `alertRule`) never touches `#dynamic` — it +just lives entirely under `server/private/` and is imported with `#private/*` +directly from `server/private/routers/external.ts`. + +## 8. Checklist for a new entity + +1. Add the DB table to `server/db/pg/schema/schema.ts` (and sqlite schema if + applicable). +2. Add `ActionsEnum` entries in `server/auth/actions.ts`. +3. Create `server/routers//` (or `server/private/routers//`): + `types.ts`, optional `validation.ts`, one file per operation, `index.ts` + barrel. +4. If routes are row-scoped by a global id, add + `verifyAccess.ts` to `server/middlewares/` or + `server/private/middlewares/`, and export it from that directory's + `index.ts`. +5. Wire routes into `external.ts` (public or private) following the verb/ + middleware table in §6. Add to `internal.ts` only if a sidecar needs + direct access. +6. Add license header block to every new file if it's under `server/private/`. diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index aa8b004ce..e2eb50205 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1,4 +1,3 @@ -import { aiBudgetPeriods } from "@server/db/sqlite"; import { randomUUID } from "crypto"; import { InferSelectModel, sql } from "drizzle-orm"; import { @@ -1747,15 +1746,6 @@ export const aiBudgets = pgTable( updatedAt: bigint("updatedAt", { mode: "number" }).notNull() }, (t) => [ - check( - "ai_budget_one_scope", - sql`( - (CASE WHEN ${t.providerId} IS NOT NULL THEN 1 ELSE 0 END) + - (CASE WHEN ${t.modelId} IS NOT NULL THEN 1 ELSE 0 END) + - (CASE WHEN ${t.resourceId} IS NOT NULL THEN 1 ELSE 0 END) + - (CASE WHEN ${t.siteResourceId} IS NOT NULL THEN 1 ELSE 0 END) - ) = 1` - ), unique("ai_budget_provider_uniq").on(t.providerId), unique("ai_budget_model_uniq").on(t.modelId), unique("ai_budget_resource_uniq").on(t.resourceId), From 969e7c9296c32f46a7e9a7bbfad97087f4aa8259 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 10:58:11 -0400 Subject: [PATCH 258/494] add crud endpoint skill --- .claude/skills/crud-endpoints/SKILL.md | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .claude/skills/crud-endpoints/SKILL.md diff --git a/.claude/skills/crud-endpoints/SKILL.md b/.claude/skills/crud-endpoints/SKILL.md new file mode 100644 index 000000000..6536ea234 --- /dev/null +++ b/.claude/skills/crud-endpoints/SKILL.md @@ -0,0 +1,31 @@ +--- +name: crud-endpoints +description: Use whenever asked to add, create, or scaffold a CRUD endpoint, router, or entity in this repo's server (create/list/get/update/delete handlers, new `server/routers//` or `server/private/routers//` folder). Points to the established file layout, middleware, ActionsEnum, and route-registration conventions before writing any code. +--- + +Before writing any router/handler/middleware code for a new entity, read +`docs/crud-endpoints.md` in full. It documents, with real examples from +`server/routers/aiProvider/` (public) and `server/private/routers/alertRule/` +(enterprise-only), how this repo structures CRUD endpoints: + +- Directory/file layout per entity (`index.ts`, `types.ts`, `validation.ts`, + one file per operation). +- The standard handler anatomy (zod parsing, OpenAPI registry, response + envelope, error handling). +- Where access-control middleware (`verifyAccess`) lives and when + it's needed vs. plain `verifyOrgAccess`. +- How to wire up `ActionsEnum` entries, `verifyUserHasAction`, and + `logActionAudit`. +- Which of the four router files (`server/routers/external.ts`, + `server/routers/internal.ts`, `server/private/routers/external.ts`, + `server/private/routers/internal.ts`) to register routes in, and the + middleware chain template per HTTP verb. +- The repo's non-standard verb convention: **`PUT` = create, `POST` = + update** (backwards from typical REST) — don't "fix" this to standard + REST verbs, match the existing convention. +- The `#dynamic` import alias, for the rare case of a hook needing different + implementations in OSS vs. enterprise builds. + +Follow that doc's checklist (§8) step by step rather than improvising a +structure. If the doc and the actual code in `aiProvider`/`alertRule` ever +disagree, trust the code and flag the doc as stale. From 3dc9c100e92f6b5cfc525c582ede6657df010bb9 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 10 Aug 2026 11:02:44 -0400 Subject: [PATCH 259/494] better form feedback when saving roles --- .../users/[userId]/access-controls/page.tsx | 44 +++++---------- src/components/OrgRolesTagField.tsx | 55 ++----------------- 2 files changed, 18 insertions(+), 81 deletions(-) diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index a3ed1e2df..2f94e78da 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -38,18 +38,6 @@ import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -const accessControlsFormSchema = z.object({ - username: z.string(), - autoProvisioned: z.boolean(), - roles: z.array( - z.object({ - id: z.string(), - text: z.string(), - isAdmin: z.boolean().optional() - }) - ) -}); - export default function AccessControlsPage() { const { orgUser: user, updateOrgUser } = userOrgUserContext(); const { user: sessionUser } = useUserContext(); @@ -69,6 +57,20 @@ export default function AccessControlsPage() { (build === "enterprise" && !isPaid) || (build === "oss" && !isPaid)); + const accessControlsFormSchema = z.object({ + username: z.string(), + autoProvisioned: z.boolean(), + roles: z + .array( + z.object({ + id: z.string(), + text: z.string(), + isAdmin: z.boolean().optional() + }) + ) + .min(1, { message: t("accessRoleSelectPlease") }) + }); + const form = useForm({ resolver: zodResolver(accessControlsFormSchema), defaultValues: { @@ -108,15 +110,6 @@ export default function AccessControlsPage() { async function executeSave() { const values = form.getValues(); - if (values.roles.length === 0) { - toast({ - variant: "destructive", - title: t("accessRoleRequired"), - description: t("accessRoleSelectPlease") - }); - return; - } - setIsSaving(true); try { const roleIds = values.roles.map((r) => parseInt(r.id, 10)); @@ -170,15 +163,6 @@ export default function AccessControlsPage() { const values = form.getValues(); - if (values.roles.length === 0) { - toast({ - variant: "destructive", - title: t("accessRoleRequired"), - description: t("accessRoleSelectPlease") - }); - return; - } - const willHaveAdminRole = values.roles.some((r) => r.isAdmin === true); const isRemovingOwnAdmin = diff --git a/src/components/OrgRolesTagField.tsx b/src/components/OrgRolesTagField.tsx index eef1e0570..32d96794a 100644 --- a/src/components/OrgRolesTagField.tsx +++ b/src/components/OrgRolesTagField.tsx @@ -9,17 +9,15 @@ import { FormMessage } from "@app/components/ui/form"; -import { toast } from "@app/hooks/useToast"; import { useTranslations } from "next-intl"; -import { useRef } from "react"; import type { FieldValues, Path, UseFormReturn } from "react-hook-form"; import { RolesSelector, type SelectedRole } from "./roles-selector"; type OrgRolesTagFieldProps = { form: Pick< UseFormReturn, - "control" | "getValues" | "setValue" + "control" | "getValues" | "setValue" | "clearErrors" >; orgId: string; /** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */ @@ -42,46 +40,6 @@ export default function OrgRolesTagField({ disabled }: OrgRolesTagFieldProps) { const t = useTranslations(); - const isPopoverOpenRef = useRef(false); - const lastValidRolesRef = useRef( - (form.getValues(name) as SelectedRole[]) ?? [] - ); - - function validateRolesSelection() { - const current = form.getValues(name) as SelectedRole[]; - - if (current.length === 0 && lastValidRolesRef.current.length > 0) { - form.setValue(name, lastValidRolesRef.current as never, { - shouldDirty: true - }); - toast({ - variant: "destructive", - title: t("accessRoleRequired"), - description: t("accessRoleSelectPlease") - }); - return false; - } - - if (current.length > 0) { - lastValidRolesRef.current = current; - } - - return true; - } - - function handlePopoverOpenChange(open: boolean) { - isPopoverOpenRef.current = open; - - if (open) { - const current = form.getValues(name) as SelectedRole[]; - if (current.length > 0) { - lastValidRolesRef.current = current; - } - return; - } - - validateRolesSelection(); - } function setRoleTags(nextValue: SelectedRole[]) { const prev = form.getValues(name) as SelectedRole[]; @@ -99,15 +57,14 @@ export default function OrgRolesTagField({ form.setValue(name, [prev[prev.length - 1]] as never, { shouldDirty: true }); + form.clearErrors(name); return; } form.setValue(name, next as never, { shouldDirty: true }); - if (next.length > 0 && !isPopoverOpenRef.current) { - lastValidRolesRef.current = next; - } else if (!isPopoverOpenRef.current) { - validateRolesSelection(); + if (next.length > 0) { + form.clearErrors(name); } } @@ -117,9 +74,6 @@ export default function OrgRolesTagField({ name={name} render={({ field }) => { const selectedRoles = (field.value ?? []) as SelectedRole[]; - if (!isPopoverOpenRef.current && selectedRoles.length > 0) { - lastValidRolesRef.current = selectedRoles; - } return ( @@ -129,7 +83,6 @@ export default function OrgRolesTagField({ orgId={orgId} selectedRoles={selectedRoles} onSelectRoles={setRoleTags} - onPopoverOpenChange={handlePopoverOpenChange} disabled={disabled} /> From 0050fad70dbd6c74d0e564971f3b906423b57630 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 11:05:10 -0400 Subject: [PATCH 260/494] add basic crud for ai budgets --- server/auth/actions.ts | 7 +- server/index.ts | 2 + server/middlewares/index.ts | 1 + server/middlewares/verifyAiBudgetAccess.ts | 99 ++++++ server/openApi.ts | 3 +- server/routers/aiBudget/createAiBudget.ts | 252 +++++++++++++++ server/routers/aiBudget/deleteAiBudget.ts | 79 +++++ server/routers/aiBudget/getAiBudget.ts | 81 +++++ server/routers/aiBudget/index.ts | 6 + server/routers/aiBudget/listAiBudgets.ts | 130 ++++++++ server/routers/aiBudget/types.ts | 14 + server/routers/aiBudget/updateAiBudget.ts | 339 +++++++++++++++++++++ server/routers/aiBudget/validation.ts | 46 +++ server/routers/external.ts | 42 ++- 14 files changed, 1098 insertions(+), 3 deletions(-) create mode 100644 server/middlewares/verifyAiBudgetAccess.ts create mode 100644 server/routers/aiBudget/createAiBudget.ts create mode 100644 server/routers/aiBudget/deleteAiBudget.ts create mode 100644 server/routers/aiBudget/getAiBudget.ts create mode 100644 server/routers/aiBudget/index.ts create mode 100644 server/routers/aiBudget/listAiBudgets.ts create mode 100644 server/routers/aiBudget/types.ts create mode 100644 server/routers/aiBudget/updateAiBudget.ts create mode 100644 server/routers/aiBudget/validation.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 1b4a33e89..f9d781169 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -194,7 +194,12 @@ export enum ActionsEnum { deleteAiModel = "deleteAiModel", getAiModel = "getAiModel", listAiModels = "listAiModels", - updateAiModel = "updateAiModel" + updateAiModel = "updateAiModel", + createAiBudget = "createAiBudget", + deleteAiBudget = "deleteAiBudget", + getAiBudget = "getAiBudget", + listAiBudgets = "listAiBudgets", + updateAiBudget = "updateAiBudget" } export async function checkUserActionPermission( diff --git a/server/index.ts b/server/index.ts index 62ee28b0b..3eb16092a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,6 +10,7 @@ import { createIntegrationApiServer } from "./integrationApiServer"; import { ApiKey, ApiKeyOrg, + AiBudget, AiModel, AiProvider, RemoteExitNode, @@ -92,6 +93,7 @@ declare global { siteResource?: SiteResource; aiProvider?: AiProvider; aiModel?: AiModel; + aiBudget?: AiBudget; orgPolicyAllowed?: boolean; } } diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 8f6b50e33..7242ea0cf 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -29,6 +29,7 @@ export * from "./verifySiteProvisioningKeyAccess"; export * from "./verifyDomainAccess"; export * from "./verifyAiProviderAccess"; export * from "./verifyAiModelAccess"; +export * from "./verifyAiBudgetAccess"; export * from "./verifyUserIsOrgOwner"; export * from "./verifyUserFromResourceSession"; export * from "./verifySiteResourceAccess"; diff --git a/server/middlewares/verifyAiBudgetAccess.ts b/server/middlewares/verifyAiBudgetAccess.ts new file mode 100644 index 000000000..d0834057a --- /dev/null +++ b/server/middlewares/verifyAiBudgetAccess.ts @@ -0,0 +1,99 @@ +import { Request, Response, NextFunction } from "express"; +import { aiBudgets, db, userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyAiBudgetAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const budgetIdRaw = getFirstString(req.params.budgetId); + const budgetId = Number.parseInt(budgetIdRaw ?? "", 10); + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (Number.isNaN(budgetId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid budget ID") + ); + } + + const [budget] = await db + .select() + .from(aiBudgets) + .where(eq(aiBudgets.budgetId, budgetId)) + .limit(1); + + if (!budget) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI budget with ID ${budgetId} not found` + ) + ); + } + + const orgId = budget.orgId; + + if (!req.userOrg || req.userOrg.orgId !== orgId) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "" + (policyCheck.error || "Unknown error") + ) + ); + } + } + + req.userOrgId = orgId; + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); + req.aiBudget = budget; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying AI budget access" + ) + ); + } +} diff --git a/server/openApi.ts b/server/openApi.ts index 1bcaa23ab..920aaa471 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -30,7 +30,8 @@ export enum OpenAPITags { PublicResourceLegacy = "Public Resource (Legacy)", PrivateResourceLegacy = "Private Resource (Legacy)", AiProvider = "AI Provider", - AiModel = "AI Model" + AiModel = "AI Model", + AiBudget = "AI Budget" } // Order here controls the order tags are displayed in Swagger UI diff --git a/server/routers/aiBudget/createAiBudget.ts b/server/routers/aiBudget/createAiBudget.ts new file mode 100644 index 000000000..705167c3a --- /dev/null +++ b/server/routers/aiBudget/createAiBudget.ts @@ -0,0 +1,252 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + aiBudgets, + aiModels, + aiProviders, + db, + resources, + roles, + siteResources +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq } from "drizzle-orm"; +import type { CreateOrEditAiBudgetResponse } from "@server/routers/aiBudget/types"; +import { + aiBudgetEnforcementSchema, + aiBudgetPeriodSchema, + aiBudgetUnitSchema, + refineBudgetScopeFields +} from "@server/routers/aiBudget/validation"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z + .strictObject({ + providerId: z.coerce.number().int().positive().optional(), + modelId: z.coerce.number().int().positive().optional(), + resourceId: z.coerce.number().int().positive().optional(), + siteResourceId: z.coerce.number().int().positive().optional(), + roleId: z.coerce.number().int().positive().optional(), + amount: z.number().positive(), + unit: aiBudgetUnitSchema, + period: aiBudgetPeriodSchema.optional().default("monthly"), + enforcement: aiBudgetEnforcementSchema.optional().default("hard"), + enabled: z.boolean().optional() + }) + .superRefine((data, ctx) => refineBudgetScopeFields(data, ctx)); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/ai-budget", + description: "Create an AI budget for an organization.", + tags: [OpenAPITags.AiBudget], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 201: { + description: "Successful response" + } + } +}); + +export async function createAiBudget( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { + providerId, + modelId, + resourceId, + siteResourceId, + roleId, + amount, + unit, + period, + enforcement, + enabled + } = parsedBody.data; + + if (providerId !== undefined) { + const [provider] = await db + .select({ orgId: aiProviders.orgId }) + .from(aiProviders) + .where(eq(aiProviders.providerId, providerId)) + .limit(1); + if (!provider || provider.orgId !== orgId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found in this organization` + ) + ); + } + } + + if (modelId !== undefined) { + const [model] = await db + .select({ orgId: aiProviders.orgId }) + .from(aiModels) + .innerJoin( + aiProviders, + eq(aiModels.providerId, aiProviders.providerId) + ) + .where(eq(aiModels.modelId, modelId)) + .limit(1); + if (!model || model.orgId !== orgId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI model with ID ${modelId} not found in this organization` + ) + ); + } + } + + if (resourceId !== undefined) { + const [resource] = await db + .select({ orgId: resources.orgId }) + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + if (!resource || resource.orgId !== orgId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found in this organization` + ) + ); + } + } + + if (siteResourceId !== undefined) { + const [siteResource] = await db + .select({ orgId: siteResources.orgId }) + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + if (!siteResource || siteResource.orgId !== orgId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site resource with ID ${siteResourceId} not found in this organization` + ) + ); + } + } + + if (roleId !== undefined) { + const [role] = await db + .select({ orgId: roles.orgId }) + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); + if (!role || role.orgId !== orgId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Role with ID ${roleId} not found in this organization` + ) + ); + } + } + + const conflictCondition = + providerId !== undefined + ? eq(aiBudgets.providerId, providerId) + : modelId !== undefined + ? eq(aiBudgets.modelId, modelId) + : resourceId !== undefined + ? eq(aiBudgets.resourceId, resourceId) + : siteResourceId !== undefined + ? eq(aiBudgets.siteResourceId, siteResourceId) + : undefined; + + if (conflictCondition) { + const [existing] = await db + .select({ budgetId: aiBudgets.budgetId }) + .from(aiBudgets) + .where(conflictCondition) + .limit(1); + if (existing) { + return next( + createHttpError( + HttpCode.CONFLICT, + "A budget already exists for this scope" + ) + ); + } + } + + const now = Date.now(); + const [budget] = await db + .insert(aiBudgets) + .values({ + orgId, + providerId: providerId ?? null, + modelId: modelId ?? null, + resourceId: resourceId ?? null, + siteResourceId: siteResourceId ?? null, + roleId: roleId ?? null, + amount, + unit, + period, + enforcement, + enabled: enabled ?? true, + createdAt: now, + updatedAt: now + }) + .returning(); + + return response(res, { + data: { budget }, + success: true, + error: false, + message: "AI budget created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiBudget/deleteAiBudget.ts b/server/routers/aiBudget/deleteAiBudget.ts new file mode 100644 index 000000000..fc49ca3d4 --- /dev/null +++ b/server/routers/aiBudget/deleteAiBudget.ts @@ -0,0 +1,79 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiBudgets, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq } from "drizzle-orm"; + +const paramsSchema = z.strictObject({ + budgetId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "delete", + path: "/ai-budget/{budgetId}", + description: "Delete an AI budget.", + tags: [OpenAPITags.AiBudget], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function deleteAiBudget( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { budgetId } = parsedParams.data; + + const [existing] = await db + .select({ budgetId: aiBudgets.budgetId }) + .from(aiBudgets) + .where(eq(aiBudgets.budgetId, budgetId)) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI budget with ID ${budgetId} not found` + ) + ); + } + + await db.delete(aiBudgets).where(eq(aiBudgets.budgetId, budgetId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "AI budget deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiBudget/getAiBudget.ts b/server/routers/aiBudget/getAiBudget.ts new file mode 100644 index 000000000..e5435101f --- /dev/null +++ b/server/routers/aiBudget/getAiBudget.ts @@ -0,0 +1,81 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiBudgets, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq } from "drizzle-orm"; +import type { GetAiBudgetResponse } from "@server/routers/aiBudget/types"; + +const paramsSchema = z.strictObject({ + budgetId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "get", + path: "/ai-budget/{budgetId}", + description: "Get an AI budget by ID.", + tags: [OpenAPITags.AiBudget], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function getAiBudget( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { budgetId } = parsedParams.data; + + const [budget] = + req.aiBudget && req.aiBudget.budgetId === budgetId + ? [req.aiBudget] + : await db + .select() + .from(aiBudgets) + .where(eq(aiBudgets.budgetId, budgetId)) + .limit(1); + + if (!budget) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI budget with ID ${budgetId} not found` + ) + ); + } + + return response(res, { + data: { budget }, + success: true, + error: false, + message: "AI budget retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiBudget/index.ts b/server/routers/aiBudget/index.ts new file mode 100644 index 000000000..0da53e0d2 --- /dev/null +++ b/server/routers/aiBudget/index.ts @@ -0,0 +1,6 @@ +export * from "./createAiBudget"; +export * from "./listAiBudgets"; +export * from "./getAiBudget"; +export * from "./updateAiBudget"; +export * from "./deleteAiBudget"; +export * from "./types"; diff --git a/server/routers/aiBudget/listAiBudgets.ts b/server/routers/aiBudget/listAiBudgets.ts new file mode 100644 index 000000000..f71666140 --- /dev/null +++ b/server/routers/aiBudget/listAiBudgets.ts @@ -0,0 +1,130 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiBudgets, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { asc, eq } from "drizzle-orm"; +import type { ListAiBudgetsResponse } from "@server/routers/aiBudget/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const listSchema = z.object({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() + .int() + .min(0) + .optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }) +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/ai-budgets", + description: "List AI budgets for an organization.", + tags: [OpenAPITags.AiBudget], + request: { + params: paramsSchema, + query: listSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listAiBudgets( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { pageSize, page } = parsedQuery.data; + + const baseQuery = db + .select() + .from(aiBudgets) + .where(eq(aiBudgets.orgId, orgId)); + + const countQuery = db.$count( + db + .select() + .from(aiBudgets) + .where(eq(aiBudgets.orgId, orgId)) + .as("filtered_ai_budgets") + ); + + const [totalCount, rows] = await Promise.all([ + countQuery, + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(aiBudgets.budgetId)) + ]); + + return response(res, { + data: { + budgets: rows, + pagination: { + total: totalCount, + pageSize, + page + } + }, + success: true, + error: false, + message: "AI budgets retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiBudget/types.ts b/server/routers/aiBudget/types.ts new file mode 100644 index 000000000..11ffd1050 --- /dev/null +++ b/server/routers/aiBudget/types.ts @@ -0,0 +1,14 @@ +import type { AiBudget } from "@server/db"; +import type { PaginatedResponse } from "@server/types/Pagination"; + +export type ListAiBudgetsResponse = PaginatedResponse<{ + budgets: AiBudget[]; +}>; + +export type GetAiBudgetResponse = { + budget: AiBudget; +}; + +export type CreateOrEditAiBudgetResponse = { + budget: AiBudget; +}; diff --git a/server/routers/aiBudget/updateAiBudget.ts b/server/routers/aiBudget/updateAiBudget.ts new file mode 100644 index 000000000..e8bd845c5 --- /dev/null +++ b/server/routers/aiBudget/updateAiBudget.ts @@ -0,0 +1,339 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + aiBudgets, + aiModels, + aiProviders, + db, + resources, + roles, + siteResources +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq, ne } from "drizzle-orm"; +import type { CreateOrEditAiBudgetResponse } from "@server/routers/aiBudget/types"; +import { + aiBudgetEnforcementSchema, + aiBudgetPeriodSchema, + aiBudgetUnitSchema, + refineBudgetScopeFields +} from "@server/routers/aiBudget/validation"; + +const paramsSchema = z.strictObject({ + budgetId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + providerId: z.coerce.number().int().positive().nullable().optional(), + modelId: z.coerce.number().int().positive().nullable().optional(), + resourceId: z.coerce.number().int().positive().nullable().optional(), + siteResourceId: z.coerce.number().int().positive().nullable().optional(), + roleId: z.coerce.number().int().positive().nullable().optional(), + amount: z.number().positive().optional(), + unit: aiBudgetUnitSchema.optional(), + period: aiBudgetPeriodSchema.optional(), + enforcement: aiBudgetEnforcementSchema.optional(), + enabled: z.boolean().optional() +}); + +registry.registerPath({ + method: "post", + path: "/ai-budget/{budgetId}", + description: "Update an AI budget.", + tags: [OpenAPITags.AiBudget], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function updateAiBudget( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { budgetId } = parsedParams.data; + const body = parsedBody.data; + + const [existing] = + req.aiBudget && req.aiBudget.budgetId === budgetId + ? [req.aiBudget] + : await db + .select() + .from(aiBudgets) + .where(eq(aiBudgets.budgetId, budgetId)) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI budget with ID ${budgetId} not found` + ) + ); + } + + const orgId = existing.orgId; + + const nextProviderId = + body.providerId !== undefined + ? body.providerId + : existing.providerId; + const nextModelId = + body.modelId !== undefined ? body.modelId : existing.modelId; + const nextResourceId = + body.resourceId !== undefined + ? body.resourceId + : existing.resourceId; + const nextSiteResourceId = + body.siteResourceId !== undefined + ? body.siteResourceId + : existing.siteResourceId; + const nextRoleId = + body.roleId !== undefined ? body.roleId : existing.roleId; + + const scopeValidation = z + .object({ + providerId: z.number().nullable().optional(), + modelId: z.number().nullable().optional(), + resourceId: z.number().nullable().optional(), + siteResourceId: z.number().nullable().optional(), + roleId: z.number().nullable().optional() + }) + .superRefine((data, ctx) => refineBudgetScopeFields(data, ctx)) + .safeParse({ + providerId: nextProviderId, + modelId: nextModelId, + resourceId: nextResourceId, + siteResourceId: nextSiteResourceId, + roleId: nextRoleId + }); + + if (!scopeValidation.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(scopeValidation.error).toString() + ) + ); + } + + if (body.providerId !== undefined && body.providerId !== null) { + const [provider] = await db + .select({ orgId: aiProviders.orgId }) + .from(aiProviders) + .where(eq(aiProviders.providerId, body.providerId)) + .limit(1); + if (!provider || provider.orgId !== orgId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${body.providerId} not found in this organization` + ) + ); + } + } + + if (body.modelId !== undefined && body.modelId !== null) { + const [model] = await db + .select({ orgId: aiProviders.orgId }) + .from(aiModels) + .innerJoin( + aiProviders, + eq(aiModels.providerId, aiProviders.providerId) + ) + .where(eq(aiModels.modelId, body.modelId)) + .limit(1); + if (!model || model.orgId !== orgId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI model with ID ${body.modelId} not found in this organization` + ) + ); + } + } + + if (body.resourceId !== undefined && body.resourceId !== null) { + const [resource] = await db + .select({ orgId: resources.orgId }) + .from(resources) + .where(eq(resources.resourceId, body.resourceId)) + .limit(1); + if (!resource || resource.orgId !== orgId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${body.resourceId} not found in this organization` + ) + ); + } + } + + if ( + body.siteResourceId !== undefined && + body.siteResourceId !== null + ) { + const [siteResource] = await db + .select({ orgId: siteResources.orgId }) + .from(siteResources) + .where(eq(siteResources.siteResourceId, body.siteResourceId)) + .limit(1); + if (!siteResource || siteResource.orgId !== orgId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site resource with ID ${body.siteResourceId} not found in this organization` + ) + ); + } + } + + if (body.roleId !== undefined && body.roleId !== null) { + const [role] = await db + .select({ orgId: roles.orgId }) + .from(roles) + .where(eq(roles.roleId, body.roleId)) + .limit(1); + if (!role || role.orgId !== orgId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Role with ID ${body.roleId} not found in this organization` + ) + ); + } + } + + const conflictCondition = + body.providerId !== undefined && body.providerId !== null + ? and( + eq(aiBudgets.providerId, body.providerId), + ne(aiBudgets.budgetId, budgetId) + ) + : body.modelId !== undefined && body.modelId !== null + ? and( + eq(aiBudgets.modelId, body.modelId), + ne(aiBudgets.budgetId, budgetId) + ) + : body.resourceId !== undefined && body.resourceId !== null + ? and( + eq(aiBudgets.resourceId, body.resourceId), + ne(aiBudgets.budgetId, budgetId) + ) + : body.siteResourceId !== undefined && + body.siteResourceId !== null + ? and( + eq(aiBudgets.siteResourceId, body.siteResourceId), + ne(aiBudgets.budgetId, budgetId) + ) + : undefined; + + if (conflictCondition) { + const [conflict] = await db + .select({ budgetId: aiBudgets.budgetId }) + .from(aiBudgets) + .where(conflictCondition) + .limit(1); + if (conflict) { + return next( + createHttpError( + HttpCode.CONFLICT, + "A budget already exists for this scope" + ) + ); + } + } + + const updateData: Partial = { + updatedAt: Date.now() + }; + + if (body.providerId !== undefined) { + updateData.providerId = body.providerId; + } + if (body.modelId !== undefined) { + updateData.modelId = body.modelId; + } + if (body.resourceId !== undefined) { + updateData.resourceId = body.resourceId; + } + if (body.siteResourceId !== undefined) { + updateData.siteResourceId = body.siteResourceId; + } + if (body.roleId !== undefined) { + updateData.roleId = body.roleId; + } + if (body.amount !== undefined) { + updateData.amount = body.amount; + } + if (body.unit !== undefined) { + updateData.unit = body.unit; + } + if (body.period !== undefined) { + updateData.period = body.period; + } + if (body.enforcement !== undefined) { + updateData.enforcement = body.enforcement; + } + if (body.enabled !== undefined) { + updateData.enabled = body.enabled; + } + + const [budget] = await db + .update(aiBudgets) + .set(updateData) + .where(eq(aiBudgets.budgetId, budgetId)) + .returning(); + + return response(res, { + data: { budget }, + success: true, + error: false, + message: "AI budget updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiBudget/validation.ts b/server/routers/aiBudget/validation.ts new file mode 100644 index 000000000..303039948 --- /dev/null +++ b/server/routers/aiBudget/validation.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +export const aiBudgetUnitSchema = z.enum(["usd", "tokens"]); + +export const aiBudgetPeriodSchema = z.enum([ + "monthly", + "yearly", + "lifetime", + "daily", + "hourly", + "weekly" +]); + +export const aiBudgetEnforcementSchema = z.enum(["hard", "soft"]); + +export function refineBudgetScopeFields( + data: { + providerId?: number | null; + modelId?: number | null; + resourceId?: number | null; + siteResourceId?: number | null; + roleId?: number | null; + }, + ctx: z.RefinementCtx +) { + const scopeFields = [ + data.providerId, + data.modelId, + data.resourceId, + data.siteResourceId, + data.roleId + ]; + + const setCount = scopeFields.filter( + (value) => value !== null && value !== undefined + ).length; + + if (setCount > 1) { + ctx.addIssue({ + code: "custom", + message: + "Only one of providerId, modelId, resourceId, siteResourceId, or roleId may be set on a budget", + path: ["providerId"] + }); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 94165cc8d..f469707f8 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -47,7 +47,8 @@ import { verifyLimits, verifyResourcePolicyAccess, verifyAiProviderAccess, - verifyAiModelAccess + verifyAiModelAccess, + verifyAiBudgetAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; @@ -58,6 +59,7 @@ import { logActionAudit } from "#dynamic/middlewares"; import { checkRoundTripMessage } from "./ws"; import * as labels from "@server/routers/labels"; import * as aiProvider from "@server/routers/aiProvider"; +import * as aiBudget from "@server/routers/aiBudget"; // Root routes export const unauthenticated = Router(); @@ -1586,6 +1588,44 @@ authenticated.delete( aiProvider.deleteAiModel ); +authenticated.put( + "/org/:orgId/ai-budget", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createAiBudget), + logActionAudit(ActionsEnum.createAiBudget), + aiBudget.createAiBudget +); + +authenticated.get( + "/org/:orgId/ai-budgets", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listAiBudgets), + aiBudget.listAiBudgets +); + +authenticated.get( + "/ai-budget/:budgetId", + verifyAiBudgetAccess, + verifyUserHasAction(ActionsEnum.getAiBudget), + aiBudget.getAiBudget +); + +authenticated.post( + "/ai-budget/:budgetId", + verifyAiBudgetAccess, + verifyUserHasAction(ActionsEnum.updateAiBudget), + logActionAudit(ActionsEnum.updateAiBudget), + aiBudget.updateAiBudget +); + +authenticated.delete( + "/ai-budget/:budgetId", + verifyAiBudgetAccess, + verifyUserHasAction(ActionsEnum.deleteAiBudget), + logActionAudit(ActionsEnum.deleteAiBudget), + aiBudget.deleteAiBudget +); + authenticated.get( "/org/:orgId/labels", verifyOrgAccess, From ed46afd81a2df778fd79a792f53055957e638c6c Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 11:15:27 -0400 Subject: [PATCH 261/494] add subscription override column --- server/db/pg/schema/privateSchema.ts | 3 +- server/db/sqlite/schema/privateSchema.ts | 3 +- .../hooks/handleSubscriptionDeleted.ts | 9 +++++ .../hooks/handleSubscriptionUpdated.ts | 34 +++++++++++++------ 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index cbe8a4039..e41498264 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -95,7 +95,8 @@ export const subscriptions = pgTable("subscriptions", { billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }), expiresAt: bigint("expiresAt", { mode: "number" }), trial: boolean("trial").default(false), - type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license + type: varchar("type", { length: 50 }), // tier1, tier2, tier3, or license + override: boolean("override").default(false) }); export const subscriptionItems = pgTable("subscriptionItems", { diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index b75836e29..f8d2f5f09 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -89,7 +89,8 @@ export const subscriptions = sqliteTable("subscriptions", { expiresAt: integer("expiresAt"), trial: integer("trial", { mode: "boolean" }).default(false), billingCycleAnchor: integer("billingCycleAnchor"), - type: text("type") // tier1, tier2, tier3, or license + type: text("type"), // tier1, tier2, tier3, or license + override: integer("override", { mode: "boolean" }).default(false) }); export const subscriptionItems = sqliteTable("subscriptionItems", { diff --git a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts index 962cdd424..dd44f4101 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts @@ -53,6 +53,15 @@ export async function handleSubscriptionDeleted( return; } + // If the subscription has been manually overridden, we lock it down + // so Stripe can no longer change (or delete) its status locally. + if (existingSubscription.override === true) { + logger.info( + `Subscription ${subscription.id} is locked (override=true). Ignoring deletion event from Stripe.` + ); + return; + } + await db .delete(subscriptions) .where(eq(subscriptions.subscriptionId, subscription.id)); diff --git a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts index e1ec7a7b9..13df7910f 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts @@ -68,13 +68,27 @@ export async function handleSubscriptionUpdated( const type = getSubType(fullSubscription); const previousType = existingSubscription.type as SubscriptionType | null; + // If the subscription has been manually overridden, we lock the + // status down so Stripe webhooks can no longer change it. + const isLocked = existingSubscription.override === true; + if (isLocked) { + logger.info( + `Subscription ${subscription.id} is locked (override=true). Ignoring status change from Stripe (would have been ${subscription.status}).` + ); + } + const effectiveStatus = isLocked + ? existingSubscription.status + : subscription.status; + await db .update(subscriptions) .set({ - status: subscription.status, - canceledAt: subscription.canceled_at - ? subscription.canceled_at - : null, + status: effectiveStatus, + canceledAt: isLocked + ? existingSubscription.canceledAt + : subscription.canceled_at + ? subscription.canceled_at + : null, updatedAt: Math.floor(Date.now() / 1000), billingCycleAnchor: subscription.billing_cycle_anchor, type: type @@ -275,23 +289,23 @@ export async function handleSubscriptionUpdated( // we only need to handle the limit lifecycle for saas subscriptions not for the licenses await handleSubscriptionLifesycle( customer.orgId, - subscription.status, + effectiveStatus, type ); // Handle feature lifecycle when subscription is canceled or becomes unpaid if ( - subscription.status === "canceled" || - subscription.status === "unpaid" || - subscription.status === "incomplete_expired" + effectiveStatus === "canceled" || + effectiveStatus === "unpaid" || + effectiveStatus === "incomplete_expired" ) { logger.info( - `Subscription ${subscription.id} for org ${customer.orgId} is ${subscription.status}, disabling paid features` + `Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features` ); await handleTierChange(customer.orgId, null, previousType ?? undefined); } } else if (type === "license") { - if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") { + if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") { try { // WARNING: // this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId From b180d064d18cf5fc63fd561699913088a8b9358c Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 11:39:34 -0400 Subject: [PATCH 262/494] adjust conflict to be on unit<>period and add list endpoints for providers --- server/db/pg/schema/schema.ts | 13 ++- server/db/sqlite/schema/schema.ts | 13 ++- server/routers/aiBudget/createAiBudget.ts | 47 +++++++---- server/routers/aiBudget/index.ts | 5 ++ .../routers/aiBudget/listAiBudgetsForModel.ts | 69 ++++++++++++++++ .../aiBudget/listAiBudgetsForProvider.ts | 69 ++++++++++++++++ .../aiBudget/listAiBudgetsForResource.ts | 69 ++++++++++++++++ .../routers/aiBudget/listAiBudgetsForRole.ts | 69 ++++++++++++++++ .../aiBudget/listAiBudgetsForSiteResource.ts | 69 ++++++++++++++++ server/routers/aiBudget/types.ts | 4 + server/routers/aiBudget/updateAiBudget.ts | 80 ++++++++++--------- server/routers/external.ts | 35 ++++++++ 12 files changed, 479 insertions(+), 63 deletions(-) create mode 100644 server/routers/aiBudget/listAiBudgetsForModel.ts create mode 100644 server/routers/aiBudget/listAiBudgetsForProvider.ts create mode 100644 server/routers/aiBudget/listAiBudgetsForResource.ts create mode 100644 server/routers/aiBudget/listAiBudgetsForRole.ts create mode 100644 server/routers/aiBudget/listAiBudgetsForSiteResource.ts diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index e2eb50205..7ded27eb7 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1746,10 +1746,15 @@ export const aiBudgets = pgTable( updatedAt: bigint("updatedAt", { mode: "number" }).notNull() }, (t) => [ - unique("ai_budget_provider_uniq").on(t.providerId), - unique("ai_budget_model_uniq").on(t.modelId), - unique("ai_budget_resource_uniq").on(t.resourceId), - unique("ai_budget_site_resource_uniq").on(t.siteResourceId) + unique("ai_budget_provider_uniq").on(t.providerId, t.unit, t.period), + unique("ai_budget_model_uniq").on(t.modelId, t.unit, t.period), + unique("ai_budget_resource_uniq").on(t.resourceId, t.unit, t.period), + unique("ai_budget_site_resource_uniq").on( + t.siteResourceId, + t.unit, + t.period + ), + unique("ai_budget_role_uniq").on(t.roleId, t.unit, t.period) ] ); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index c8d0042df..34fe67a0f 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1732,10 +1732,15 @@ export const aiBudgets = sqliteTable( updatedAt: integer("updatedAt").notNull() }, (t) => [ - unique("ai_budget_provider_uniq").on(t.providerId), - unique("ai_budget_model_uniq").on(t.modelId), - unique("ai_budget_resource_uniq").on(t.resourceId), - unique("ai_budget_site_resource_uniq").on(t.siteResourceId) + unique("ai_budget_provider_uniq").on(t.providerId, t.unit, t.period), + unique("ai_budget_model_uniq").on(t.modelId, t.unit, t.period), + unique("ai_budget_resource_uniq").on(t.resourceId, t.unit, t.period), + unique("ai_budget_site_resource_uniq").on( + t.siteResourceId, + t.unit, + t.period + ), + unique("ai_budget_role_uniq").on(t.roleId, t.unit, t.period) ] ); diff --git a/server/routers/aiBudget/createAiBudget.ts b/server/routers/aiBudget/createAiBudget.ts index 705167c3a..3ddd76174 100644 --- a/server/routers/aiBudget/createAiBudget.ts +++ b/server/routers/aiBudget/createAiBudget.ts @@ -15,7 +15,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import type { CreateOrEditAiBudgetResponse } from "@server/routers/aiBudget/types"; import { aiBudgetEnforcementSchema, @@ -189,7 +189,7 @@ export async function createAiBudget( } } - const conflictCondition = + const scopeCondition = providerId !== undefined ? eq(aiBudgets.providerId, providerId) : modelId !== undefined @@ -198,22 +198,35 @@ export async function createAiBudget( ? eq(aiBudgets.resourceId, resourceId) : siteResourceId !== undefined ? eq(aiBudgets.siteResourceId, siteResourceId) - : undefined; + : roleId !== undefined + ? eq(aiBudgets.roleId, roleId) + : and( + eq(aiBudgets.orgId, orgId), + isNull(aiBudgets.providerId), + isNull(aiBudgets.modelId), + isNull(aiBudgets.resourceId), + isNull(aiBudgets.siteResourceId), + isNull(aiBudgets.roleId) + ); - if (conflictCondition) { - const [existing] = await db - .select({ budgetId: aiBudgets.budgetId }) - .from(aiBudgets) - .where(conflictCondition) - .limit(1); - if (existing) { - return next( - createHttpError( - HttpCode.CONFLICT, - "A budget already exists for this scope" - ) - ); - } + const [existing] = await db + .select({ budgetId: aiBudgets.budgetId }) + .from(aiBudgets) + .where( + and( + scopeCondition, + eq(aiBudgets.unit, unit), + eq(aiBudgets.period, period) + ) + ) + .limit(1); + if (existing) { + return next( + createHttpError( + HttpCode.CONFLICT, + `A ${period} ${unit} budget already exists for this scope` + ) + ); } const now = Date.now(); diff --git a/server/routers/aiBudget/index.ts b/server/routers/aiBudget/index.ts index 0da53e0d2..36eed7ba4 100644 --- a/server/routers/aiBudget/index.ts +++ b/server/routers/aiBudget/index.ts @@ -1,5 +1,10 @@ export * from "./createAiBudget"; export * from "./listAiBudgets"; +export * from "./listAiBudgetsForProvider"; +export * from "./listAiBudgetsForModel"; +export * from "./listAiBudgetsForResource"; +export * from "./listAiBudgetsForSiteResource"; +export * from "./listAiBudgetsForRole"; export * from "./getAiBudget"; export * from "./updateAiBudget"; export * from "./deleteAiBudget"; diff --git a/server/routers/aiBudget/listAiBudgetsForModel.ts b/server/routers/aiBudget/listAiBudgetsForModel.ts new file mode 100644 index 000000000..4a2277b5b --- /dev/null +++ b/server/routers/aiBudget/listAiBudgetsForModel.ts @@ -0,0 +1,69 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiBudgets, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { asc, eq } from "drizzle-orm"; +import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types"; + +const paramsSchema = z.strictObject({ + modelId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "get", + path: "/ai-model/{modelId}/ai-budgets", + description: "List AI budgets scoped to an AI model.", + tags: [OpenAPITags.AiBudget], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listAiBudgetsForModel( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { modelId } = parsedParams.data; + + const budgets = await db + .select() + .from(aiBudgets) + .where(eq(aiBudgets.modelId, modelId)) + .orderBy(asc(aiBudgets.unit), asc(aiBudgets.period)); + + return response(res, { + data: { budgets }, + success: true, + error: false, + message: "AI budgets retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiBudget/listAiBudgetsForProvider.ts b/server/routers/aiBudget/listAiBudgetsForProvider.ts new file mode 100644 index 000000000..1a0625f4b --- /dev/null +++ b/server/routers/aiBudget/listAiBudgetsForProvider.ts @@ -0,0 +1,69 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiBudgets, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { asc, eq } from "drizzle-orm"; +import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types"; + +const paramsSchema = z.strictObject({ + providerId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "get", + path: "/ai-provider/{providerId}/ai-budgets", + description: "List AI budgets scoped to an AI provider.", + tags: [OpenAPITags.AiBudget], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listAiBudgetsForProvider( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { providerId } = parsedParams.data; + + const budgets = await db + .select() + .from(aiBudgets) + .where(eq(aiBudgets.providerId, providerId)) + .orderBy(asc(aiBudgets.unit), asc(aiBudgets.period)); + + return response(res, { + data: { budgets }, + success: true, + error: false, + message: "AI budgets retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiBudget/listAiBudgetsForResource.ts b/server/routers/aiBudget/listAiBudgetsForResource.ts new file mode 100644 index 000000000..106b049a7 --- /dev/null +++ b/server/routers/aiBudget/listAiBudgetsForResource.ts @@ -0,0 +1,69 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiBudgets, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { asc, eq } from "drizzle-orm"; +import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types"; + +const paramsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/ai-budgets", + description: "List AI budgets scoped to a resource.", + tags: [OpenAPITags.AiBudget], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listAiBudgetsForResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const budgets = await db + .select() + .from(aiBudgets) + .where(eq(aiBudgets.resourceId, resourceId)) + .orderBy(asc(aiBudgets.unit), asc(aiBudgets.period)); + + return response(res, { + data: { budgets }, + success: true, + error: false, + message: "AI budgets retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiBudget/listAiBudgetsForRole.ts b/server/routers/aiBudget/listAiBudgetsForRole.ts new file mode 100644 index 000000000..2e8ff0e8d --- /dev/null +++ b/server/routers/aiBudget/listAiBudgetsForRole.ts @@ -0,0 +1,69 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiBudgets, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { asc, eq } from "drizzle-orm"; +import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types"; + +const paramsSchema = z.strictObject({ + roleId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "get", + path: "/role/{roleId}/ai-budgets", + description: "List AI budgets scoped to a role.", + tags: [OpenAPITags.AiBudget], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listAiBudgetsForRole( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { roleId } = parsedParams.data; + + const budgets = await db + .select() + .from(aiBudgets) + .where(eq(aiBudgets.roleId, roleId)) + .orderBy(asc(aiBudgets.unit), asc(aiBudgets.period)); + + return response(res, { + data: { budgets }, + success: true, + error: false, + message: "AI budgets retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiBudget/listAiBudgetsForSiteResource.ts b/server/routers/aiBudget/listAiBudgetsForSiteResource.ts new file mode 100644 index 000000000..b2557719d --- /dev/null +++ b/server/routers/aiBudget/listAiBudgetsForSiteResource.ts @@ -0,0 +1,69 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiBudgets, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { asc, eq } from "drizzle-orm"; +import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types"; + +const paramsSchema = z.strictObject({ + siteResourceId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "get", + path: "/site-resource/{siteResourceId}/ai-budgets", + description: "List AI budgets scoped to a site resource.", + tags: [OpenAPITags.AiBudget], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listAiBudgetsForSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const budgets = await db + .select() + .from(aiBudgets) + .where(eq(aiBudgets.siteResourceId, siteResourceId)) + .orderBy(asc(aiBudgets.unit), asc(aiBudgets.period)); + + return response(res, { + data: { budgets }, + success: true, + error: false, + message: "AI budgets retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiBudget/types.ts b/server/routers/aiBudget/types.ts index 11ffd1050..c689ef30e 100644 --- a/server/routers/aiBudget/types.ts +++ b/server/routers/aiBudget/types.ts @@ -5,6 +5,10 @@ export type ListAiBudgetsResponse = PaginatedResponse<{ budgets: AiBudget[]; }>; +export type ListAiBudgetsByScopeResponse = { + budgets: AiBudget[]; +}; + export type GetAiBudgetResponse = { budget: AiBudget; }; diff --git a/server/routers/aiBudget/updateAiBudget.ts b/server/routers/aiBudget/updateAiBudget.ts index e8bd845c5..723b346c7 100644 --- a/server/routers/aiBudget/updateAiBudget.ts +++ b/server/routers/aiBudget/updateAiBudget.ts @@ -15,7 +15,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq, ne } from "drizzle-orm"; +import { and, eq, isNull, ne } from "drizzle-orm"; import type { CreateOrEditAiBudgetResponse } from "@server/routers/aiBudget/types"; import { aiBudgetEnforcementSchema, @@ -128,6 +128,9 @@ export async function updateAiBudget( : existing.siteResourceId; const nextRoleId = body.roleId !== undefined ? body.roleId : existing.roleId; + const nextUnit = body.unit !== undefined ? body.unit : existing.unit; + const nextPeriod = + body.period !== undefined ? body.period : existing.period; const scopeValidation = z .object({ @@ -242,44 +245,45 @@ export async function updateAiBudget( } } - const conflictCondition = - body.providerId !== undefined && body.providerId !== null - ? and( - eq(aiBudgets.providerId, body.providerId), - ne(aiBudgets.budgetId, budgetId) - ) - : body.modelId !== undefined && body.modelId !== null - ? and( - eq(aiBudgets.modelId, body.modelId), - ne(aiBudgets.budgetId, budgetId) - ) - : body.resourceId !== undefined && body.resourceId !== null - ? and( - eq(aiBudgets.resourceId, body.resourceId), - ne(aiBudgets.budgetId, budgetId) - ) - : body.siteResourceId !== undefined && - body.siteResourceId !== null - ? and( - eq(aiBudgets.siteResourceId, body.siteResourceId), - ne(aiBudgets.budgetId, budgetId) - ) - : undefined; + const scopeCondition = + nextProviderId !== null + ? eq(aiBudgets.providerId, nextProviderId) + : nextModelId !== null + ? eq(aiBudgets.modelId, nextModelId) + : nextResourceId !== null + ? eq(aiBudgets.resourceId, nextResourceId) + : nextSiteResourceId !== null + ? eq(aiBudgets.siteResourceId, nextSiteResourceId) + : nextRoleId !== null + ? eq(aiBudgets.roleId, nextRoleId) + : and( + eq(aiBudgets.orgId, orgId), + isNull(aiBudgets.providerId), + isNull(aiBudgets.modelId), + isNull(aiBudgets.resourceId), + isNull(aiBudgets.siteResourceId), + isNull(aiBudgets.roleId) + ); - if (conflictCondition) { - const [conflict] = await db - .select({ budgetId: aiBudgets.budgetId }) - .from(aiBudgets) - .where(conflictCondition) - .limit(1); - if (conflict) { - return next( - createHttpError( - HttpCode.CONFLICT, - "A budget already exists for this scope" - ) - ); - } + const [conflict] = await db + .select({ budgetId: aiBudgets.budgetId }) + .from(aiBudgets) + .where( + and( + scopeCondition, + eq(aiBudgets.unit, nextUnit), + eq(aiBudgets.period, nextPeriod), + ne(aiBudgets.budgetId, budgetId) + ) + ) + .limit(1); + if (conflict) { + return next( + createHttpError( + HttpCode.CONFLICT, + `A ${nextPeriod} ${nextUnit} budget already exists for this scope` + ) + ); } const updateData: Partial = { diff --git a/server/routers/external.ts b/server/routers/external.ts index f469707f8..41df6ea59 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1626,6 +1626,41 @@ authenticated.delete( aiBudget.deleteAiBudget ); +authenticated.get( + "/ai-provider/:providerId/ai-budgets", + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.listAiBudgets), + aiBudget.listAiBudgetsForProvider +); + +authenticated.get( + "/ai-model/:modelId/ai-budgets", + verifyAiModelAccess, + verifyUserHasAction(ActionsEnum.listAiBudgets), + aiBudget.listAiBudgetsForModel +); + +authenticated.get( + "/resource/:resourceId/ai-budgets", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.listAiBudgets), + aiBudget.listAiBudgetsForResource +); + +authenticated.get( + "/site-resource/:siteResourceId/ai-budgets", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.listAiBudgets), + aiBudget.listAiBudgetsForSiteResource +); + +authenticated.get( + "/role/:roleId/ai-budgets", + verifyRoleAccess, + verifyUserHasAction(ActionsEnum.listAiBudgets), + aiBudget.listAiBudgetsForRole +); + authenticated.get( "/org/:orgId/labels", verifyOrgAccess, From 187936e5dd162617d56ddd54a386a72643b071e4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 10 Aug 2026 11:54:37 -0400 Subject: [PATCH 263/494] use class with helper methods for model catelog --- server/lib/aiModelCatalog.ts | 295 +++++++++++++++++------ server/lib/aiModelPricing.ts | 47 +--- server/routers/site/socketIntegration.ts | 2 +- 3 files changed, 229 insertions(+), 115 deletions(-) diff --git a/server/lib/aiModelCatalog.ts b/server/lib/aiModelCatalog.ts index 39b3a70c9..faee7a345 100644 --- a/server/lib/aiModelCatalog.ts +++ b/server/lib/aiModelCatalog.ts @@ -3,13 +3,18 @@ import axios from "axios"; import config from "@server/lib/config"; import logger from "@server/logger"; -export type CatalogProvider = - | "openai" - | "anthropic" - | "gemini" - | "vertex" - | "azure" - | "bedrock"; +export const CATALOG_PROVIDERS = [ + "openai", + "anthropic", + "gemini", + "vertex", + "azure", + "bedrock" +] as const; + +export type CatalogProvider = (typeof CATALOG_PROVIDERS)[number]; + +const CATALOG_PROVIDER_SET = new Set(CATALOG_PROVIDERS); export type AiModelCatalogEntry = { provider: CatalogProvider; @@ -22,90 +27,222 @@ export type AiModelCatalogEntry = { }; }; -let catalog: AiModelCatalogEntry[] = []; -let refreshTimer: NodeJS.Timeout | null = null; +type RawCatalogEntry = { + id?: string; + name?: string; + model?: string; + provider: string; + input_cost_per_token?: number | null; + output_cost_per_token?: number | null; + cache_read_input_token_cost?: number | null; + output_cost_per_reasoning_token?: number | null; + pricing?: { + input?: number | null; + output?: number | null; + cacheRead?: number | null; + reasoningOutput?: number | null; + }; +}; -async function fetchFromFile(filePath: string): Promise { - try { - if (!fs.existsSync(filePath)) { +function normalizeCatalogProvider(raw: string): CatalogProvider | null { + if (CATALOG_PROVIDER_SET.has(raw)) { + return raw as CatalogProvider; + } + if (raw.startsWith("bedrock")) { + return "bedrock"; + } + if (raw.startsWith("vertex")) { + return "vertex"; + } + if (raw.startsWith("azure")) { + return "azure"; + } + return null; +} + +function normalizeEntry(raw: RawCatalogEntry): AiModelCatalogEntry | null { + const provider = normalizeCatalogProvider(raw.provider); + if (!provider) { + return null; + } + + const model = raw.model ?? raw.name ?? raw.id; + if (!model) { + return null; + } + + return { + provider, + model, + pricing: { + input: raw.pricing?.input ?? raw.input_cost_per_token ?? null, + output: raw.pricing?.output ?? raw.output_cost_per_token ?? null, + cacheRead: + raw.pricing?.cacheRead ?? + raw.cache_read_input_token_cost ?? + null, + reasoningOutput: + raw.pricing?.reasoningOutput ?? + raw.output_cost_per_reasoning_token ?? + null + } + }; +} + +function providerKey(provider: CatalogProvider, key: string): string { + return `${provider}\0${key}`; +} + +export class AiModelCatalog { + private entries: AiModelCatalogEntry[] = []; + private byProvider = new Map(); + private byProviderAndKey = new Map(); + private byKey = new Map(); + private refreshTimer: NodeJS.Timeout | null = null; + + /** + * Loads the catalog into memory and schedules periodic background refreshes. + * Call once at server startup. + */ + async init(): Promise { + await this.refresh(); + this.scheduleNextRefresh(); + } + + /** Exact lookup by catalog provider and model key. */ + get( + provider: CatalogProvider, + key: string + ): AiModelCatalogEntry | undefined { + return this.byProviderAndKey.get(providerKey(provider, key)); + } + + /** All models for a catalog provider. */ + list(provider: CatalogProvider): AiModelCatalogEntry[] { + return this.byProvider.get(provider) ?? []; + } + + /** All catalog entries that share a model key, across providers. */ + listByKey(key: string): AiModelCatalogEntry[] { + return this.byKey.get(key) ?? []; + } + + /** Full in-memory catalog. */ + getAll(): AiModelCatalogEntry[] { + return this.entries; + } + + private setEntries(entries: AiModelCatalogEntry[]): void { + const byProvider = new Map(); + const byProviderAndKey = new Map(); + const byKey = new Map(); + + for (const entry of entries) { + const list = byProvider.get(entry.provider) ?? []; + list.push(entry); + byProvider.set(entry.provider, list); + + const mapKey = providerKey(entry.provider, entry.model); + if (!byProviderAndKey.has(mapKey)) { + byProviderAndKey.set(mapKey, entry); + } + + const keyList = byKey.get(entry.model) ?? []; + keyList.push(entry); + byKey.set(entry.model, keyList); + } + + this.entries = entries; + this.byProvider = byProvider; + this.byProviderAndKey = byProviderAndKey; + this.byKey = byKey; + } + + private async fetchFromFile( + filePath: string + ): Promise { + try { + if (!fs.existsSync(filePath)) { + logger.warn( + `AI model catalog file not found at ${filePath}; cost calculation will fall back to unknown pricing` + ); + return null; + } + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as { data: RawCatalogEntry[] }; + return (parsed.data ?? []) + .map(normalizeEntry) + .filter((e): e is AiModelCatalogEntry => e != null); + } catch (error) { + logger.warn("Failed to read AI model catalog file", { error }); + return null; + } + } + + private async fetchFromUpstream( + upstreamUrl: string + ): Promise { + try { + const res = await axios.get<{ data: RawCatalogEntry[] }>( + upstreamUrl, + { timeout: 15_000 } + ); + return (res.data?.data ?? []) + .map(normalizeEntry) + .filter((e): e is AiModelCatalogEntry => e != null); + } catch (error: any) { logger.warn( - `AI model catalog file not found at ${filePath}; cost calculation will fall back to unknown pricing` + `Failed to fetch AI model catalog from ${upstreamUrl}: ${error.message || error}` ); return null; } - const raw = fs.readFileSync(filePath, "utf-8"); - const parsed = JSON.parse(raw) as { data: AiModelCatalogEntry[] }; - return parsed.data ?? []; - } catch (error) { - logger.warn("Failed to read AI model catalog file", { error }); - return null; + } + + private async refresh(): Promise { + const { file, upstream_url } = config.getRawConfig().ai.model_catalog; + + const fetched = file + ? await this.fetchFromFile(file) + : await this.fetchFromUpstream(upstream_url); + + if (fetched) { + this.setEntries(fetched); + logger.debug( + `AI model catalog refreshed: ${this.entries.length} models loaded` + ); + } else { + logger.debug( + "AI model catalog refresh failed; keeping previously loaded catalog in memory" + ); + } + } + + private scheduleNextRefresh(): void { + const { refresh_interval_min_hours, refresh_interval_max_hours } = + config.getRawConfig().ai.model_catalog; + + // Jittered rather than fixed so that many self-hosted instances don't + // all hit the upstream catalog endpoint at the same moment. + const minMs = refresh_interval_min_hours * 60 * 60 * 1000; + const maxMs = refresh_interval_max_hours * 60 * 60 * 1000; + const delayMs = minMs + Math.random() * Math.max(0, maxMs - minMs); + + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + this.refreshTimer = setTimeout(async () => { + await this.refresh(); + this.scheduleNextRefresh(); + }, delayMs); } } -async function fetchFromUpstream( - upstreamUrl: string -): Promise { - try { - const res = await axios.get<{ data: AiModelCatalogEntry[] }>( - upstreamUrl, - { timeout: 15_000 } - ); - return res.data?.data ?? []; - } catch (error: any) { - logger.warn( - `Failed to fetch AI model catalog from ${upstreamUrl}: ${error.message || error}` - ); - return null; - } -} - -async function refreshCatalog(): Promise { - const { file, upstream_url } = config.getRawConfig().ai.model_catalog; - - const fetched = file - ? await fetchFromFile(file) - : await fetchFromUpstream(upstream_url); - - if (fetched) { - catalog = fetched; - logger.debug( - `AI model catalog refreshed: ${catalog.length} models loaded` - ); - } else { - logger.debug( - "AI model catalog refresh failed; keeping previously loaded catalog in memory" - ); - } -} - -function scheduleNextRefresh(): void { - const { refresh_interval_min_hours, refresh_interval_max_hours } = - config.getRawConfig().ai.model_catalog; - - // Jittered rather than fixed so that many self-hosted instances don't - // all hit the upstream catalog endpoint at the same moment. - const minMs = refresh_interval_min_hours * 60 * 60 * 1000; - const maxMs = refresh_interval_max_hours * 60 * 60 * 1000; - const delayMs = minMs + Math.random() * Math.max(0, maxMs - minMs); - - if (refreshTimer) { - clearTimeout(refreshTimer); - } - refreshTimer = setTimeout(async () => { - await refreshCatalog(); - scheduleNextRefresh(); - }, delayMs); -} +export const aiModelCatalog = new AiModelCatalog(); /** * Loads the AI model pricing catalog into memory and schedules periodic * background refreshes. Call once at server startup. */ export async function initAiModelCatalog(): Promise { - await refreshCatalog(); - scheduleNextRefresh(); -} - -export function getAiModelCatalog(): AiModelCatalogEntry[] { - return catalog; + await aiModelCatalog.init(); } diff --git a/server/lib/aiModelPricing.ts b/server/lib/aiModelPricing.ts index 873e4213a..4cc398068 100644 --- a/server/lib/aiModelPricing.ts +++ b/server/lib/aiModelPricing.ts @@ -1,7 +1,7 @@ import type { AiProviderType } from "@server/lib/aiProviderDefaults"; import type { AiUsage } from "@server/lib/aiUsageExtraction"; import { - getAiModelCatalog, + aiModelCatalog, type AiModelCatalogEntry, type CatalogProvider } from "@server/lib/aiModelCatalog"; @@ -36,31 +36,6 @@ const PROVIDER_CATALOG_MAP: Record< vercelAiGateway: null }; -// Indexed view over the in-memory catalog, rebuilt only when -// getAiModelCatalog() returns a different array instance (i.e. after a -// background refresh swaps it out), not on every lookup. -let indexedCatalog: AiModelCatalogEntry[] | null = null; -let indexedByName: Map = new Map(); - -function getIndexedCatalog(): Map { - const catalog = getAiModelCatalog(); - if (catalog === indexedCatalog) { - return indexedByName; - } - - const byName = new Map(); - for (const entry of catalog) { - if (!entry.model) continue; - const list = byName.get(entry.model) ?? []; - list.push(entry); - byName.set(entry.model, list); - } - - indexedCatalog = catalog; - indexedByName = byName; - return byName; -} - function stripVendorPrefix(modelId: string): string | null { const idx = modelId.indexOf("/"); if (idx === -1 || idx === modelId.length - 1) { @@ -83,7 +58,6 @@ function toPricing( } function findEntry( - byName: Map, modelId: string, provider: CatalogProvider | null ): AiModelCatalogEntry | null { @@ -92,11 +66,15 @@ function findEntry( ); for (const key of candidates) { - const entries = byName.get(key); - if (!entries) continue; - const match = provider - ? entries.find((e) => e.provider === provider) - : entries[0]; + if (provider) { + const match = aiModelCatalog.get(provider, key); + if (match) { + return match; + } + continue; + } + + const match = aiModelCatalog.listByKey(key)[0]; if (match) { return match; } @@ -118,18 +96,17 @@ export function getModelPricing( return null; } - const byName = getIndexedCatalog(); const catalogProvider = providerType === "custom" ? null : PROVIDER_CATALOG_MAP[providerType]; if (catalogProvider) { - const scoped = findEntry(byName, modelId, catalogProvider); + const scoped = findEntry(modelId, catalogProvider); if (scoped) { return toPricing(scoped, false); } } - const fallback = findEntry(byName, modelId, null); + const fallback = findEntry(modelId, null); if (fallback) { return toPricing(fallback, true); } diff --git a/server/routers/site/socketIntegration.ts b/server/routers/site/socketIntegration.ts index 85d5bc9dd..bf63eb686 100644 --- a/server/routers/site/socketIntegration.ts +++ b/server/routers/site/socketIntegration.ts @@ -201,7 +201,7 @@ async function checkSocket( ): Promise<{ siteId: number; newtId: string }> { const { newt } = await getSiteAndNewt(siteId); - logger.info( + logger.debug( `Checking Docker socket for site ${siteId} with Newt ${newt.newtId}` ); From 211d3a53f5940781033f1bd9ef710654b6c108f1 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 12:03:46 -0400 Subject: [PATCH 264/494] initial budget ui on the provider --- messages/en-US.json | 19 +- server/routers/aiBudget/README.md | 126 ++++++ .../ai-providers/[providerId]/budget/page.tsx | 22 + .../ai-providers/[providerId]/layout.tsx | 4 + src/components/BudgetsEditor.tsx | 418 ++++++++++++++++++ src/lib/aiBudgetScope.ts | 70 +++ src/lib/queries.ts | 18 + 7 files changed, 676 insertions(+), 1 deletion(-) create mode 100644 server/routers/aiBudget/README.md create mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/budget/page.tsx create mode 100644 src/components/BudgetsEditor.tsx create mode 100644 src/lib/aiBudgetScope.ts diff --git a/messages/en-US.json b/messages/en-US.json index f18f99133..9acb93b45 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1653,6 +1653,23 @@ "aiProviderNetworkSettingsDescription": "Choose how traffic reaches this provider", "aiProviderAuthSettings": "Authentication", "aiProviderAuthSettingsDescription": "Configure how this provider authenticates requests to its upstream URL", + "aiProviderBudgetSettings": "Budget", + "aiProviderBudgetSettingsDescription": "Configure how this provider restricts usage based on spending or token limits", + "aiBudgetAdd": "Add Budget", + "aiBudgetEmpty": "No budgets configured yet. Click Add Budget to set a spending or token limit.", + "aiBudgetAmountPlaceholder": "Maximum spend", + "aiBudgetPeriodHourly": "Hourly", + "aiBudgetPeriodDaily": "Daily", + "aiBudgetPeriodWeekly": "Weekly", + "aiBudgetPeriodMonthly": "Monthly", + "aiBudgetPeriodYearly": "Yearly", + "aiBudgetPeriodLifetime": "Lifetime", + "aiBudgetUnitUsd": "USD", + "aiBudgetUnitTokens": "Tokens", + "aiBudgetConflictError": "A budget for this reset period and spend type already exists", + "aiBudgetInvalidAmountError": "Enter a maximum spend greater than 0", + "aiBudgetUpdated": "Budgets updated", + "aiBudgetErrorSave": "Failed to update budgets", "aiProviderType": "Provider Type", "aiProviderTypeSearch": "Search providers...", "aiProviderTypeNotFound": "No provider type found", @@ -4004,4 +4021,4 @@ "sessionToolbarShow": "Show toolbar", "sessionToolbarHide": "Hide toolbar", "actionUpdateSiteApprovals": "Update Site Approvals" -} +} \ No newline at end of file diff --git a/server/routers/aiBudget/README.md b/server/routers/aiBudget/README.md new file mode 100644 index 000000000..ef13e773d --- /dev/null +++ b/server/routers/aiBudget/README.md @@ -0,0 +1,126 @@ +# AI Budget API + +Public/OSS CRUD entity (`server/routers/aiBudget/`, not enterprise-gated). +Table: `aiBudgets` in `server/db/{pg,sqlite}/schema/schema.ts`, type `AiBudget`. + +## What a budget is + +A row is a spend/usage cap of `amount` `unit` (`usd` | `tokens`) per `period` +(`hourly` | `daily` | `weekly` | `monthly` | `yearly` | `lifetime`), with +`enforcement` (`hard` | `soft`) and an `enabled` flag. + +Every budget belongs to an org (`orgId`, required) and is optionally further +scoped to **exactly one** of: + +- `providerId` → an `aiProviders` row +- `modelId` → an `aiModels` row +- `resourceId` → a `resources` row +- `siteResourceId` → a `siteResources` row +- `roleId` → a `roles` row + +If none of those five are set, the budget is **org-wide**. Setting more than +one at once is rejected by `validation.ts`'s `refineBudgetScopeFields` +(`400`, "Only one of providerId, modelId, resourceId, siteResourceId, or +roleId may be set on a budget"). + +## Uniqueness / conflict rule + +A given scope (one specific provider, or model, or resource, or site +resource, or role, or "org-wide") may have **multiple** budgets, but at most +**one per `(unit, period)` combination** — e.g. one `weekly`/`usd` budget and +one `hourly`/`usd` budget can coexist on the same provider, but two +`weekly`/`usd` budgets cannot. This is enforced at two levels: + +- DB: composite `unique` constraints in both schema files — + `ai_budget_provider_uniq (providerId, unit, period)`, + `ai_budget_model_uniq (modelId, unit, period)`, + `ai_budget_resource_uniq (resourceId, unit, period)`, + `ai_budget_site_resource_uniq (siteResourceId, unit, period)`, + `ai_budget_role_uniq (roleId, unit, period)`. (NULL scope columns never + collide under a plain unique index, so this does *not* cover the org-wide + case — see next bullet.) +- App: `createAiBudget`/`updateAiBudget` both run an explicit pre-check + query keyed on `(scopeCondition, unit, period)` before insert/update, + where `scopeCondition` is `eq(, id)` for whichever scope + field is set, or — when none is set — `orgId = X AND` all five scope + columns `IS NULL`, so org-wide budgets get the same one-per-`(unit, + period)` guarantee even though the DB constraint can't express it. + Violating this returns `409` with + `` `A ${period} ${unit} budget already exists for this scope` ``. + +Because only one row can ever exist for a given `(scope, unit, period)`, +there is no separate check needed to prevent a `hard` and a `soft` budget +from coexisting on the same `(scope, unit, period)` — the conflict check +above already blocks the second row regardless of its `enforcement` value. + +On `updateAiBudget`, the conflict/ownership checks are run against the +**merged** next-state (existing row's scope/unit/period overlaid with +whatever the request body changes), not just the fields present in the +body — so e.g. changing only `unit` on a budget that already has +`providerId` set re-validates against that provider's other budgets at the +new unit. + +## Ownership validation + +`providerId`/`modelId`/`resourceId`/`siteResourceId`/`roleId` are validated +to belong to the same `orgId` as the budget (`modelId` via an +`aiModels ⋈ aiProviders` join, since `aiModels` has no `orgId` column +directly). A mismatch returns `404`, not `403` — this matches how the +sibling `aiProvider`/`aiModel` routers report cross-org references. + +## Routes + +All under `server/routers/external.ts`, registered right after the +`aiProvider`/`aiModel` block. `PUT` = create, `POST` = update (repo +convention, not standard REST). + +| Method | Path | Middleware | Action | Handler | +|---|---|---|---|---| +| PUT | `/org/:orgId/ai-budget` | `verifyOrgAccess` | `createAiBudget` | `createAiBudget` | +| GET | `/org/:orgId/ai-budgets` | `verifyOrgAccess` | `listAiBudgets` | `listAiBudgets` (paginated) | +| GET | `/ai-budget/:budgetId` | `verifyAiBudgetAccess` | `getAiBudget` | `getAiBudget` | +| POST | `/ai-budget/:budgetId` | `verifyAiBudgetAccess` | `updateAiBudget` | `updateAiBudget` | +| DELETE | `/ai-budget/:budgetId` | `verifyAiBudgetAccess` | `deleteAiBudget` | `deleteAiBudget` | +| GET | `/ai-provider/:providerId/ai-budgets` | `verifyAiProviderAccess` | `listAiBudgets` | `listAiBudgetsForProvider` | +| GET | `/ai-model/:modelId/ai-budgets` | `verifyAiModelAccess` | `listAiBudgets` | `listAiBudgetsForModel` | +| GET | `/resource/:resourceId/ai-budgets` | `verifyResourceAccess` | `listAiBudgets` | `listAiBudgetsForResource` | +| GET | `/site-resource/:siteResourceId/ai-budgets` | `verifySiteResourceAccess` | `listAiBudgets` | `listAiBudgetsForSiteResource` | +| GET | `/role/:roleId/ai-budgets` | `verifyRoleAccess` | `listAiBudgets` | `listAiBudgetsForRole` | + +The five scope-filtered `GET .../ai-budgets` routes intentionally reuse the +single `ActionsEnum.listAiBudgets` action rather than getting one action +each — access control is already fully handled by the entity-specific +middleware (a user who can see the provider/resource/etc. can see its +budgets), so per-scope actions would just be enum bloat. They also skip +pagination (unlike the org-wide list) since a single entity realistically +has only a handful of `(unit, period)` budgets — response shape is a flat +`{ budgets: AiBudget[] }` (`ListAiBudgetsByScopeResponse`), not +`PaginatedResponse`. + +`verifyAiBudgetAccess` (`server/middlewares/verifyAiBudgetAccess.ts`) loads +the budget by `budgetId`, resolves its `orgId` directly off the row (no +join needed, unlike `verifyAiModelAccess`), and stashes it on +`req.aiBudget` so `getAiBudget`/`updateAiBudget` can skip a re-fetch. + +## Request/response shapes + +- Create body: `providerId?`, `modelId?`, `resourceId?`, `siteResourceId?`, + `roleId?` (all `number`, mutually exclusive), `amount` (positive + `number`, required), `unit` (required), `period` (default `"monthly"`), + `enforcement` (default `"hard"`), `enabled?` (default `true`). +- Update body: same fields, all optional; the five scope fields are + `nullable().optional()` so a client can explicitly send `null` to clear + a scope (turning a scoped budget into an org-wide one). +- All five CRUD responses wrap a single `budget: AiBudget` (or + `budgets: AiBudget[]` + `pagination` for the org-wide list). No public/ + private mapper exists for `AiBudget` — unlike `AiProvider`, there's no + secret field to strip, so the raw DB row is returned as-is. + +## Not yet migrated + +Schema changes here (composite unique constraints) were made directly in +`schema.ts` without hand-writing a `server/migrations/*.sql` file — this +repo's CI (`.github/workflows/test.yml`) runs `drizzle-kit generate` +against `schema.ts` fresh, and other recent schema-only commits (e.g. "Remove +budget periods") follow the same pattern of not committing a matching +migration by hand. diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/budget/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/budget/page.tsx new file mode 100644 index 000000000..f86abbfd8 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/budget/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { SettingsContainer } from "@app/components/Settings"; +import { BudgetsEditor } from "@app/components/BudgetsEditor"; +import { useAiProviderContext } from "@app/hooks/useAiProviderContext"; +import { useTranslations } from "next-intl"; + +export default function AiProviderBudgetPage() { + const { provider } = useAiProviderContext(); + const t = useTranslations(); + + return ( + + + + ); +} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx index 4647347f5..01841d925 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx @@ -76,6 +76,10 @@ export default async function AiProviderLayout({ children, params }: Props) { { title: t("aiProviderAuthSettings"), href: "/{orgId}/settings/ai-providers/{providerId}/authentication" + }, + { + title: t("aiProviderBudgetSettings"), + href: "/{orgId}/settings/ai-providers/{providerId}/budget" } ]; diff --git a/src/components/BudgetsEditor.tsx b/src/components/BudgetsEditor.tsx new file mode 100644 index 000000000..1327998cf --- /dev/null +++ b/src/components/BudgetsEditor.tsx @@ -0,0 +1,418 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + AI_BUDGET_PERIODS, + AI_BUDGET_UNITS, + getAiBudgetScopeBodyField, + type AiBudgetPeriod, + type AiBudgetScope, + type AiBudgetUnit +} from "@app/lib/aiBudgetScope"; +import { aiBudgetQueries } from "@app/lib/queries"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AiBudget } from "@server/db"; +import { Plus, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useMemo, useState } from "react"; + +type BudgetRow = { + key: string; + budgetId?: number; + amount: string; + unit: AiBudgetUnit; + period: AiBudgetPeriod; +}; + +function rowsFromBudgets(budgets: AiBudget[]): BudgetRow[] { + return budgets.map((budget) => ({ + key: String(budget.budgetId), + budgetId: budget.budgetId, + amount: String(budget.amount), + unit: budget.unit, + period: budget.period + })); +} + +function comboKey(unit: AiBudgetUnit, period: AiBudgetPeriod): string { + return `${unit}:${period}`; +} + +function nextAvailableCombo(rows: BudgetRow[]): { + unit: AiBudgetUnit; + period: AiBudgetPeriod; +} { + const used = new Set(rows.map((row) => comboKey(row.unit, row.period))); + for (const unit of AI_BUDGET_UNITS) { + for (const period of AI_BUDGET_PERIODS) { + if (!used.has(comboKey(unit, period))) { + return { unit, period }; + } + } + } + return { unit: "usd", period: "monthly" }; +} + +export function BudgetsEditor({ + scope, + orgId, + title, + description +}: { + scope: AiBudgetScope; + orgId: string; + title: string; + description: string; +}) { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const queryClient = useQueryClient(); + const t = useTranslations(); + + const [rows, setRows] = useState([]); + const [saveLoading, setSaveLoading] = useState(false); + const [attemptedSave, setAttemptedSave] = useState(false); + + const budgetsQuery = useQuery(aiBudgetQueries.scoped({ scope })); + + useEffect(() => { + if (!budgetsQuery.data) return; + setRows(rowsFromBudgets(budgetsQuery.data)); + setAttemptedSave(false); + }, [budgetsQuery.data]); + + const conflictingKeys = useMemo(() => { + const counts = new Map(); + for (const row of rows) { + const key = comboKey(row.unit, row.period); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + const conflicting = new Set(); + for (const row of rows) { + const key = comboKey(row.unit, row.period); + if ((counts.get(key) ?? 0) > 1) { + conflicting.add(row.key); + } + } + return conflicting; + }, [rows]); + + const invalidAmountKeys = useMemo(() => { + const invalid = new Set(); + for (const row of rows) { + const amount = Number(row.amount); + if (!row.amount.trim() || !Number.isFinite(amount) || amount <= 0) { + invalid.add(row.key); + } + } + return invalid; + }, [rows]); + + const hasErrors = conflictingKeys.size > 0 || invalidAmountKeys.size > 0; + + function addRow() { + const combo = nextAvailableCombo(rows); + setRows((prev) => [ + ...prev, + { + key: crypto.randomUUID(), + amount: "", + unit: combo.unit, + period: combo.period + } + ]); + } + + function removeRow(key: string) { + setRows((prev) => prev.filter((row) => row.key !== key)); + } + + function updateRow(key: string, patch: Partial) { + setRows((prev) => + prev.map((row) => (row.key === key ? { ...row, ...patch } : row)) + ); + } + + async function onSave() { + setAttemptedSave(true); + if (hasErrors) { + toast({ + variant: "destructive", + title: t("aiBudgetErrorSave"), + description: conflictingKeys.size + ? t("aiBudgetConflictError") + : t("aiBudgetInvalidAmountError") + }); + return; + } + + setSaveLoading(true); + try { + const existing = budgetsQuery.data ?? []; + const existingById = new Map( + existing.map((budget) => [budget.budgetId, budget]) + ); + const currentBudgetIds = new Set( + rows + .filter((row) => row.budgetId !== undefined) + .map((row) => row.budgetId as number) + ); + const bodyField = getAiBudgetScopeBodyField(scope); + + const toDelete = existing.filter( + (budget) => !currentBudgetIds.has(budget.budgetId) + ); + const toCreate = rows.filter((row) => row.budgetId === undefined); + const toUpdate = rows.filter((row) => { + if (row.budgetId === undefined) return false; + const existingBudget = existingById.get(row.budgetId); + if (!existingBudget) return false; + return ( + existingBudget.amount !== Number(row.amount) || + existingBudget.unit !== row.unit || + existingBudget.period !== row.period + ); + }); + + await Promise.all([ + ...toDelete.map((budget) => + api.delete(`/ai-budget/${budget.budgetId}`) + ), + ...toCreate.map((row) => + api.put(`/org/${orgId}/ai-budget`, { + [bodyField]: scope.id, + amount: Number(row.amount), + unit: row.unit, + period: row.period + }) + ), + ...toUpdate.map((row) => + api.post(`/ai-budget/${row.budgetId}`, { + amount: Number(row.amount), + unit: row.unit, + period: row.period + }) + ) + ]); + + await queryClient.invalidateQueries( + aiBudgetQueries.scoped({ scope }) + ); + + toast({ + title: t("success"), + description: t("aiBudgetUpdated") + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiBudgetErrorSave"), + description: formatAxiosError(e, t("aiBudgetErrorSave")) + }); + } finally { + setSaveLoading(false); + } + } + + const periodLabels: Record = { + hourly: t("aiBudgetPeriodHourly"), + daily: t("aiBudgetPeriodDaily"), + weekly: t("aiBudgetPeriodWeekly"), + monthly: t("aiBudgetPeriodMonthly"), + yearly: t("aiBudgetPeriodYearly"), + lifetime: t("aiBudgetPeriodLifetime") + }; + + const unitLabels: Record = { + usd: t("aiBudgetUnitUsd"), + tokens: t("aiBudgetUnitTokens") + }; + + return ( + + +
+
+ {title} + + {description} + +
+ +
+
+ + + {rows.length === 0 ? ( +

+ {t("aiBudgetEmpty")} +

+ ) : ( +
+ {rows.map((row) => { + const showConflict = conflictingKeys.has(row.key); + const showInvalidAmount = + attemptedSave && + invalidAmountKeys.has(row.key); + return ( +
+
+ + updateRow(row.key, { + amount: e.target.value + }) + } + className="flex-1" + /> + + + +
+ {showConflict && ( +

+ {t("aiBudgetConflictError")} +

+ )} + {showInvalidAmount && ( +

+ {t("aiBudgetInvalidAmountError")} +

+ )} +
+ ); + })} +
+ )} +
+ + + + +
+ ); +} diff --git a/src/lib/aiBudgetScope.ts b/src/lib/aiBudgetScope.ts new file mode 100644 index 000000000..beabc85a3 --- /dev/null +++ b/src/lib/aiBudgetScope.ts @@ -0,0 +1,70 @@ +import type { AiBudget } from "@server/db"; + +export type AiBudgetScopeType = + | "provider" + | "model" + | "resource" + | "siteResource" + | "role"; + +export type AiBudgetScope = { + type: AiBudgetScopeType; + id: number; +}; + +export type AiBudgetScopeBodyField = + | "providerId" + | "modelId" + | "resourceId" + | "siteResourceId" + | "roleId"; + +const scopeConfig: Record< + AiBudgetScopeType, + { listPath: (id: number) => string; bodyField: AiBudgetScopeBodyField } +> = { + provider: { + listPath: (id) => `/ai-provider/${id}/ai-budgets`, + bodyField: "providerId" + }, + model: { + listPath: (id) => `/ai-model/${id}/ai-budgets`, + bodyField: "modelId" + }, + resource: { + listPath: (id) => `/resource/${id}/ai-budgets`, + bodyField: "resourceId" + }, + siteResource: { + listPath: (id) => `/site-resource/${id}/ai-budgets`, + bodyField: "siteResourceId" + }, + role: { + listPath: (id) => `/role/${id}/ai-budgets`, + bodyField: "roleId" + } +}; + +export function getAiBudgetScopeListPath(scope: AiBudgetScope): string { + return scopeConfig[scope.type].listPath(scope.id); +} + +export function getAiBudgetScopeBodyField( + scope: AiBudgetScope +): AiBudgetScopeBodyField { + return scopeConfig[scope.type].bodyField; +} + +export type AiBudgetUnit = AiBudget["unit"]; +export type AiBudgetPeriod = AiBudget["period"]; + +export const AI_BUDGET_UNITS: AiBudgetUnit[] = ["usd", "tokens"]; + +export const AI_BUDGET_PERIODS: AiBudgetPeriod[] = [ + "hourly", + "daily", + "weekly", + "monthly", + "yearly", + "lifetime" +]; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index e1e06f928..868b3aa90 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -63,6 +63,11 @@ import type { ListAiModelsResponse, ListAiProvidersResponse } from "@server/routers/aiProvider/types"; +import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types"; +import { + getAiBudgetScopeListPath, + type AiBudgetScope +} from "@app/lib/aiBudgetScope"; import type { ListUsersResponse } from "@server/routers/user"; import type ResponseT from "@server/types/Response"; import { @@ -1213,6 +1218,19 @@ export const aiProviderQueries = { }) }; +export const aiBudgetQueries = { + scoped: ({ scope }: { scope: AiBudgetScope }) => + queryOptions({ + queryKey: ["AI_BUDGETS", scope.type, scope.id] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(getAiBudgetScopeListPath(scope), { signal }); + return res.data.data.budgets; + } + }) +}; + export const resourceQueries = { resourceUsers: ({ resourceId }: { resourceId: number }) => queryOptions({ From 7e6e0a8a9c8c3ce411ef4088423ad3e70da213f4 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 12:20:47 -0400 Subject: [PATCH 265/494] make the budget match the rules --- messages/en-US.json | 3 + src/components/BudgetsEditor.tsx | 387 +++++++++++++++++++------------ 2 files changed, 242 insertions(+), 148 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 9acb93b45..6dea651c9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1657,6 +1657,9 @@ "aiProviderBudgetSettingsDescription": "Configure how this provider restricts usage based on spending or token limits", "aiBudgetAdd": "Add Budget", "aiBudgetEmpty": "No budgets configured yet. Click Add Budget to set a spending or token limit.", + "aiBudgetUnit": "Spend Type", + "aiBudgetPeriod": "Reset Period", + "aiBudgetAmount": "Maximum Spend", "aiBudgetAmountPlaceholder": "Maximum spend", "aiBudgetPeriodHourly": "Hourly", "aiBudgetPeriodDaily": "Daily", diff --git a/src/components/BudgetsEditor.tsx b/src/components/BudgetsEditor.tsx index 1327998cf..edef2906b 100644 --- a/src/components/BudgetsEditor.tsx +++ b/src/components/BudgetsEditor.tsx @@ -1,14 +1,18 @@ "use client"; import { + SettingsFormCell, + SettingsFormGrid, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, + SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { Button } from "@app/components/ui/button"; +import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state"; import { Input } from "@app/components/ui/input"; import { Select, @@ -17,6 +21,14 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; @@ -247,160 +259,239 @@ export function BudgetsEditor({ tokens: t("aiBudgetUnitTokens") }; + const addRowButton = ( + + ); + return ( -
-
- {title} - - {description} - -
- -
+ {title} + + {description} +
- {rows.length === 0 ? ( -

- {t("aiBudgetEmpty")} -

- ) : ( -
- {rows.map((row) => { - const showConflict = conflictingKeys.has(row.key); - const showInvalidAmount = - attemptedSave && - invalidAmountKeys.has(row.key); - return ( -
-
- - updateRow(row.key, { - amount: e.target.value - }) - } - className="flex-1" - /> - - - -
- {showConflict && ( -

- {t("aiBudgetConflictError")} -

- )} - {showInvalidAmount && ( -

- {t("aiBudgetInvalidAmountError")} -

- )} -
- ); - })} -
- )} + + + +
+
+ + + + {t("aiBudgetUnit")} + + + {t("aiBudgetPeriod")} + + + {t("aiBudgetAmount")} + + + + + + {rows.length === 0 ? ( + + ) : ( + rows.map((row) => { + const showConflict = + conflictingKeys.has( + row.key + ); + const showInvalidAmount = + attemptedSave && + invalidAmountKeys.has( + row.key + ); + return ( + + + + + + + + + + updateRow( + row.key, + { + amount: e + .target + .value + } + ) + } + className="w-full min-w-0" + /> + + + + + + ); + }) + )} + +
+ {(conflictingKeys.size > 0 || + (attemptedSave && + invalidAmountKeys.size > 0)) && ( +

+ {conflictingKeys.size > 0 + ? t("aiBudgetConflictError") + : t("aiBudgetInvalidAmountError")} +

+ )} + {rows.length > 0 && addRowButton} +
+
+ + From 93dea0525ecbe5fd6f75ce507b4d2b69d3a48fa5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 10 Aug 2026 12:32:00 -0400 Subject: [PATCH 266/494] basic provider model selector --- messages/en-US.json | 8 +- server/lib/aiModelCatalog.ts | 27 ++++ server/lib/aiModelPricing.ts | 21 +-- server/routers/aiProvider/index.ts | 1 + .../routers/aiProvider/listCatalogModels.ts | 131 ++++++++++++++++++ server/routers/aiProvider/types.ts | 4 + server/routers/external.ts | 7 + .../ai-providers/[providerId]/models/page.tsx | 24 +++- src/lib/queries.ts | 13 +- 9 files changed, 209 insertions(+), 27 deletions(-) create mode 100644 server/routers/aiProvider/listCatalogModels.ts diff --git a/messages/en-US.json b/messages/en-US.json index 9acb93b45..da08b4a57 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1776,14 +1776,14 @@ "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 allow and block patterns for this provider. Requests must match an allow pattern and must not match a block pattern. Use * and ? as wildcards (for example gpt-4* or claude-?). An empty allow list denies all models.", - "aiProviderModelsPlaceholder": "Model name or pattern (e.g. gpt-4*)", + "aiProviderModelsDescription": "Define allow and block patterns for this provider. Requests must match an allow pattern and must not match a block pattern. Pick from known models for this provider, or type a custom key or wildcard (for example gpt-4* or claude-?). An empty allow list denies all models.", + "aiProviderModelsPlaceholder": "Select a model or type a pattern (e.g. gpt-4*)", "aiProviderModelsAllow": "Allow List", "aiProviderModelsAllowDescription": "Models that may be used through this provider. Empty means deny all.", - "aiProviderModelsAllowPlaceholder": "Allowed model or pattern (e.g. gpt-4*)", + "aiProviderModelsAllowPlaceholder": "Select a model or type a pattern (e.g. gpt-4*)", "aiProviderModelsBlock": "Block List", "aiProviderModelsBlockDescription": "Models to deny even if they match an allow pattern.", - "aiProviderModelsBlockPlaceholder": "Blocked model or pattern (e.g. gpt-4o-mini)", + "aiProviderModelsBlockPlaceholder": "Select a model or type a pattern (e.g. gpt-4o-mini)", "aiProviderModelsOverlapError": "These patterns cannot be on both lists: {keys}", "aiProviderModelsUpdated": "Models updated", "aiProviderModelsErrorUpdate": "Failed to update models", diff --git a/server/lib/aiModelCatalog.ts b/server/lib/aiModelCatalog.ts index faee7a345..9986366fb 100644 --- a/server/lib/aiModelCatalog.ts +++ b/server/lib/aiModelCatalog.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import axios from "axios"; import config from "@server/lib/config"; import logger from "@server/logger"; +import type { AiProviderType } from "@server/lib/aiProviderDefaults"; export const CATALOG_PROVIDERS = [ "openai", @@ -16,6 +17,32 @@ export type CatalogProvider = (typeof CATALOG_PROVIDERS)[number]; const CATALOG_PROVIDER_SET = new Set(CATALOG_PROVIDERS); +// Each of our provider types maps to at most one catalog provider. Provider +// types that proxy arbitrary underlying models (openRouter, vercelAiGateway, +// custom) have no mapping. +const PROVIDER_CATALOG_MAP: Record< + Exclude, + CatalogProvider | null +> = { + openai: "openai", + anthropic: "anthropic", + googleGemini: "gemini", + vertexAi: "vertex", + bedrock: "bedrock", + microsoftFoundry: "azure", + openRouter: null, + vercelAiGateway: null +}; + +export function getCatalogProviderForType( + type: AiProviderType +): CatalogProvider | null { + if (type === "custom") { + return null; + } + return PROVIDER_CATALOG_MAP[type]; +} + export type AiModelCatalogEntry = { provider: CatalogProvider; model: string; diff --git a/server/lib/aiModelPricing.ts b/server/lib/aiModelPricing.ts index 4cc398068..569bfa42e 100644 --- a/server/lib/aiModelPricing.ts +++ b/server/lib/aiModelPricing.ts @@ -2,6 +2,7 @@ import type { AiProviderType } from "@server/lib/aiProviderDefaults"; import type { AiUsage } from "@server/lib/aiUsageExtraction"; import { aiModelCatalog, + getCatalogProviderForType, type AiModelCatalogEntry, type CatalogProvider } from "@server/lib/aiModelCatalog"; @@ -19,23 +20,6 @@ export type AiModelPricing = { approximate: boolean; }; -// Each of our provider types maps to at most one catalog provider. Provider types that proxy -// arbitrary underlying models (openRouter, vercelAiGateway, custom) have no -// mapping and always fall back to a global search. -const PROVIDER_CATALOG_MAP: Record< - Exclude, - CatalogProvider | null -> = { - openai: "openai", - anthropic: "anthropic", - googleGemini: "gemini", - vertexAi: "vertex", - bedrock: "bedrock", - microsoftFoundry: "azure", - openRouter: null, - vercelAiGateway: null -}; - function stripVendorPrefix(modelId: string): string | null { const idx = modelId.indexOf("/"); if (idx === -1 || idx === modelId.length - 1) { @@ -96,8 +80,7 @@ export function getModelPricing( return null; } - const catalogProvider = - providerType === "custom" ? null : PROVIDER_CATALOG_MAP[providerType]; + const catalogProvider = getCatalogProviderForType(providerType); if (catalogProvider) { const scoped = findEntry(modelId, catalogProvider); diff --git a/server/routers/aiProvider/index.ts b/server/routers/aiProvider/index.ts index 34c900139..835991fbd 100644 --- a/server/routers/aiProvider/index.ts +++ b/server/routers/aiProvider/index.ts @@ -5,6 +5,7 @@ export * from "./updateAiProvider"; export * from "./deleteAiProvider"; export * from "./createAiModel"; export * from "./listAiModels"; +export * from "./listCatalogModels"; export * from "./getAiModel"; export * from "./updateAiModel"; export * from "./deleteAiModel"; diff --git a/server/routers/aiProvider/listCatalogModels.ts b/server/routers/aiProvider/listCatalogModels.ts new file mode 100644 index 000000000..9a3241276 --- /dev/null +++ b/server/routers/aiProvider/listCatalogModels.ts @@ -0,0 +1,131 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq } from "drizzle-orm"; +import { + aiModelCatalog, + getCatalogProviderForType +} from "@server/lib/aiModelCatalog"; +import type { AiProviderType } from "@server/lib/aiProviderDefaults"; +import type { ListCatalogModelsResponse } from "@server/routers/aiProvider/types"; + +const paramsSchema = z.strictObject({ + providerId: z.coerce.number().int().positive() +}); + +const listSchema = z.object({ + query: z.string().optional() +}); + +registry.registerPath({ + method: "get", + path: "/ai-provider/{providerId}/catalog-models", + description: + "List known catalog models for an AI provider's type. Used for model key suggestions.", + tags: [OpenAPITags.AiModel], + request: { + params: paramsSchema, + query: listSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listCatalogModels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { providerId } = parsedParams.data; + + const [provider] = + req.aiProvider && req.aiProvider.providerId === providerId + ? [req.aiProvider] + : await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, providerId)) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + const catalogProvider = getCatalogProviderForType( + provider.type as AiProviderType + ); + + let models = catalogProvider + ? aiModelCatalog.list(catalogProvider).map((entry) => ({ + model: entry.model + })) + : []; + + const { query } = parsedQuery.data; + if (query) { + const q = query.toLowerCase(); + models = models.filter((m) => m.model.toLowerCase().includes(q)); + } + + // Deduplicate model keys (catalog may have duplicates after provider + // normalization, e.g. bedrock + bedrock_converse). + const seen = new Set(); + models = models.filter((m) => { + if (seen.has(m.model)) { + return false; + } + seen.add(m.model); + return true; + }); + + models.sort((a, b) => a.model.localeCompare(b.model)); + + return response(res, { + data: { models }, + success: true, + error: false, + message: "Catalog models retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/types.ts b/server/routers/aiProvider/types.ts index d2f314b30..c2b8e2ed1 100644 --- a/server/routers/aiProvider/types.ts +++ b/server/routers/aiProvider/types.ts @@ -39,6 +39,10 @@ export type ListAiModelsResponse = PaginatedResponse<{ models: AiModel[]; }>; +export type ListCatalogModelsResponse = { + models: { model: string }[]; +}; + export type GetAiModelResponse = { model: AiModel; }; diff --git a/server/routers/external.ts b/server/routers/external.ts index 41df6ea59..9e1a5f210 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1565,6 +1565,13 @@ authenticated.get( aiProvider.listAiModels ); +authenticated.get( + "/ai-provider/:providerId/catalog-models", + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.listAiModels), + aiProvider.listCatalogModels +); + authenticated.get( "/ai-model/:modelId", verifyAiModelAccess, diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx index 10db41571..cba252dc8 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx @@ -20,7 +20,7 @@ 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"; +import { useEffect, useMemo, useState } from "react"; type ModelListType = "allow" | "block"; @@ -43,6 +43,18 @@ export default function AiProviderModelsPage() { const modelsQuery = useQuery( aiProviderQueries.providerModels({ providerId: provider.providerId }) ); + const catalogQuery = useQuery( + aiProviderQueries.catalogModels({ providerId: provider.providerId }) + ); + + const catalogTags = useMemo( + () => + (catalogQuery.data ?? []).map((entry) => ({ + id: entry.model, + text: entry.model + })), + [catalogQuery.data] + ); useEffect(() => { if (!modelsQuery.data) return; @@ -165,6 +177,8 @@ export default function AiProviderModelsPage() { } } + const inputsDisabled = modelsQuery.isLoading || saveLoading; + return ( @@ -196,10 +210,12 @@ export default function AiProviderModelsPage() { : newTags; setAllowTags(next as Tag[]); }} + enableAutocomplete={catalogTags.length > 0} + autocompleteOptions={catalogTags} allowDuplicates={false} sortTags delimiterList={[",", "Enter"]} - disabled={modelsQuery.isLoading || saveLoading} + disabled={inputsDisabled} />

{t("aiProviderModelsAllowDescription")} @@ -223,10 +239,12 @@ export default function AiProviderModelsPage() { : newTags; setBlockTags(next as Tag[]); }} + enableAutocomplete={catalogTags.length > 0} + autocompleteOptions={catalogTags} allowDuplicates={false} sortTags delimiterList={[",", "Enter"]} - disabled={modelsQuery.isLoading || saveLoading} + disabled={inputsDisabled} />

{t("aiProviderModelsBlockDescription")} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 868b3aa90..a7faae811 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -61,7 +61,8 @@ import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSi import type { ListTargetsResponse } from "@server/routers/target"; import type { ListAiModelsResponse, - ListAiProvidersResponse + ListAiProvidersResponse, + ListCatalogModelsResponse } from "@server/routers/aiProvider/types"; import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types"; import { @@ -1199,6 +1200,16 @@ export const aiProviderQueries = { return res.data.data.models; } }), + catalogModels: ({ providerId }: { providerId: number }) => + queryOptions({ + queryKey: ["AI_PROVIDERS", providerId, "CATALOG_MODELS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/ai-provider/${providerId}/catalog-models`, { signal }); + return res.data.data.models; + } + }), orgProviders: ({ orgId, query }: { orgId: string; query?: string }) => queryOptions({ queryKey: ["AI_PROVIDERS", orgId, "LIST", query ?? ""] as const, From f2e7f83b3d8995178bb2ab3c0001f6480db9d593 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 13:55:05 -0400 Subject: [PATCH 267/494] match the budget picker to the rules picker --- src/components/BudgetsEditor.tsx | 370 ++++++++++++++----------------- 1 file changed, 161 insertions(+), 209 deletions(-) diff --git a/src/components/BudgetsEditor.tsx b/src/components/BudgetsEditor.tsx index edef2906b..2a8b73022 100644 --- a/src/components/BudgetsEditor.tsx +++ b/src/components/BudgetsEditor.tsx @@ -1,13 +1,10 @@ "use client"; import { - SettingsFormCell, - SettingsFormGrid, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, - SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; @@ -281,217 +278,172 @@ export function BudgetsEditor({ - - - -

- - - - - {t("aiBudgetUnit")} - - - {t("aiBudgetPeriod")} - - - {t("aiBudgetAmount")} - - - - - - {rows.length === 0 ? ( - - ) : ( - rows.map((row) => { - const showConflict = - conflictingKeys.has( - row.key - ); - const showInvalidAmount = - attemptedSave && - invalidAmountKeys.has( - row.key - ); - return ( - - -
+ + + {t("aiBudgetAmount")} + {t("aiBudgetUnit")} + {t("aiBudgetPeriod")} + + + + + {rows.length === 0 ? ( + + ) : ( + rows.map((row) => { + const showConflict = conflictingKeys.has( + row.key + ); + const showInvalidAmount = + attemptedSave && + invalidAmountKeys.has(row.key); + return ( + + + + updateRow(row.key, { + amount: e.target + .value + }) + } + className="w-full min-w-0" + /> + + + - - - + + + - - - - updateRow( - row.key, - { - amount: e - .target - .value - } - ) - } - className="w-full min-w-0" - /> - - - - - - ); - }) - )} - -
- {(conflictingKeys.size > 0 || - (attemptedSave && - invalidAmountKeys.size > 0)) && ( -

- {conflictingKeys.size > 0 - ? t("aiBudgetConflictError") - : t("aiBudgetInvalidAmountError")} -

- )} - {rows.length > 0 && addRowButton} -
- - - + ] + } + + ) + )} + + + + +
+ +
+
+ + ); + }) + )} + + + {(conflictingKeys.size > 0 || + (attemptedSave && invalidAmountKeys.size > 0)) && ( +

+ {conflictingKeys.size > 0 + ? t("aiBudgetConflictError") + : t("aiBudgetInvalidAmountError")} +

+ )} + {rows.length > 0 && addRowButton} +
From 52de5eb539207f1ffa14ef7bad245516558b3fb6 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 14:08:17 -0400 Subject: [PATCH 268/494] add the budget config to the resources --- messages/en-US.json | 2 + .../private/[niceId]/budget/page.tsx | 42 +++++++++++++++++++ .../resources/private/[niceId]/layout.tsx | 7 ++++ .../resources/public/[niceId]/budget/page.tsx | 37 ++++++++++++++++ .../resources/public/[niceId]/layout.tsx | 7 ++++ 5 files changed, 95 insertions(+) create mode 100644 src/app/[orgId]/settings/resources/private/[niceId]/budget/page.tsx create mode 100644 src/app/[orgId]/settings/resources/public/[niceId]/budget/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 5b9eb0ce5..646a82664 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1817,6 +1817,8 @@ "aiResourceAliasRequired": "Alias is required for inference resources", "aiResourceDomainConfiguration": "Domain configuration", "aiResourceDomainConfigurationDescription": "Choose the domain clients will use to reach this inference resource.", + "resourceBudgetSettings": "Budget", + "resourceBudgetSettingsDescription": "Configure how this inference resource restricts usage based on spending or token limits", "sidebarApiKeys": "API Keys", "sidebarProvisioning": "Provisioning", "sidebarSettings": "Settings", diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/budget/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/budget/page.tsx new file mode 100644 index 000000000..9268d664e --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/budget/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { SettingsContainer } from "@app/components/Settings"; +import { BudgetsEditor } from "@app/components/BudgetsEditor"; +import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function PrivateResourceBudgetPage() { + const { siteResource } = useSiteResourceContext(); + const router = useRouter(); + const t = useTranslations(); + + useEffect(() => { + if (siteResource.mode !== "inference") { + router.replace( + `/${siteResource.orgId}/settings/resources/private/${siteResource.niceId}/general` + ); + } + }, [ + router, + siteResource.mode, + siteResource.niceId, + siteResource.orgId + ]); + + if (siteResource.mode !== "inference") { + return null; + } + + return ( + + + + ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx index 294e8f81a..e4bb32ef7 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx @@ -70,6 +70,13 @@ export default async function PrivateResourceLayout( } ]; + if (siteResource.mode === "inference") { + navItems.push({ + title: t("resourceBudgetSettings"), + href: `/{orgId}/settings/resources/private/{niceId}/budget` + }); + } + return ( <> { + if (resource.mode !== "inference") { + router.replace( + `/${resource.orgId}/settings/resources/public/${resource.niceId}/general` + ); + } + }, [router, resource.mode, resource.niceId, resource.orgId]); + + if (resource.mode !== "inference") { + return null; + } + + return ( + + + + ); +} diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index 43a3a62c0..ed69a22b0 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -113,6 +113,13 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { } } + if (resource.mode === "inference") { + navItems.push({ + title: t("resourceBudgetSettings"), + href: `/{orgId}/settings/resources/public/{niceId}/budget` + }); + } + return ( <> Date: Mon, 10 Aug 2026 14:12:34 -0400 Subject: [PATCH 269/494] order by budget id --- server/routers/aiBudget/listAiBudgetsForModel.ts | 2 +- server/routers/aiBudget/listAiBudgetsForProvider.ts | 2 +- server/routers/aiBudget/listAiBudgetsForResource.ts | 2 +- server/routers/aiBudget/listAiBudgetsForRole.ts | 2 +- server/routers/aiBudget/listAiBudgetsForSiteResource.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/routers/aiBudget/listAiBudgetsForModel.ts b/server/routers/aiBudget/listAiBudgetsForModel.ts index 4a2277b5b..2c04368a7 100644 --- a/server/routers/aiBudget/listAiBudgetsForModel.ts +++ b/server/routers/aiBudget/listAiBudgetsForModel.ts @@ -51,7 +51,7 @@ export async function listAiBudgetsForModel( .select() .from(aiBudgets) .where(eq(aiBudgets.modelId, modelId)) - .orderBy(asc(aiBudgets.unit), asc(aiBudgets.period)); + .orderBy(asc(aiBudgets.budgetId)); return response(res, { data: { budgets }, diff --git a/server/routers/aiBudget/listAiBudgetsForProvider.ts b/server/routers/aiBudget/listAiBudgetsForProvider.ts index 1a0625f4b..d231f7445 100644 --- a/server/routers/aiBudget/listAiBudgetsForProvider.ts +++ b/server/routers/aiBudget/listAiBudgetsForProvider.ts @@ -51,7 +51,7 @@ export async function listAiBudgetsForProvider( .select() .from(aiBudgets) .where(eq(aiBudgets.providerId, providerId)) - .orderBy(asc(aiBudgets.unit), asc(aiBudgets.period)); + .orderBy(asc(aiBudgets.budgetId)); return response(res, { data: { budgets }, diff --git a/server/routers/aiBudget/listAiBudgetsForResource.ts b/server/routers/aiBudget/listAiBudgetsForResource.ts index 106b049a7..0d59ccae6 100644 --- a/server/routers/aiBudget/listAiBudgetsForResource.ts +++ b/server/routers/aiBudget/listAiBudgetsForResource.ts @@ -51,7 +51,7 @@ export async function listAiBudgetsForResource( .select() .from(aiBudgets) .where(eq(aiBudgets.resourceId, resourceId)) - .orderBy(asc(aiBudgets.unit), asc(aiBudgets.period)); + .orderBy(asc(aiBudgets.budgetId)); return response(res, { data: { budgets }, diff --git a/server/routers/aiBudget/listAiBudgetsForRole.ts b/server/routers/aiBudget/listAiBudgetsForRole.ts index 2e8ff0e8d..45f90a4a6 100644 --- a/server/routers/aiBudget/listAiBudgetsForRole.ts +++ b/server/routers/aiBudget/listAiBudgetsForRole.ts @@ -51,7 +51,7 @@ export async function listAiBudgetsForRole( .select() .from(aiBudgets) .where(eq(aiBudgets.roleId, roleId)) - .orderBy(asc(aiBudgets.unit), asc(aiBudgets.period)); + .orderBy(asc(aiBudgets.budgetId)); return response(res, { data: { budgets }, diff --git a/server/routers/aiBudget/listAiBudgetsForSiteResource.ts b/server/routers/aiBudget/listAiBudgetsForSiteResource.ts index b2557719d..a1f6e280d 100644 --- a/server/routers/aiBudget/listAiBudgetsForSiteResource.ts +++ b/server/routers/aiBudget/listAiBudgetsForSiteResource.ts @@ -51,7 +51,7 @@ export async function listAiBudgetsForSiteResource( .select() .from(aiBudgets) .where(eq(aiBudgets.siteResourceId, siteResourceId)) - .orderBy(asc(aiBudgets.unit), asc(aiBudgets.period)); + .orderBy(asc(aiBudgets.budgetId)); return response(res, { data: { budgets }, From 186eeed784bd62f9d8e36d43b0dd95f5c28a8cb0 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 10 Aug 2026 14:54:58 -0400 Subject: [PATCH 270/494] add richer model editor on provider --- messages/en-US.json | 45 +- .../ai-providers/[providerId]/models/page.tsx | 207 +++-- src/components/AiProviderModelListEditor.tsx | 875 ++++++++++++++++++ 3 files changed, 1028 insertions(+), 99 deletions(-) create mode 100644 src/components/AiProviderModelListEditor.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 646a82664..ec25fde1a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1779,14 +1779,49 @@ "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 allow and block patterns for this provider. Requests must match an allow pattern and must not match a block pattern. Pick from known models for this provider, or type a custom key or wildcard (for example gpt-4* or claude-?). An empty allow list denies all models.", - "aiProviderModelsPlaceholder": "Select a model or type a pattern (e.g. gpt-4*)", + "aiProviderModelsDescription": "Define allow and block lists for this provider. Requests must match an allow entry and must not match a block entry.", + "aiProviderModelsPlaceholder": "Search models or type a custom key", "aiProviderModelsAllow": "Allow List", "aiProviderModelsAllowDescription": "Models that may be used through this provider. Empty means deny all.", - "aiProviderModelsAllowPlaceholder": "Select a model or type a pattern (e.g. gpt-4*)", + "aiProviderModelsAllowPlaceholder": "Enter model key", + "aiProviderModelsAllowEmpty": "No models allowed. All requests will be denied.", "aiProviderModelsBlock": "Block List", - "aiProviderModelsBlockDescription": "Models to deny even if they match an allow pattern.", - "aiProviderModelsBlockPlaceholder": "Select a model or type a pattern (e.g. gpt-4o-mini)", + "aiProviderModelsBlockDescription": "Models to deny even if they match an allow entry.", + "aiProviderModelsBlockPlaceholder": "Enter model key", + "aiProviderModelsBlockEmpty": "No blocked models.", + "aiProviderModelsAdd": "Add Models", + "aiProviderModelsClearAll": "Clear All", + "aiProviderModelsAddCustom": "Add \"{key}\"", + "aiProviderModelsAddCustomHint": "Press Enter to add this custom model key.", + "aiProviderModelsAddBulk": "Add {count} custom keys", + "aiProviderModelsAddBulkHint": "Press Enter to add {count} custom keys.", + "aiProviderModelsAddOne": "Add {key} now", + "aiProviderModelsAddSelected": "Add Selected", + "aiProviderModelsSelectedCount": "{count} selected", + "aiProviderModelsSelectAll": "Select all", + "aiProviderModelsClearSelected": "Clear", + "aiProviderModelsBulkHint": "Select known models or type a custom key.", + "aiProviderModelsCatalogEmpty": "No matching catalog models.", + "aiProviderModelsCatalogHeading": "Known Models", + "aiProviderModelsAllLabel": "All models", + "aiProviderModelsAllPatternHint": "Wildcard: *", + "aiProviderModelsAddAllAllow": "Allow all models", + "aiProviderModelsAddAllBlock": "Block all models", + "aiProviderModelsAddAllDescription": "Uses the * wildcard so every model key matches.", + "aiProviderModelsViewMore": "View more ({count})", + "aiProviderModelsViewLess": "View less", + "aiProviderModelsRemove": "Remove model", + "aiProviderModelsEditHint": "Click to edit model settings", + "aiProviderModelsSourceCatalog": "Known catalog model", + "aiProviderModelsSourceCustom": "Custom model key", + "aiProviderModelsSourcePattern": "Wildcard pattern", + "aiProviderModelsSourceAll": "Matches every model key", + "aiProviderModelsBudgetConfigured": "Budget configured", + "aiProviderModelsEditTitle": "Edit Model", + "aiProviderModelsEditDescription": "Update the model key. Additional settings such as budgets will appear here later.", + "aiProviderModelsKeyLabel": "Model Key", + "aiProviderModelsKeyRequired": "Enter a model key", + "aiProviderModelsKeyDuplicate": "This model key is already on a list", "aiProviderModelsOverlapError": "These patterns cannot be on both lists: {keys}", "aiProviderModelsUpdated": "Models updated", "aiProviderModelsErrorUpdate": "Failed to update models", diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx index cba252dc8..c8a1a4483 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx @@ -10,7 +10,11 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; -import { TagInput, type Tag } from "@app/components/tags/tag-input"; +import { + AiProviderModelListEditor, + type AiProviderModelListItem, + type ModelListType +} from "@app/components/AiProviderModelListEditor"; import { Button } from "@app/components/ui/button"; import { Label } from "@app/components/ui/label"; import { useAiProviderContext } from "@app/hooks/useAiProviderContext"; @@ -22,7 +26,22 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useEffect, useMemo, useState } from "react"; -type ModelListType = "allow" | "block"; +function toListItem( + model: { + modelId: number; + modelKey: string; + listType?: ModelListType | null; + }, + listType: ModelListType +): AiProviderModelListItem { + return { + clientId: String(model.modelId), + modelId: model.modelId, + modelKey: model.modelKey, + listType, + hasBudget: false + }; +} export default function AiProviderModelsPage() { const { provider } = useAiProviderContext(); @@ -31,14 +50,8 @@ export default function AiProviderModelsPage() { const queryClient = useQueryClient(); const t = useTranslations(); const [saveLoading, setSaveLoading] = useState(false); - const [allowTags, setAllowTags] = useState([]); - const [blockTags, setBlockTags] = useState([]); - const [activeAllowTagIndex, setActiveAllowTagIndex] = useState< - number | null - >(null); - const [activeBlockTagIndex, setActiveBlockTagIndex] = useState< - number | null - >(null); + const [allowItems, setAllowItems] = useState([]); + const [blockItems, setBlockItems] = useState([]); const modelsQuery = useQuery( aiProviderQueries.providerModels({ providerId: provider.providerId }) @@ -47,32 +60,31 @@ export default function AiProviderModelsPage() { aiProviderQueries.catalogModels({ providerId: provider.providerId }) ); - const catalogTags = useMemo( - () => - (catalogQuery.data ?? []).map((entry) => ({ - id: entry.model, - text: entry.model - })), + const catalogModels = useMemo( + () => (catalogQuery.data ?? []).map((entry) => entry.model), [catalogQuery.data] ); + const allowExcludeKeys = useMemo( + () => new Set(blockItems.map((item) => item.modelKey)), + [blockItems] + ); + const blockExcludeKeys = useMemo( + () => new Set(allowItems.map((item) => item.modelKey)), + [allowItems] + ); + useEffect(() => { if (!modelsQuery.data) return; - setAllowTags( + setAllowItems( modelsQuery.data .filter((model) => (model.listType ?? "allow") === "allow") - .map((model) => ({ - id: String(model.modelId), - text: model.modelKey - })) + .map((model) => toListItem(model, "allow")) ); - setBlockTags( + setBlockItems( modelsQuery.data .filter((model) => model.listType === "block") - .map((model) => ({ - id: String(model.modelId), - text: model.modelKey - })) + .map((model) => toListItem(model, "block")) ); }, [modelsQuery.data]); @@ -80,15 +92,23 @@ export default function AiProviderModelsPage() { setSaveLoading(true); try { const existing = modelsQuery.data ?? []; + const existingById = new Map( + existing.map((model) => [model.modelId, model]) + ); const existingByKey = new Map( existing.map((model) => [model.modelKey, model]) ); + const desiredItems = [...allowItems, ...blockItems].map((item) => ({ + ...item, + modelKey: item.modelKey.trim() + })); + const nextAllow = new Set( - allowTags.map((tag) => tag.text.trim()).filter(Boolean) + allowItems.map((item) => item.modelKey.trim()).filter(Boolean) ); const nextBlock = new Set( - blockTags.map((tag) => tag.text.trim()).filter(Boolean) + blockItems.map((item) => item.modelKey.trim()).filter(Boolean) ); const overlap = [...nextAllow].filter((key) => nextBlock.has(key)); @@ -103,41 +123,58 @@ export default function AiProviderModelsPage() { return; } - const desired = new Map(); - for (const key of nextAllow) { - desired.set(key, "allow"); - } - for (const key of nextBlock) { - desired.set(key, "block"); - } - const toCreate: { modelKey: string; listType: ModelListType }[] = []; const toUpdate: { modelId: number; + modelKey: string; listType: ModelListType; }[] = []; - const toDelete: number[] = []; + const retainedIds = new Set(); - for (const [modelKey, listType] of desired) { - const existingModel = existingByKey.get(modelKey); - if (!existingModel) { - toCreate.push({ modelKey, listType }); + for (const item of desiredItems) { + const listType = item.listType; + const modelKey = item.modelKey; + if (!modelKey) continue; + + if (item.modelId != null && existingById.has(item.modelId)) { + retainedIds.add(item.modelId); + const existingModel = existingById.get(item.modelId)!; + if ( + existingModel.modelKey !== modelKey || + (existingModel.listType ?? "allow") !== listType + ) { + toUpdate.push({ + modelId: item.modelId, + modelKey, + listType + }); + } continue; } - if ((existingModel.listType ?? "allow") !== listType) { - toUpdate.push({ - modelId: existingModel.modelId, - listType - }); + + const existingBySameKey = existingByKey.get(modelKey); + if ( + existingBySameKey && + !retainedIds.has(existingBySameKey.modelId) + ) { + retainedIds.add(existingBySameKey.modelId); + if ((existingBySameKey.listType ?? "allow") !== listType) { + toUpdate.push({ + modelId: existingBySameKey.modelId, + modelKey, + listType + }); + } + continue; } + + toCreate.push({ modelKey, listType }); } - for (const model of existing) { - if (!desired.has(model.modelKey)) { - toDelete.push(model.modelId); - } - } + const toDelete = existing + .filter((model) => !retainedIds.has(model.modelId)) + .map((model) => model.modelId); await Promise.all([ ...toCreate.map(({ modelKey, listType }) => @@ -147,8 +184,12 @@ export default function AiProviderModelsPage() { listType }) ), - ...toUpdate.map(({ modelId, listType }) => - api.post(`/ai-model/${modelId}`, { listType }) + ...toUpdate.map(({ modelId, modelKey, listType }) => + api.post(`/ai-model/${modelId}`, { + modelKey, + name: modelKey, + listType + }) ), ...toDelete.map((modelId) => api.delete(`/ai-model/${modelId}`)) ]); @@ -177,8 +218,6 @@ export default function AiProviderModelsPage() { } } - const inputsDisabled = modelsQuery.isLoading || saveLoading; - return ( @@ -192,30 +231,20 @@ export default function AiProviderModelsPage() { - +
- { - const next = - typeof newTags === "function" - ? newTags(allowTags) - : newTags; - setAllowTags(next as Tag[]); - }} - enableAutocomplete={catalogTags.length > 0} - autocompleteOptions={catalogTags} - allowDuplicates={false} - sortTags - delimiterList={[",", "Enter"]} - disabled={inputsDisabled} />

{t("aiProviderModelsAllowDescription")} @@ -224,27 +253,17 @@ export default function AiProviderModelsPage() {

- { - const next = - typeof newTags === "function" - ? newTags(blockTags) - : newTags; - setBlockTags(next as Tag[]); - }} - enableAutocomplete={catalogTags.length > 0} - autocompleteOptions={catalogTags} - allowDuplicates={false} - sortTags - delimiterList={[",", "Enter"]} - disabled={inputsDisabled} />

{t("aiProviderModelsBlockDescription")} diff --git a/src/components/AiProviderModelListEditor.tsx b/src/components/AiProviderModelListEditor.tsx new file mode 100644 index 000000000..e3bf2bf07 --- /dev/null +++ b/src/components/AiProviderModelListEditor.tsx @@ -0,0 +1,875 @@ +"use client"; + +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; +import { cn } from "@app/lib/cn"; +import { isModelKeyPattern } from "@server/lib/aiModelKeyMatch"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Asterisk, + BookMarked, + Check, + Globe, + Pencil, + Plus, + Wallet, + XIcon +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +export type ModelListType = "allow" | "block"; +export type ModelSource = "catalog" | "custom" | "pattern" | "all"; + +/** Matches every model key via the provider policy wildcard. */ +export const ALL_MODELS_KEY = "*"; + +const COLLAPSED_ROWS = 5; + +export type AiProviderModelListItem = { + clientId: string; + modelId?: number; + modelKey: string; + listType: ModelListType; + hasBudget?: boolean; +}; + +export type AiProviderModelListEditorProps = { + listType: ModelListType; + items: AiProviderModelListItem[]; + catalogModels: string[]; + /** Keys already used on this list or the sibling list. */ + excludeKeys?: ReadonlySet; + onChange: (items: AiProviderModelListItem[]) => void; + disabled?: boolean; + emptyMessage: string; + addPlaceholder: string; +}; + +export function isAllModelsKey(modelKey: string): boolean { + return modelKey.trim() === ALL_MODELS_KEY; +} + +export function resolveModelSource( + modelKey: string, + catalogModels: ReadonlySet | readonly string[] +): ModelSource { + if (isAllModelsKey(modelKey)) { + return "all"; + } + if (isModelKeyPattern(modelKey)) { + return "pattern"; + } + const set = + catalogModels instanceof Set ? catalogModels : new Set(catalogModels); + return set.has(modelKey) ? "catalog" : "custom"; +} + +function newClientId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function parseBulkKeys(raw: string): string[] { + const seen = new Set(); + const keys: string[] = []; + for (const part of raw.split(/[\n,]+/)) { + const key = part.trim(); + if (!key || seen.has(key)) continue; + seen.add(key); + keys.push(key); + } + return keys; +} + +function useModelGridColumns(): number { + const [columns, setColumns] = useState(1); + + useEffect(() => { + const sm = window.matchMedia("(min-width: 640px)"); + const xl = window.matchMedia("(min-width: 1280px)"); + const update = () => { + setColumns(xl.matches ? 3 : sm.matches ? 2 : 1); + }; + update(); + sm.addEventListener("change", update); + xl.addEventListener("change", update); + return () => { + sm.removeEventListener("change", update); + xl.removeEventListener("change", update); + }; + }, []); + + return columns; +} + +export function AiProviderModelListEditor({ + listType, + items, + catalogModels, + excludeKeys, + onChange, + disabled, + emptyMessage, + addPlaceholder +}: AiProviderModelListEditorProps) { + const t = useTranslations(); + const [editingClientId, setEditingClientId] = useState(null); + const [addOpen, setAddOpen] = useState(false); + const [addQuery, setAddQuery] = useState(""); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const [listExpanded, setListExpanded] = useState(false); + const [clipHeight, setClipHeight] = useState(null); + const gridRef = useRef(null); + const columns = useModelGridColumns(); + const collapsedLimit = columns * COLLAPSED_ROWS; + const hasOverflow = items.length > collapsedLimit; + const isCollapsed = hasOverflow && !listExpanded; + + const catalogSet = useMemo(() => new Set(catalogModels), [catalogModels]); + + const blockedKeys = useMemo(() => { + const set = new Set(excludeKeys ? [...excludeKeys] : []); + for (const item of items) { + set.add(item.modelKey); + } + return set; + }, [excludeKeys, items]); + + const availableCatalog = useMemo(() => { + const q = addQuery.trim().toLowerCase(); + return catalogModels + .filter((model) => !blockedKeys.has(model)) + .filter((model) => (q ? model.toLowerCase().includes(q) : true)); + }, [addQuery, blockedKeys, catalogModels]); + + const trimmedQuery = addQuery.trim(); + const bulkKeys = useMemo( + () => + parseBulkKeys(addQuery).filter( + (key) => !blockedKeys.has(key) && !catalogSet.has(key) + ), + [addQuery, blockedKeys, catalogSet] + ); + const canAddCustom = + bulkKeys.length === 1 && + !trimmedQuery.includes("\n") && + !trimmedQuery.includes(",") && + !isAllModelsKey(trimmedQuery) && + !catalogSet.has(trimmedQuery) && + !blockedKeys.has(trimmedQuery); + const canAddBulkCustom = bulkKeys.length > 1; + + const allModelsLabel = t("aiProviderModelsAllLabel"); + const showAllModelsOption = + !blockedKeys.has(ALL_MODELS_KEY) && + (!trimmedQuery || + trimmedQuery === ALL_MODELS_KEY || + "all".includes(trimmedQuery.toLowerCase()) || + allModelsLabel.toLowerCase().includes(trimmedQuery.toLowerCase())); + + const editing = items.find((item) => item.clientId === editingClientId); + + function appendModels(modelKeys: string[]) { + if (disabled) return; + const nextBlocked = new Set(blockedKeys); + const additions: AiProviderModelListItem[] = []; + for (const raw of modelKeys) { + const key = raw.trim(); + if (!key || nextBlocked.has(key)) continue; + nextBlocked.add(key); + additions.push({ + clientId: newClientId(), + modelKey: key, + listType, + hasBudget: false + }); + } + if (additions.length === 0) return; + onChange([...items, ...additions]); + } + + function addModel(modelKey: string, options?: { keepOpen?: boolean }) { + appendModels([modelKey]); + setAddQuery(""); + setSelectedKeys(new Set()); + if (!options?.keepOpen) { + setAddOpen(false); + } + } + + function addSelected() { + appendModels([...selectedKeys]); + setSelectedKeys(new Set()); + setAddQuery(""); + // Keep open so more can be selected after filter refresh + } + + function addBulkCustom() { + appendModels(bulkKeys); + setAddQuery(""); + setSelectedKeys(new Set()); + } + + function toggleSelected(model: string) { + setSelectedKeys((prev) => { + const next = new Set(prev); + if (next.has(model)) { + next.delete(model); + } else { + next.add(model); + } + return next; + }); + } + + function selectAllVisible() { + setSelectedKeys((prev) => { + const next = new Set(prev); + for (const model of availableCatalog) { + next.add(model); + } + return next; + }); + } + + function clearSelected() { + setSelectedKeys(new Set()); + } + + function removeModel(clientId: string) { + onChange(items.filter((item) => item.clientId !== clientId)); + } + + function updateModel(updated: AiProviderModelListItem) { + onChange( + items.map((item) => + item.clientId === updated.clientId ? updated : item + ) + ); + setEditingClientId(null); + } + + // Drop selections that are no longer available (already added). + useEffect(() => { + setSelectedKeys((prev) => { + let changed = false; + const next = new Set(); + for (const key of prev) { + if (blockedKeys.has(key)) { + changed = true; + continue; + } + next.add(key); + } + return changed ? next : prev; + }); + }, [blockedKeys]); + + 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, items]); + + return ( +

+
+
+
+ {items.length === 0 ? ( +
+ + {emptyMessage} + +
+ ) : ( + items.map((item) => ( + + setEditingClientId(item.clientId) + } + onRemove={() => removeModel(item.clientId)} + /> + )) + )} +
+ {isCollapsed ? ( +
+ ) : null} +
+ {isCollapsed ? ( +
+ +
+ ) : null} + {hasOverflow && listExpanded ? ( +
+ +
+ ) : null} +
+ +
+ { + if (disabled) return; + setAddOpen(open); + if (!open) { + setAddQuery(""); + setSelectedKeys(new Set()); + } + }} + > + + + + + + { + if (e.key !== "Enter") return; + if (canAddBulkCustom) { + e.preventDefault(); + addBulkCustom(); + return; + } + if (canAddCustom) { + e.preventDefault(); + addModel(trimmedQuery, { + keepOpen: true + }); + } + }} + /> +
+

+ {t("aiProviderModelsBulkHint")} +

+ {availableCatalog.length > 0 ? ( +
+ + {selectedKeys.size > 0 ? ( + + ) : null} +
+ ) : null} +
+ + + {canAddBulkCustom + ? t("aiProviderModelsAddBulkHint", { + count: bulkKeys.length + }) + : canAddCustom + ? t("aiProviderModelsAddCustomHint") + : t("aiProviderModelsCatalogEmpty")} + + {showAllModelsOption ? ( + + + addModel(ALL_MODELS_KEY, { + keepOpen: true + }) + } + className="items-start gap-2 py-2" + > + +
+

+ {listType === "allow" + ? t( + "aiProviderModelsAddAllAllow" + ) + : t( + "aiProviderModelsAddAllBlock" + )} +

+

+ {t( + "aiProviderModelsAddAllDescription" + )} +

+
+
+
+ ) : null} + {canAddBulkCustom ? ( + + + + {t("aiProviderModelsAddBulk", { + count: bulkKeys.length + })} + + + ) : null} + {canAddCustom ? ( + + + addModel(trimmedQuery, { + keepOpen: true + }) + } + > + + {t("aiProviderModelsAddCustom", { + key: trimmedQuery + })} + + + ) : null} + {availableCatalog.length > 0 ? ( + + {availableCatalog.map((model) => { + const isSelected = + selectedKeys.has(model); + return ( + { + // Toggle selection for bulk; + // double-purpose: shift-free multi-pick. + toggleSelected(model); + }} + className="gap-2" + > + + + {model} + + + ); + })} + + ) : null} +
+ {selectedKeys.size > 0 ? ( +
+ + {t("aiProviderModelsSelectedCount", { + count: selectedKeys.size + })} + + +
+ ) : null} +
+
+
+ {items.length > 0 ? ( + + ) : null} +
+ + {editing && ( + { + if (!open) setEditingClientId(null); + }} + existingKeys={blockedKeys} + onSave={updateModel} + /> + )} +
+ ); +} + +function ModelCard({ + item, + source, + disabled, + onEdit, + onRemove +}: { + item: AiProviderModelListItem; + source: ModelSource; + disabled?: boolean; + onEdit: () => void; + onRemove: () => void; +}) { + const t = useTranslations(); + + const sourceLabel = + source === "all" + ? t("aiProviderModelsSourceAll") + : source === "catalog" + ? t("aiProviderModelsSourceCatalog") + : source === "pattern" + ? t("aiProviderModelsSourcePattern") + : t("aiProviderModelsSourceCustom"); + + const SourceIcon = + source === "all" + ? Globe + : source === "catalog" + ? BookMarked + : source === "pattern" + ? Asterisk + : Pencil; + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onEdit(); + } + } + } + role={disabled ? undefined : "button"} + tabIndex={disabled ? undefined : 0} + title={t("aiProviderModelsEditHint")} + > +
+ {source === "all" ? ( + <> + + {t("aiProviderModelsAllLabel")} + + + ) : ( + + {item.modelKey} + + )} +
+ +
+ + + e.stopPropagation()} + > + + + + {sourceLabel} + + {item.hasBudget ? ( + + + e.stopPropagation()} + > + + + + + {t("aiProviderModelsBudgetConfigured")} + + + ) : null} +
+
+ +
+ ); +} + +type EditFormValues = { + modelKey: string; +}; + +function EditModelCredenza({ + item, + open, + onOpenChange, + existingKeys, + onSave +}: { + item: AiProviderModelListItem; + open: boolean; + onOpenChange: (open: boolean) => void; + existingKeys: ReadonlySet; + onSave: (item: AiProviderModelListItem) => void; +}) { + const t = useTranslations(); + + const editSchema = useMemo( + () => + z.object({ + modelKey: z + .string() + .trim() + .min(1, t("aiProviderModelsKeyRequired")) + .refine( + (key) => + key === item.modelKey || !existingKeys.has(key), + t("aiProviderModelsKeyDuplicate") + ) + }), + [existingKeys, item.modelKey, t] + ); + + const form = useForm({ + resolver: zodResolver(editSchema), + defaultValues: { modelKey: item.modelKey } + }); + + useEffect(() => { + if (!open) return; + form.reset({ modelKey: item.modelKey }); + }, [form, item.clientId, item.modelKey, open]); + + function handleSubmit(values: EditFormValues) { + onSave({ + ...item, + modelKey: values.modelKey.trim() + }); + } + + return ( + + + + + {t("aiProviderModelsEditTitle")} + + + {t("aiProviderModelsEditDescription")} + + +
+ + + ( + + + {t("aiProviderModelsKeyLabel")} + + + + + + + )} + /> + +
+ + + + + + + +
+
+ ); +} From 2becb15916ec4d545eb971c7a221d391ff781b65 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 14:27:17 -0400 Subject: [PATCH 271/494] put the budget on the role form --- messages/en-US.json | 2 ++ src/components/BudgetsEditor.tsx | 31 +++++++++++++++++++++---------- src/components/RoleForm.tsx | 29 ++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index ec25fde1a..f8619f7da 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1079,6 +1079,8 @@ "accessRoleErrorNewRequired": "New role is required", "accessRoleErrorRemove": "Failed to remove role", "accessRoleErrorRemoveDescription": "An error occurred while removing the role.", + "accessRoleInferenceBudget": "Inference Budget", + "accessRoleInferenceBudgetDescription": "Configure how members of this role restrict AI usage based on spending or token limits", "accessRoleName": "Role Name", "accessRoleQuestionRemove": "You're about to delete the `{name}` role. You cannot undo this action.", "accessRoleRemove": "Remove Role", diff --git a/src/components/BudgetsEditor.tsx b/src/components/BudgetsEditor.tsx index 2a8b73022..61a0595b8 100644 --- a/src/components/BudgetsEditor.tsx +++ b/src/components/BudgetsEditor.tsx @@ -85,12 +85,14 @@ export function BudgetsEditor({ scope, orgId, title, - description + description, + hideCardHeader = false }: { scope: AiBudgetScope; orgId: string; title: string; description: string; + hideCardHeader?: boolean; }) { const { env } = useEnvContext(); const api = createApiClient({ env }); @@ -268,15 +270,8 @@ export function BudgetsEditor({ ); - return ( - - - {title} - - {description} - - - + const body = ( + <>
@@ -456,6 +451,22 @@ export function BudgetsEditor({ {t("saveSettings")} + + ); + + if (hideCardHeader) { + return
{body}
; + } + + return ( + + + {title} + + {description} + + + {body} ); } diff --git a/src/components/RoleForm.tsx b/src/components/RoleForm.tsx index 102aa8e3a..eb34f6aa5 100644 --- a/src/components/RoleForm.tsx +++ b/src/components/RoleForm.tsx @@ -35,6 +35,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; import { CheckboxWithLabel } from "./ui/checkbox"; +import { BudgetsEditor } from "@app/components/BudgetsEditor"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { Role } from "@server/db"; @@ -333,7 +334,15 @@ export function RoleForm({ { title: t("general"), href: "#" }, ...(env.flags.disableEnterpriseFeatures ? [] - : [{ title: t("sshAccess"), href: "#" }]) + : [{ title: t("sshAccess"), href: "#" }]), + ...(variant === "edit" && role + ? [ + { + title: t("accessRoleInferenceBudget"), + href: "#" + } + ] + : []) ]} > {/* General tab */} @@ -635,6 +644,24 @@ export function RoleForm({ /> )} + + {/* Inference Budget tab - only available once the role exists */} + {variant === "edit" && role && ( +
+ +
+ )} )} From a357f42c488a570b2ae93c19460f41c65ba53714 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 14:35:35 -0400 Subject: [PATCH 272/494] allow creating role with budgets --- src/components/BudgetsEditor.tsx | 496 +++++++++++++++--------------- src/components/CreateRoleForm.tsx | 28 +- src/components/RoleForm.tsx | 85 ++++- 3 files changed, 351 insertions(+), 258 deletions(-) diff --git a/src/components/BudgetsEditor.tsx b/src/components/BudgetsEditor.tsx index 61a0595b8..7a3e15344 100644 --- a/src/components/BudgetsEditor.tsx +++ b/src/components/BudgetsEditor.tsx @@ -44,7 +44,7 @@ import { Plus, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useMemo, useState } from "react"; -type BudgetRow = { +export type BudgetRow = { key: string; budgetId?: number; amount: string; @@ -81,6 +81,249 @@ function nextAvailableCombo(rows: BudgetRow[]): { return { unit: "usd", period: "monthly" }; } +export function newBudgetRow(rows: BudgetRow[]): BudgetRow { + const combo = nextAvailableCombo(rows); + return { + key: crypto.randomUUID(), + amount: "", + unit: combo.unit, + period: combo.period + }; +} + +export function getBudgetRowsErrors(rows: BudgetRow[]): { + conflictingKeys: Set; + invalidAmountKeys: Set; +} { + const counts = new Map(); + for (const row of rows) { + const key = comboKey(row.unit, row.period); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + const conflictingKeys = new Set(); + for (const row of rows) { + const key = comboKey(row.unit, row.period); + if ((counts.get(key) ?? 0) > 1) { + conflictingKeys.add(row.key); + } + } + + const invalidAmountKeys = new Set(); + for (const row of rows) { + const amount = Number(row.amount); + if (!row.amount.trim() || !Number.isFinite(amount) || amount <= 0) { + invalidAmountKeys.add(row.key); + } + } + + return { conflictingKeys, invalidAmountKeys }; +} + +export function BudgetRowsFields({ + rows, + onChange, + disabled = false, + attemptedSave = false +}: { + rows: BudgetRow[]; + onChange: (rows: BudgetRow[]) => void; + disabled?: boolean; + attemptedSave?: boolean; +}) { + const t = useTranslations(); + + const { conflictingKeys, invalidAmountKeys } = useMemo( + () => getBudgetRowsErrors(rows), + [rows] + ); + + function addRow() { + onChange([...rows, newBudgetRow(rows)]); + } + + function removeRow(key: string) { + onChange(rows.filter((row) => row.key !== key)); + } + + function updateRow(key: string, patch: Partial) { + onChange( + rows.map((row) => (row.key === key ? { ...row, ...patch } : row)) + ); + } + + const periodLabels: Record = { + hourly: t("aiBudgetPeriodHourly"), + daily: t("aiBudgetPeriodDaily"), + weekly: t("aiBudgetPeriodWeekly"), + monthly: t("aiBudgetPeriodMonthly"), + yearly: t("aiBudgetPeriodYearly"), + lifetime: t("aiBudgetPeriodLifetime") + }; + + const unitLabels: Record = { + usd: t("aiBudgetUnitUsd"), + tokens: t("aiBudgetUnitTokens") + }; + + const addRowButton = ( + + ); + + return ( +
+
+ + + {t("aiBudgetAmount")} + {t("aiBudgetUnit")} + {t("aiBudgetPeriod")} + + + + + {rows.length === 0 ? ( + + ) : ( + rows.map((row) => { + const showConflict = conflictingKeys.has(row.key); + const showInvalidAmount = + attemptedSave && + invalidAmountKeys.has(row.key); + return ( + + + + updateRow(row.key, { + amount: e.target.value + }) + } + className="w-full min-w-0" + /> + + + + + + + + +
+ +
+
+
+ ); + }) + )} +
+
+ {(conflictingKeys.size > 0 || + (attemptedSave && invalidAmountKeys.size > 0)) && ( +

+ {conflictingKeys.size > 0 + ? t("aiBudgetConflictError") + : t("aiBudgetInvalidAmountError")} +

+ )} + {rows.length > 0 && addRowButton} +
+ ); +} + export function BudgetsEditor({ scope, orgId, @@ -111,58 +354,13 @@ export function BudgetsEditor({ setAttemptedSave(false); }, [budgetsQuery.data]); - const conflictingKeys = useMemo(() => { - const counts = new Map(); - for (const row of rows) { - const key = comboKey(row.unit, row.period); - counts.set(key, (counts.get(key) ?? 0) + 1); - } - const conflicting = new Set(); - for (const row of rows) { - const key = comboKey(row.unit, row.period); - if ((counts.get(key) ?? 0) > 1) { - conflicting.add(row.key); - } - } - return conflicting; - }, [rows]); - - const invalidAmountKeys = useMemo(() => { - const invalid = new Set(); - for (const row of rows) { - const amount = Number(row.amount); - if (!row.amount.trim() || !Number.isFinite(amount) || amount <= 0) { - invalid.add(row.key); - } - } - return invalid; - }, [rows]); + const { conflictingKeys, invalidAmountKeys } = useMemo( + () => getBudgetRowsErrors(rows), + [rows] + ); const hasErrors = conflictingKeys.size > 0 || invalidAmountKeys.size > 0; - function addRow() { - const combo = nextAvailableCombo(rows); - setRows((prev) => [ - ...prev, - { - key: crypto.randomUUID(), - amount: "", - unit: combo.unit, - period: combo.period - } - ]); - } - - function removeRow(key: string) { - setRows((prev) => prev.filter((row) => row.key !== key)); - } - - function updateRow(key: string, patch: Partial) { - setRows((prev) => - prev.map((row) => (row.key === key ? { ...row, ...patch } : row)) - ); - } - async function onSave() { setAttemptedSave(true); if (hasErrors) { @@ -244,201 +442,15 @@ export function BudgetsEditor({ } } - const periodLabels: Record = { - hourly: t("aiBudgetPeriodHourly"), - daily: t("aiBudgetPeriodDaily"), - weekly: t("aiBudgetPeriodWeekly"), - monthly: t("aiBudgetPeriodMonthly"), - yearly: t("aiBudgetPeriodYearly"), - lifetime: t("aiBudgetPeriodLifetime") - }; - - const unitLabels: Record = { - usd: t("aiBudgetUnitUsd"), - tokens: t("aiBudgetUnitTokens") - }; - - const addRowButton = ( - - ); - const body = ( <> -
- - - - {t("aiBudgetAmount")} - {t("aiBudgetUnit")} - {t("aiBudgetPeriod")} - - - - - {rows.length === 0 ? ( - - ) : ( - rows.map((row) => { - const showConflict = conflictingKeys.has( - row.key - ); - const showInvalidAmount = - attemptedSave && - invalidAmountKeys.has(row.key); - return ( - - - - updateRow(row.key, { - amount: e.target - .value - }) - } - className="w-full min-w-0" - /> - - - - - - - - -
- -
-
-
- ); - }) - )} -
-
- {(conflictingKeys.size > 0 || - (attemptedSave && invalidAmountKeys.size > 0)) && ( -

- {conflictingKeys.size > 0 - ? t("aiBudgetConflictError") - : t("aiBudgetInvalidAmountError")} -

- )} - {rows.length > 0 && addRowButton} -
+
diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx index 678a9edb5..56af53c56 100644 --- a/src/components/CreateRoleForm.tsx +++ b/src/components/CreateRoleForm.tsx @@ -80,13 +80,39 @@ export default function CreateRoleForm({ }); if (res && res.status === 201) { + const createdRole = res.data.data; + + const pendingBudgets = (values.budgets ?? []).filter( + (budget) => budget.amount.trim() !== "" + ); + if (pendingBudgets.length > 0) { + try { + await Promise.all( + pendingBudgets.map((budget) => + api.put(`/org/${org?.org.orgId}/ai-budget`, { + roleId: createdRole.roleId, + amount: Number(budget.amount), + unit: budget.unit, + period: budget.period + }) + ) + ); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiBudgetErrorSave"), + description: formatAxiosError(e, t("aiBudgetErrorSave")) + }); + } + } + toast({ variant: "default", title: t("accessRoleCreated"), description: t("accessRoleCreatedDescription") }); if (open) setOpen(false); - afterCreate?.(res.data.data); + afterCreate?.(createdRole); } } diff --git a/src/components/RoleForm.tsx b/src/components/RoleForm.tsx index eb34f6aa5..e55a7363b 100644 --- a/src/components/RoleForm.tsx +++ b/src/components/RoleForm.tsx @@ -35,7 +35,13 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; import { CheckboxWithLabel } from "./ui/checkbox"; -import { BudgetsEditor } from "@app/components/BudgetsEditor"; +import { + BudgetsEditor, + BudgetRowsFields, + getBudgetRowsErrors, + type BudgetRow +} from "@app/components/BudgetsEditor"; +import type { AiBudgetPeriod, AiBudgetUnit } from "@app/lib/aiBudgetScope"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { Role } from "@server/db"; @@ -83,6 +89,12 @@ function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean { }); } +export type PendingRoleBudget = { + amount: string; + unit: AiBudgetUnit; + period: AiBudgetPeriod; +}; + export type RoleFormValues = { name: string; description?: string; @@ -92,6 +104,7 @@ export type RoleFormValues = { sshSudoCommands?: string; sshCreateHomeDir?: boolean; sshUnixGroups?: string; + budgets?: PendingRoleBudget[]; }; type RoleFormProps = { @@ -203,6 +216,10 @@ export function RoleForm({ useState(null); const [dragOverField, setDragOverField] = useState(null); + const [pendingBudgetRows, setPendingBudgetRows] = useState( + [] + ); + const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false); useEffect(() => { if (sshDisabled) { @@ -253,6 +270,35 @@ export function RoleForm({ }); } + function handleFormSubmit(values: z.infer) { + if (variant === "create") { + const { conflictingKeys, invalidAmountKeys } = + getBudgetRowsErrors(pendingBudgetRows); + if (conflictingKeys.size > 0 || invalidAmountKeys.size > 0) { + setAttemptedBudgetsSave(true); + toast({ + variant: "destructive", + title: t("aiBudgetErrorSave"), + description: conflictingKeys.size + ? t("aiBudgetConflictError") + : t("aiBudgetInvalidAmountError") + }); + return; + } + + return onSubmit({ + ...values, + budgets: pendingBudgetRows.map(({ amount, unit, period }) => ({ + amount, + unit, + period + })) + }); + } + + return onSubmit(values); + } + function getTextImportDropHandlers(field: RoleTextImportField) { return { onDragOver: (event: React.DragEvent) => { @@ -285,7 +331,7 @@ export function RoleForm({ return (
onSubmit(values))} + onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-4" id={formId} > @@ -335,14 +381,10 @@ export function RoleForm({ ...(env.flags.disableEnterpriseFeatures ? [] : [{ title: t("sshAccess"), href: "#" }]), - ...(variant === "edit" && role - ? [ - { - title: t("accessRoleInferenceBudget"), - href: "#" - } - ] - : []) + { + title: t("accessRoleInferenceBudget"), + href: "#" + } ]} > {/* General tab */} @@ -645,9 +687,9 @@ export function RoleForm({
)} - {/* Inference Budget tab - only available once the role exists */} - {variant === "edit" && role && ( -
+ {/* Inference Budget tab */} +
+ {variant === "edit" && role ? ( -
- )} + ) : ( + <> +

+ {t( + "accessRoleInferenceBudgetDescription" + )} +

+ + + )} +
)} From e76377d3c7fa747d0efe71383871be99ef44766f Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 14:55:31 -0400 Subject: [PATCH 273/494] Match the pg query logging --- server/db/sqlite/driver.ts | 38 ++++---------------------------------- 1 file changed, 4 insertions(+), 34 deletions(-) diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 6b115e4ee..a58ec1ead 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -5,39 +5,7 @@ import path from "path"; import fs from "fs"; import { APP_PATH } from "@server/lib/consts"; import { existsSync, mkdirSync } from "fs"; - -// Temporary diagnostic trace for the random better-sqlite3 native aborts -// (Statement::~Statement -> RemoveEnvironmentCleanupHook assertion). That -// abort is a hard SIGABRT from native code, so it bypasses uncaughtException/ -// unhandledRejection and can outrun winston's async file transport. This -// writes every statement text synchronously (fsync'd via appendFileSync) so -// the statements immediately preceding a crash survive it. better-sqlite3's -// `verbose` hook fires for BEGIN/SAVEPOINT/RELEASE/COMMIT/ROLLBACK too, since -// those are just prepared statements under the hood - so this also lets us -// see if two "transactions" ever overlap on the shared connection. -// Enable with SQL_TRACE=true; remove once the crash is root-caused. -function sqlTraceVerbose(): - | ((message: unknown, ...args: unknown[]) => void) - | undefined { - if (process.env.SQL_TRACE !== "true") { - return undefined; - } - const traceLogDir = path.join(APP_PATH, "logs"); - if (!existsSync(traceLogDir)) { - mkdirSync(traceLogDir, { recursive: true }); - } - const traceLogPath = path.join(traceLogDir, "sql-trace.log"); - let seq = 0; - return (message: unknown) => { - seq += 1; - const line = `${new Date().toISOString()} pid=${process.pid} #${seq} ${String(message).replace(/\s+/g, " ").trim()}\n`; - try { - fs.appendFileSync(traceLogPath, line); - } catch { - // best-effort diagnostic logging only - } - }; -} +import logger from "@server/logger"; export const location = path.join(APP_PATH, "db", "db.sqlite"); export const exists = checkFileExists(location); @@ -46,7 +14,9 @@ bootstrapVolume(); function createDb() { const verbose = - process.env.QUERY_LOGGING == "true" ? sqlTraceVerbose() : undefined; + process.env.QUERY_LOGGING == "true" + ? (message: unknown) => logger.debug(String(message)) + : undefined; const sqlite = new Database(location, { verbose }); if (process.env.ENABLE_SQLITE_WAL_MODE == "true") { From e012063f5b70b00e36d8e2ec106de9ab022b9be7 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 15:09:39 -0400 Subject: [PATCH 274/494] add the budget to the models on the provider --- messages/en-US.json | 5 +- .../ai-providers/[providerId]/models/page.tsx | 2 + src/components/AiProviderModelListEditor.tsx | 91 ++++++++++++++----- 3 files changed, 76 insertions(+), 22 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index f8619f7da..4ff3f9da2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1820,7 +1820,10 @@ "aiProviderModelsSourceAll": "Matches every model key", "aiProviderModelsBudgetConfigured": "Budget configured", "aiProviderModelsEditTitle": "Edit Model", - "aiProviderModelsEditDescription": "Update the model key. Additional settings such as budgets will appear here later.", + "aiProviderModelsEditDescription": "Update the model key or configure its inference budget.", + "aiProviderModelsBudgetTab": "Inference Budget", + "aiProviderModelsBudgetDescription": "Configure how this model restricts usage based on spending or token limits", + "aiProviderModelsBudgetUnsaved": "Save this model first to configure its inference budget.", "aiProviderModelsKeyLabel": "Model Key", "aiProviderModelsKeyRequired": "Enter a model key", "aiProviderModelsKeyDuplicate": "This model key is already on a list", diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx index c8a1a4483..f8ace5cb2 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx @@ -235,6 +235,7 @@ export default function AiProviderModelsPage() {
{ @@ -772,12 +777,14 @@ type EditFormValues = { }; function EditModelCredenza({ + orgId, item, open, onOpenChange, existingKeys, onSave }: { + orgId: string; item: AiProviderModelListItem; open: boolean; onOpenChange: (open: boolean) => void; @@ -835,27 +842,69 @@ function EditModelCredenza({ id="ai-provider-model-edit-form" onSubmit={form.handleSubmit(handleSubmit)} > - - ( - - - {t("aiProviderModelsKeyLabel")} - - - - - - - )} - /> + + +
+ ( + + + {t( + "aiProviderModelsKeyLabel" + )} + + + + + + + )} + /> +
+
+ {item.modelId !== undefined ? ( + + ) : ( +

+ {t( + "aiProviderModelsBudgetUnsaved" + )} +

+ )} +
+
From 88515a7dad2f941184a0380bc55aaf7ce460f55b Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 15:31:45 -0400 Subject: [PATCH 275/494] remove duplicate save buttons --- src/components/AiProviderModelListEditor.tsx | 127 ++++++++++++++++--- src/components/BudgetsEditor.tsx | 112 +++++++++------- src/components/EditRoleForm.tsx | 32 +++++ src/components/RoleForm.tsx | 100 +++++++-------- 4 files changed, 258 insertions(+), 113 deletions(-) diff --git a/src/components/AiProviderModelListEditor.tsx b/src/components/AiProviderModelListEditor.tsx index 6bf935f4d..993ef88b8 100644 --- a/src/components/AiProviderModelListEditor.tsx +++ b/src/components/AiProviderModelListEditor.tsx @@ -43,7 +43,18 @@ import { import { cn } from "@app/lib/cn"; import { isModelKeyPattern } from "@server/lib/aiModelKeyMatch"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; -import { BudgetsEditor } from "@app/components/BudgetsEditor"; +import { + BudgetRowsFields, + getBudgetRowsErrors, + rowsFromBudgets, + saveBudgetRows, + type BudgetRow +} from "@app/components/BudgetsEditor"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { aiBudgetQueries } from "@app/lib/queries"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { Asterisk, @@ -792,6 +803,9 @@ function EditModelCredenza({ onSave: (item: AiProviderModelListItem) => void; }) { const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const queryClient = useQueryClient(); const editSchema = useMemo( () => @@ -814,12 +828,78 @@ function EditModelCredenza({ defaultValues: { modelKey: item.modelKey } }); + const [pendingBudgetRows, setPendingBudgetRows] = useState( + [] + ); + const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false); + const [savingBudgets, setSavingBudgets] = useState(false); + + const budgetScope = + item.modelId !== undefined + ? { type: "model" as const, id: item.modelId } + : null; + + const budgetsQuery = useQuery({ + ...aiBudgetQueries.scoped({ + scope: budgetScope ?? { type: "model", id: -1 } + }), + enabled: open && budgetScope !== null + }); + useEffect(() => { if (!open) return; form.reset({ modelKey: item.modelKey }); + setAttemptedBudgetsSave(false); }, [form, item.clientId, item.modelKey, open]); - function handleSubmit(values: EditFormValues) { + useEffect(() => { + if (!open || !budgetsQuery.data) return; + setPendingBudgetRows(rowsFromBudgets(budgetsQuery.data)); + }, [open, budgetsQuery.data]); + + async function handleSubmit(values: EditFormValues) { + const { conflictingKeys, invalidAmountKeys } = + getBudgetRowsErrors(pendingBudgetRows); + if (conflictingKeys.size > 0 || invalidAmountKeys.size > 0) { + setAttemptedBudgetsSave(true); + toast({ + variant: "destructive", + title: t("aiBudgetErrorSave"), + description: conflictingKeys.size + ? t("aiBudgetConflictError") + : t("aiBudgetInvalidAmountError") + }); + return; + } + + if (budgetScope) { + setSavingBudgets(true); + try { + const existingBudgets = await queryClient.fetchQuery( + aiBudgetQueries.scoped({ scope: budgetScope }) + ); + await saveBudgetRows({ + api, + orgId, + scope: budgetScope, + existingBudgets, + rows: pendingBudgetRows + }); + await queryClient.invalidateQueries( + aiBudgetQueries.scoped({ scope: budgetScope }) + ); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiBudgetErrorSave"), + description: formatAxiosError(e, t("aiBudgetErrorSave")) + }); + setSavingBudgets(false); + return; + } + setSavingBudgets(false); + } + onSave({ ...item, modelKey: values.modelKey.trim() @@ -881,21 +961,27 @@ function EditModelCredenza({ />
- {item.modelId !== undefined ? ( - + {budgetScope ? ( + <> +

+ {t( + "aiProviderModelsBudgetDescription" + )} +

+ + ) : (

{t( @@ -914,7 +1000,12 @@ function EditModelCredenza({ {t("cancel")} - diff --git a/src/components/BudgetsEditor.tsx b/src/components/BudgetsEditor.tsx index 7a3e15344..e9dbbaefb 100644 --- a/src/components/BudgetsEditor.tsx +++ b/src/components/BudgetsEditor.tsx @@ -40,6 +40,7 @@ import { import { aiBudgetQueries } from "@app/lib/queries"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { AiBudget } from "@server/db"; +import type { AxiosInstance } from "axios"; import { Plus, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useMemo, useState } from "react"; @@ -52,7 +53,7 @@ export type BudgetRow = { period: AiBudgetPeriod; }; -function rowsFromBudgets(budgets: AiBudget[]): BudgetRow[] { +export function rowsFromBudgets(budgets: AiBudget[]): BudgetRow[] { return budgets.map((budget) => ({ key: String(budget.budgetId), budgetId: budget.budgetId, @@ -324,6 +325,66 @@ export function BudgetRowsFields({ ); } +export async function saveBudgetRows({ + api, + orgId, + scope, + existingBudgets, + rows +}: { + api: AxiosInstance; + orgId: string; + scope: AiBudgetScope; + existingBudgets: AiBudget[]; + rows: Pick[]; +}): Promise { + const existingById = new Map( + existingBudgets.map((budget) => [budget.budgetId, budget]) + ); + const currentBudgetIds = new Set( + rows + .filter((row) => row.budgetId !== undefined) + .map((row) => row.budgetId as number) + ); + const bodyField = getAiBudgetScopeBodyField(scope); + + const toDelete = existingBudgets.filter( + (budget) => !currentBudgetIds.has(budget.budgetId) + ); + const toCreate = rows.filter((row) => row.budgetId === undefined); + const toUpdate = rows.filter((row) => { + if (row.budgetId === undefined) return false; + const existingBudget = existingById.get(row.budgetId); + if (!existingBudget) return false; + return ( + existingBudget.amount !== Number(row.amount) || + existingBudget.unit !== row.unit || + existingBudget.period !== row.period + ); + }); + + await Promise.all([ + ...toDelete.map((budget) => + api.delete(`/ai-budget/${budget.budgetId}`) + ), + ...toCreate.map((row) => + api.put(`/org/${orgId}/ai-budget`, { + [bodyField]: scope.id, + amount: Number(row.amount), + unit: row.unit, + period: row.period + }) + ), + ...toUpdate.map((row) => + api.post(`/ai-budget/${row.budgetId}`, { + amount: Number(row.amount), + unit: row.unit, + period: row.period + }) + ) + ]); +} + export function BudgetsEditor({ scope, orgId, @@ -376,53 +437,14 @@ export function BudgetsEditor({ setSaveLoading(true); try { - const existing = budgetsQuery.data ?? []; - const existingById = new Map( - existing.map((budget) => [budget.budgetId, budget]) - ); - const currentBudgetIds = new Set( + await saveBudgetRows({ + api, + orgId, + scope, + existingBudgets: budgetsQuery.data ?? [], rows - .filter((row) => row.budgetId !== undefined) - .map((row) => row.budgetId as number) - ); - const bodyField = getAiBudgetScopeBodyField(scope); - - const toDelete = existing.filter( - (budget) => !currentBudgetIds.has(budget.budgetId) - ); - const toCreate = rows.filter((row) => row.budgetId === undefined); - const toUpdate = rows.filter((row) => { - if (row.budgetId === undefined) return false; - const existingBudget = existingById.get(row.budgetId); - if (!existingBudget) return false; - return ( - existingBudget.amount !== Number(row.amount) || - existingBudget.unit !== row.unit || - existingBudget.period !== row.period - ); }); - await Promise.all([ - ...toDelete.map((budget) => - api.delete(`/ai-budget/${budget.budgetId}`) - ), - ...toCreate.map((row) => - api.put(`/org/${orgId}/ai-budget`, { - [bodyField]: scope.id, - amount: Number(row.amount), - unit: row.unit, - period: row.period - }) - ), - ...toUpdate.map((row) => - api.post(`/ai-budget/${row.budgetId}`, { - amount: Number(row.amount), - unit: row.unit, - period: row.period - }) - ) - ]); - await queryClient.invalidateQueries( aiBudgetQueries.scoped({ scope }) ); diff --git a/src/components/EditRoleForm.tsx b/src/components/EditRoleForm.tsx index bebf50288..d02e30c0e 100644 --- a/src/components/EditRoleForm.tsx +++ b/src/components/EditRoleForm.tsx @@ -15,6 +15,8 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { aiBudgetQueries } from "@app/lib/queries"; +import { useQueryClient } from "@tanstack/react-query"; import type { Role } from "@server/db"; import type { UpdateRoleBody, UpdateRoleResponse } from "@server/routers/role"; import { AxiosResponse } from "axios"; @@ -26,6 +28,7 @@ import { RoleForm, type RoleFormValues } from "./RoleForm"; +import { saveBudgetRows } from "./BudgetsEditor"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; type EditRoleFormProps = { @@ -44,6 +47,7 @@ export default function EditRoleForm({ const t = useTranslations(); const { isPaidUser } = usePaidStatus(); const api = createApiClient(useEnvContext()); + const queryClient = useQueryClient(); const [loading, startTransition] = useTransition(); async function onSubmit(values: RoleFormValues) { @@ -83,6 +87,34 @@ export default function EditRoleForm({ }); if (res && res.status === 200) { + if (values.budgets) { + try { + const scope = { type: "role" as const, id: role.roleId }; + const existingBudgets = await queryClient.fetchQuery( + aiBudgetQueries.scoped({ scope }) + ); + await saveBudgetRows({ + api, + orgId: role.orgId, + scope, + existingBudgets, + rows: values.budgets + }); + await queryClient.invalidateQueries( + aiBudgetQueries.scoped({ scope }) + ); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiBudgetErrorSave"), + description: formatAxiosError( + e, + t("aiBudgetErrorSave") + ) + }); + } + } + toast({ variant: "default", title: t("accessRoleUpdated"), diff --git a/src/components/RoleForm.tsx b/src/components/RoleForm.tsx index e55a7363b..4e8f457cb 100644 --- a/src/components/RoleForm.tsx +++ b/src/components/RoleForm.tsx @@ -30,17 +30,19 @@ import { import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; +import { useQuery } from "@tanstack/react-query"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; import { CheckboxWithLabel } from "./ui/checkbox"; import { - BudgetsEditor, BudgetRowsFields, getBudgetRowsErrors, + rowsFromBudgets, type BudgetRow } from "@app/components/BudgetsEditor"; +import { aiBudgetQueries } from "@app/lib/queries"; import type { AiBudgetPeriod, AiBudgetUnit } from "@app/lib/aiBudgetScope"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { Role } from "@server/db"; @@ -90,6 +92,7 @@ function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean { } export type PendingRoleBudget = { + budgetId?: number; amount: string; unit: AiBudgetUnit; period: AiBudgetPeriod; @@ -221,6 +224,19 @@ export function RoleForm({ ); const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false); + const budgetsQuery = useQuery({ + ...aiBudgetQueries.scoped({ + scope: { type: "role", id: role?.roleId ?? -1 } + }), + enabled: variant === "edit" && !!role + }); + + useEffect(() => { + if (variant !== "edit" || !budgetsQuery.data) return; + setPendingBudgetRows(rowsFromBudgets(budgetsQuery.data)); + setAttemptedBudgetsSave(false); + }, [variant, budgetsQuery.data]); + useEffect(() => { if (sshDisabled) { form.setValue("allowSsh", false); @@ -271,32 +287,31 @@ export function RoleForm({ } function handleFormSubmit(values: z.infer) { - if (variant === "create") { - const { conflictingKeys, invalidAmountKeys } = - getBudgetRowsErrors(pendingBudgetRows); - if (conflictingKeys.size > 0 || invalidAmountKeys.size > 0) { - setAttemptedBudgetsSave(true); - toast({ - variant: "destructive", - title: t("aiBudgetErrorSave"), - description: conflictingKeys.size - ? t("aiBudgetConflictError") - : t("aiBudgetInvalidAmountError") - }); - return; - } + const { conflictingKeys, invalidAmountKeys } = + getBudgetRowsErrors(pendingBudgetRows); + if (conflictingKeys.size > 0 || invalidAmountKeys.size > 0) { + setAttemptedBudgetsSave(true); + toast({ + variant: "destructive", + title: t("aiBudgetErrorSave"), + description: conflictingKeys.size + ? t("aiBudgetConflictError") + : t("aiBudgetInvalidAmountError") + }); + return; + } - return onSubmit({ - ...values, - budgets: pendingBudgetRows.map(({ amount, unit, period }) => ({ + return onSubmit({ + ...values, + budgets: pendingBudgetRows.map( + ({ budgetId, amount, unit, period }) => ({ + budgetId, amount, unit, period - })) - }); - } - - return onSubmit(values); + }) + ) + }); } function getTextImportDropHandlers(field: RoleTextImportField) { @@ -689,33 +704,18 @@ export function RoleForm({ {/* Inference Budget tab */}

- {variant === "edit" && role ? ( - - ) : ( - <> -

- {t( - "accessRoleInferenceBudgetDescription" - )} -

- - - )} +

+ {t("accessRoleInferenceBudgetDescription")} +

+
)} From 8b8e7913dc3ee0c43dfb18de339801fe4c1ab3c8 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 17:17:24 -0400 Subject: [PATCH 276/494] budget enforcement logic --- server/db/pg/schema/schema.ts | 96 ++++++++ server/db/sqlite/schema/schema.ts | 98 ++++++++ server/lib/aiBudgetEnforcement.ts | 341 +++++++++++++++++++++++++++ server/routers/aiGateway/pipeline.ts | 78 +++++- 4 files changed, 607 insertions(+), 6 deletions(-) create mode 100644 server/lib/aiBudgetEnforcement.ts diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 7ded27eb7..c9a45618c 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1758,6 +1758,100 @@ export const aiBudgets = pgTable( ] ); +export const aiUsageRecords = pgTable( + "aiUsageRecords", + { + id: serial("id").primaryKey(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + providerId: integer("providerId") + .notNull() + .references(() => aiProviders.providerId, { onDelete: "cascade" }), + resourceId: integer("resourceId").references( + () => resources.resourceId, + { onDelete: "cascade" } + ), + siteResourceId: integer("siteResourceId").references( + () => siteResources.siteResourceId, + { onDelete: "cascade" } + ), + userId: varchar("userId").references(() => users.userId, { + onDelete: "set null" + }), + requestedModel: varchar("requestedModel").notNull(), + promptTokens: integer("promptTokens").notNull().default(0), + cacheReadTokens: integer("cacheReadTokens").notNull().default(0), + cacheWriteTokens: integer("cacheWriteTokens").notNull().default(0), + completionTokens: integer("completionTokens").notNull().default(0), + reasoningTokens: integer("reasoningTokens").notNull().default(0), + totalTokens: integer("totalTokens").notNull().default(0), + costUsd: real("costUsd"), + estimated: boolean("estimated").notNull().default(false), + createdAt: bigint("createdAt", { mode: "number" }).notNull() + }, + (t) => [ + index("idx_ai_usage_records_org_provider_created").on( + t.orgId, + t.providerId, + t.createdAt + ), + index("idx_ai_usage_records_org_resource_created").on( + t.orgId, + t.resourceId, + t.createdAt + ), + index("idx_ai_usage_records_org_site_resource_created").on( + t.orgId, + t.siteResourceId, + t.createdAt + ), + index("idx_ai_usage_records_org_user_created").on( + t.orgId, + t.userId, + t.createdAt + ) + ] +); + +export const aiBudgetBreachEvents = pgTable( + "aiBudgetBreachEvents", + { + id: serial("id").primaryKey(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + budgetId: integer("budgetId") + .notNull() + .references(() => aiBudgets.budgetId, { onDelete: "cascade" }), + enforcement: varchar("enforcement").$type<"hard" | "soft">().notNull(), + unit: varchar("unit").$type<"usd" | "tokens">().notNull(), + period: varchar("period") + .$type< + | "monthly" + | "yearly" + | "lifetime" + | "daily" + | "hourly" + | "weekly" + >() + .notNull(), + amount: real("amount").notNull(), + usageAmount: real("usageAmount").notNull(), + blocked: boolean("blocked").notNull(), + requestUserId: varchar("requestUserId").references(() => users.userId, { + onDelete: "set null" + }), + createdAt: bigint("createdAt", { mode: "number" }).notNull() + }, + (t) => [ + index("idx_ai_budget_breach_events_budget_created").on( + t.budgetId, + t.createdAt + ) + ] +); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -1845,6 +1939,8 @@ export type ResourcePolicyRule = InferSelectModel; export type AiProvider = InferSelectModel; export type AiModel = InferSelectModel; export type AiBudget = InferSelectModel; +export type AiUsageRecord = InferSelectModel; +export type AiBudgetBreachEvent = InferSelectModel; export type ResourceAiProvider = InferSelectModel; export type SiteResourceAiProvider = InferSelectModel< typeof siteResourceAiProviders diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 34fe67a0f..8017cd3b7 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1744,6 +1744,102 @@ export const aiBudgets = sqliteTable( ] ); +export const aiUsageRecords = sqliteTable( + "aiUsageRecords", + { + id: integer("id").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + providerId: integer("providerId") + .notNull() + .references(() => aiProviders.providerId, { onDelete: "cascade" }), + resourceId: integer("resourceId").references( + () => resources.resourceId, + { onDelete: "cascade" } + ), + siteResourceId: integer("siteResourceId").references( + () => siteResources.siteResourceId, + { onDelete: "cascade" } + ), + userId: text("userId").references(() => users.userId, { + onDelete: "set null" + }), + requestedModel: text("requestedModel").notNull(), + promptTokens: integer("promptTokens").notNull().default(0), + cacheReadTokens: integer("cacheReadTokens").notNull().default(0), + cacheWriteTokens: integer("cacheWriteTokens").notNull().default(0), + completionTokens: integer("completionTokens").notNull().default(0), + reasoningTokens: integer("reasoningTokens").notNull().default(0), + totalTokens: integer("totalTokens").notNull().default(0), + costUsd: real("costUsd"), + estimated: integer("estimated", { mode: "boolean" }) + .notNull() + .default(false), + createdAt: integer("createdAt").notNull() + }, + (t) => [ + index("idx_ai_usage_records_org_provider_created").on( + t.orgId, + t.providerId, + t.createdAt + ), + index("idx_ai_usage_records_org_resource_created").on( + t.orgId, + t.resourceId, + t.createdAt + ), + index("idx_ai_usage_records_org_site_resource_created").on( + t.orgId, + t.siteResourceId, + t.createdAt + ), + index("idx_ai_usage_records_org_user_created").on( + t.orgId, + t.userId, + t.createdAt + ) + ] +); + +export const aiBudgetBreachEvents = sqliteTable( + "aiBudgetBreachEvents", + { + id: integer("id").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + budgetId: integer("budgetId") + .notNull() + .references(() => aiBudgets.budgetId, { onDelete: "cascade" }), + enforcement: text("enforcement").$type<"hard" | "soft">().notNull(), + unit: text("unit").$type<"usd" | "tokens">().notNull(), + period: text("period") + .$type< + | "monthly" + | "yearly" + | "lifetime" + | "daily" + | "hourly" + | "weekly" + >() + .notNull(), + amount: real("amount").notNull(), + usageAmount: real("usageAmount").notNull(), + blocked: integer("blocked", { mode: "boolean" }).notNull(), + requestUserId: text("requestUserId").references(() => users.userId, { + onDelete: "set null" + }), + createdAt: integer("createdAt").notNull() + }, + (t) => [ + index("idx_ai_budget_breach_events_budget_created").on( + t.budgetId, + t.createdAt + ) + ] +); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -1829,6 +1925,8 @@ export type UserPolicy = InferSelectModel; export type AiProvider = InferSelectModel; export type AiModel = InferSelectModel; export type AiBudget = InferSelectModel; +export type AiUsageRecord = InferSelectModel; +export type AiBudgetBreachEvent = InferSelectModel; export type ResourceAiProvider = InferSelectModel; export type SiteResourceAiProvider = InferSelectModel< typeof siteResourceAiProviders diff --git a/server/lib/aiBudgetEnforcement.ts b/server/lib/aiBudgetEnforcement.ts new file mode 100644 index 000000000..8f70340fa --- /dev/null +++ b/server/lib/aiBudgetEnforcement.ts @@ -0,0 +1,341 @@ +import { and, eq, gte, inArray, isNull, or, sql, SQL } from "drizzle-orm"; +import { + AiBudget, + aiBudgetBreachEvents, + aiBudgets, + aiModels, + aiUsageRecords, + db, + userOrgRoles +} from "@server/db"; +import { modelKeyMatches } from "@server/lib/aiModelKeyMatch"; +import type { AiUsage } from "@server/lib/aiUsageExtraction"; +import logger from "@server/logger"; + +type BudgetPeriod = AiBudget["period"]; + +const PERIOD_DURATIONS_MS: Record, number> = { + hourly: 60 * 60 * 1000, + daily: 24 * 60 * 60 * 1000, + weekly: 7 * 24 * 60 * 60 * 1000, + monthly: 30 * 24 * 60 * 60 * 1000, + yearly: 365 * 24 * 60 * 60 * 1000 +}; + +// Budget periods are trailing windows from "now", not calendar-aligned +// (e.g. "daily" = last 24h). "lifetime" has no lower bound. +function windowStart(period: BudgetPeriod, now: number): number { + if (period === "lifetime") { + return 0; + } + return now - PERIOD_DURATIONS_MS[period]; +} + +export type BudgetScopeContext = { + orgId: string; + providerId: number; + requestedModel: string; + resourceId: number | null; + siteResourceId: number | null; + roleIds: number[]; + requestUserId: string | null; +}; + +/** + * Every budget that could apply to this request: the provider itself, any + * model on that provider whose (possibly wildcarded) modelKey matches the + * requested model, the target resource/site-resource, and any role the + * requesting user holds in the org. + */ +export async function resolveApplicableBudgets( + ctx: BudgetScopeContext +): Promise { + const providerModels = await db + .select({ modelId: aiModels.modelId, modelKey: aiModels.modelKey }) + .from(aiModels) + .where( + and( + eq(aiModels.providerId, ctx.providerId), + eq(aiModels.enabled, true) + ) + ); + + const matchingModelIds = providerModels + .filter((m) => modelKeyMatches(m.modelKey, ctx.requestedModel)) + .map((m) => m.modelId); + + const scopeConditions: SQL[] = [ + and( + eq(aiBudgets.providerId, ctx.providerId), + isNull(aiBudgets.modelId) + )! + ]; + if (matchingModelIds.length > 0) { + scopeConditions.push(inArray(aiBudgets.modelId, matchingModelIds)); + } + if (ctx.resourceId != null) { + scopeConditions.push(eq(aiBudgets.resourceId, ctx.resourceId)); + } + if (ctx.siteResourceId != null) { + scopeConditions.push(eq(aiBudgets.siteResourceId, ctx.siteResourceId)); + } + if (ctx.roleIds.length > 0) { + scopeConditions.push(inArray(aiBudgets.roleId, ctx.roleIds)); + } + + return db + .select() + .from(aiBudgets) + .where( + and( + eq(aiBudgets.orgId, ctx.orgId), + eq(aiBudgets.enabled, true), + or(...scopeConditions) + ) + ); +} + +async function sumUsageAmount( + where: SQL, + unit: AiBudget["unit"] +): Promise { + const column = + unit === "usd" ? aiUsageRecords.costUsd : aiUsageRecords.totalTokens; + const [row] = await db + .select({ total: sql`coalesce(sum(${column}), 0)` }) + .from(aiUsageRecords) + .where(where); + return Number(row?.total ?? 0); +} + +/** + * Sums recorded usage for a single budget's scope + rolling window. Model + * budgets can't be pushed down to SQL because the model's key may itself be + * a glob, so those rows are fetched for the provider+window and matched in + * JS the same way access-control matching does. + */ +export async function sumUsageForBudget( + budget: AiBudget, + ctx: BudgetScopeContext, + now: number +): Promise { + const start = windowStart(budget.period, now); + + if (budget.modelId != null) { + const [model] = await db + .select({ + providerId: aiModels.providerId, + modelKey: aiModels.modelKey + }) + .from(aiModels) + .where(eq(aiModels.modelId, budget.modelId)) + .limit(1); + if (!model) { + return 0; + } + const rows = await db + .select({ + requestedModel: aiUsageRecords.requestedModel, + costUsd: aiUsageRecords.costUsd, + totalTokens: aiUsageRecords.totalTokens + }) + .from(aiUsageRecords) + .where( + and( + eq(aiUsageRecords.orgId, ctx.orgId), + eq(aiUsageRecords.providerId, model.providerId), + gte(aiUsageRecords.createdAt, start) + ) + ); + return rows + .filter((r) => modelKeyMatches(model.modelKey, r.requestedModel)) + .reduce( + (sum, r) => + sum + + (budget.unit === "usd" ? (r.costUsd ?? 0) : r.totalTokens), + 0 + ); + } + + if (budget.providerId != null) { + return sumUsageAmount( + and( + eq(aiUsageRecords.orgId, ctx.orgId), + eq(aiUsageRecords.providerId, budget.providerId), + gte(aiUsageRecords.createdAt, start) + )!, + budget.unit + ); + } + + if (budget.resourceId != null) { + return sumUsageAmount( + and( + eq(aiUsageRecords.orgId, ctx.orgId), + eq(aiUsageRecords.resourceId, budget.resourceId), + gte(aiUsageRecords.createdAt, start) + )!, + budget.unit + ); + } + + if (budget.siteResourceId != null) { + return sumUsageAmount( + and( + eq(aiUsageRecords.orgId, ctx.orgId), + eq(aiUsageRecords.siteResourceId, budget.siteResourceId), + gte(aiUsageRecords.createdAt, start) + )!, + budget.unit + ); + } + + if (budget.roleId != null) { + const members = await db + .select({ userId: userOrgRoles.userId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.roleId, budget.roleId), + eq(userOrgRoles.orgId, ctx.orgId) + ) + ); + const userIds = members.map((m) => m.userId); + if (userIds.length === 0) { + return 0; + } + return sumUsageAmount( + and( + eq(aiUsageRecords.orgId, ctx.orgId), + inArray(aiUsageRecords.userId, userIds), + gte(aiUsageRecords.createdAt, start) + )!, + budget.unit + ); + } + + return 0; +} + +// Throttled to one durable event per budget per breach window, so a soft +// budget being exceeded doesn't write a row on every subsequent request +// while it stays over. +async function recordBreachEventIfNew( + budget: AiBudget, + ctx: BudgetScopeContext, + usageAmount: number, + now: number +): Promise { + try { + const start = windowStart(budget.period, now); + const [existing] = await db + .select({ id: aiBudgetBreachEvents.id }) + .from(aiBudgetBreachEvents) + .where( + and( + eq(aiBudgetBreachEvents.budgetId, budget.budgetId), + gte(aiBudgetBreachEvents.createdAt, start) + ) + ) + .limit(1); + if (existing) { + return; + } + + await db.insert(aiBudgetBreachEvents).values({ + orgId: ctx.orgId, + budgetId: budget.budgetId, + enforcement: budget.enforcement, + unit: budget.unit, + period: budget.period, + amount: budget.amount, + usageAmount, + blocked: budget.enforcement === "hard", + requestUserId: ctx.requestUserId, + createdAt: now + }); + } catch (error) { + logger.error("Failed to record AI budget breach event", { + error, + budgetId: budget.budgetId + }); + } +} + +export type BudgetCheckResult = { + blocked: boolean; + blockingBudget?: AiBudget; +}; + +export async function checkBudgets( + ctx: BudgetScopeContext +): Promise { + const budgets = await resolveApplicableBudgets(ctx); + if (budgets.length === 0) { + return { blocked: false }; + } + + const now = Date.now(); + let blockingBudget: AiBudget | undefined; + + for (const budget of budgets) { + const usage = await sumUsageForBudget(budget, ctx, now); + if (usage < budget.amount) { + continue; + } + + await recordBreachEventIfNew(budget, ctx, usage, now); + + if (budget.enforcement === "hard" && !blockingBudget) { + blockingBudget = budget; + } + } + + return blockingBudget + ? { blocked: true, blockingBudget } + : { blocked: false }; +} + +export type UsageRecordInput = { + orgId: string; + providerId: number; + resourceId: number | null; + siteResourceId: number | null; + userId: string | null; + requestedModel: string; + usage: AiUsage; + costUsd: number | null; + createdAt?: number; +}; + +export async function recordUsage(input: UsageRecordInput): Promise { + try { + const { usage } = input; + const totalTokens = + usage.promptTokens + + usage.cacheReadTokens + + usage.cacheWriteTokens + + usage.completionTokens + + usage.reasoningTokens; + + await db.insert(aiUsageRecords).values({ + orgId: input.orgId, + providerId: input.providerId, + resourceId: input.resourceId, + siteResourceId: input.siteResourceId, + userId: input.userId, + requestedModel: input.requestedModel, + promptTokens: usage.promptTokens, + cacheReadTokens: usage.cacheReadTokens, + cacheWriteTokens: usage.cacheWriteTokens, + completionTokens: usage.completionTokens, + reasoningTokens: usage.reasoningTokens, + totalTokens, + costUsd: input.costUsd, + estimated: usage.estimated, + createdAt: input.createdAt ?? Date.now() + }); + } catch (error) { + logger.error("Failed to record AI usage", { error }); + } +} diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index bcff1a501..802f5f453 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -51,6 +51,7 @@ import { } from "@server/lib/aiModelKeyMatch"; import { aiGatewayUpstreamFetch } from "@server/lib/aiGatewayUpstreamFetch"; import { getModelPricing, calculateAiCost } from "@server/lib/aiModelPricing"; +import { checkBudgets, recordUsage } from "@server/lib/aiBudgetEnforcement"; import { extractUsage, estimateUsage, @@ -141,6 +142,7 @@ export type RequestUser = { email: string | null; name: string | null; role: string | null; + roleIds: number[]; }; // Identity headers forwarded to the upstream inference endpoint when the @@ -193,7 +195,8 @@ async function buildRequestUser( username: user.username, email: user.email, name: user.name, - role: orgRoles.map((r) => r.roleName).join(", ") || null + role: orgRoles.map((r) => r.roleName).join(", ") || null, + roleIds: orgRoles.map((r) => r.roleId) }; localCache.set(cacheKey, requestUser, REQUEST_USER_TTL_SEC); @@ -526,6 +529,10 @@ function logAiUsageAndCost(args: { responseText: string; isStream: boolean; headers: Headers; + orgId: string | null; + resourceId: number | null; + siteResourceId: number | null; + requestUserId: string | null; }): void { const { capability, @@ -534,7 +541,11 @@ function logAiUsageAndCost(args: { requestBody, responseText, isStream, - headers + headers, + orgId, + resourceId, + siteResourceId, + requestUserId } = args; let usage: AiUsage | null = extractUsage( @@ -565,6 +576,19 @@ function logAiUsageAndCost(args: { pricingApproximate: pricing?.approximate ?? null, totalCostUsd: cost?.totalCost ?? null }); + + if (orgId) { + void recordUsage({ + orgId, + providerId: provider.providerId, + resourceId, + siteResourceId, + userId: requestUserId, + requestedModel: model ?? "unknown", + usage, + costUsd: cost?.totalCost ?? null + }); + } } export async function handleAiGatewayProxy( @@ -597,8 +621,13 @@ export async function handleAiGatewayProxy( }); } - const { attachments, resourceListsByProvider, resourceId, orgId } = - target; + const { + attachments, + resourceListsByProvider, + resourceId, + siteResourceId, + orgId + } = target; const capableAttachments = attachments.filter((a) => providerHasCapability(a.provider.capabilities, capability) @@ -637,6 +666,35 @@ export async function handleAiGatewayProxy( const { provider } = selection; + if (orgId) { + const budgetCheck = await checkBudgets({ + orgId, + providerId: provider.providerId, + requestedModel: requestedModel!, + resourceId, + siteResourceId, + roleIds: requestUser?.roleIds ?? [], + requestUserId: requestUser?.userId ?? null + }); + + if (budgetCheck.blocked) { + logger.warn("AI gateway request blocked by budget", { + budgetId: budgetCheck.blockingBudget?.budgetId, + orgId, + providerId: provider.providerId, + requestedModel, + resourceId, + siteResourceId, + userId: requestUser?.userId ?? null + }); + return res.status(HttpCode.TOO_MANY_REQUESTS).json({ + error: { + message: "AI usage budget exceeded for this request" + } + }); + } + } + if (provider.type === "custom" && provider.routingMode === "target") { return await proxyAiGatewayToSiteTarget( req, @@ -814,7 +872,11 @@ export async function handleAiGatewayProxy( requestBody: req.body, responseText: fullText, isStream: true, - headers: upstreamRes.headers + headers: upstreamRes.headers, + orgId, + resourceId, + siteResourceId, + requestUserId: requestUser?.userId ?? null }); } return; @@ -829,7 +891,11 @@ export async function handleAiGatewayProxy( requestBody: req.body, responseText: text, isStream: false, - headers: upstreamRes.headers + headers: upstreamRes.headers, + orgId, + resourceId, + siteResourceId, + requestUserId: requestUser?.userId ?? null }); return res.send(text); } catch (error) { From 1e3a9fb92112c4febd68891d6bc675eeefd0802c Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 10 Aug 2026 17:19:58 -0400 Subject: [PATCH 277/494] add virtual api key schema and crud endpoints --- cli/commands/rotateServerSecret.ts | 49 +++- server/auth/actions.ts | 7 +- server/db/pg/schema/schema.ts | 51 ++++ server/db/sqlite/schema/schema.ts | 55 ++++- server/index.ts | 4 +- server/lib/virtualApiKey.ts | 209 +++++++++++++++++ server/middlewares/index.ts | 1 + server/middlewares/integration/index.ts | 1 + .../integration/verifyVirtualApiKeyAccess.ts | 87 +++++++ .../middlewares/verifyVirtualApiKeyAccess.ts | 101 ++++++++ server/openApi.ts | 3 +- server/routers/external.ts | 42 +++- server/routers/integration.ts | 40 ++++ .../virtualApiKey/createVirtualApiKey.ts | 177 ++++++++++++++ .../virtualApiKey/deleteVirtualApiKey.ts | 87 +++++++ .../routers/virtualApiKey/getVirtualApiKey.ts | 96 ++++++++ server/routers/virtualApiKey/index.ts | 6 + .../virtualApiKey/listVirtualApiKeys.ts | 218 ++++++++++++++++++ server/routers/virtualApiKey/types.ts | 16 ++ .../virtualApiKey/updateVirtualApiKey.ts | 218 ++++++++++++++++++ server/routers/virtualApiKey/validation.ts | 23 ++ 21 files changed, 1485 insertions(+), 6 deletions(-) create mode 100644 server/lib/virtualApiKey.ts create mode 100644 server/middlewares/integration/verifyVirtualApiKeyAccess.ts create mode 100644 server/middlewares/verifyVirtualApiKeyAccess.ts create mode 100644 server/routers/virtualApiKey/createVirtualApiKey.ts create mode 100644 server/routers/virtualApiKey/deleteVirtualApiKey.ts create mode 100644 server/routers/virtualApiKey/getVirtualApiKey.ts create mode 100644 server/routers/virtualApiKey/index.ts create mode 100644 server/routers/virtualApiKey/listVirtualApiKeys.ts create mode 100644 server/routers/virtualApiKey/types.ts create mode 100644 server/routers/virtualApiKey/updateVirtualApiKey.ts create mode 100644 server/routers/virtualApiKey/validation.ts diff --git a/cli/commands/rotateServerSecret.ts b/cli/commands/rotateServerSecret.ts index 2edb7592d..1bacfa99c 100644 --- a/cli/commands/rotateServerSecret.ts +++ b/cli/commands/rotateServerSecret.ts @@ -1,5 +1,5 @@ import { CommandModule } from "yargs"; -import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions, aiProviders } from "@server/db"; +import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions, aiProviders, virtualApiKeys } from "@server/db"; import { encrypt, decrypt } from "@server/lib/crypto"; import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { eq } from "drizzle-orm"; @@ -133,6 +133,7 @@ export const rotateServerSecret: CommandModule< const streamingDestinations = await db.select().from(eventStreamingDestinations); const webhookActions = await db.select().from(alertWebhookActions); const providers = await db.select().from(aiProviders); + const virtualKeys = await db.select().from(virtualApiKeys); console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`); console.log(`Found ${licenseKeys.length} license key(s)`); @@ -140,6 +141,7 @@ export const rotateServerSecret: CommandModule< console.log(`Found ${streamingDestinations.length} event streaming destination(s)`); console.log(`Found ${webhookActions.length} alert webhook action(s)`); console.log(`Found ${providers.length} AI provider(s)`); + console.log(`Found ${virtualKeys.length} virtual API key(s)`); // Prepare all decrypted and re-encrypted values console.log("\nDecrypting and re-encrypting values..."); @@ -179,12 +181,18 @@ export const rotateServerSecret: CommandModule< encryptedHeaders: string | null; }; + type VirtualApiKeyUpdate = { + virtualApiKeyId: string; + encryptedToken: string; + }; + const idpUpdates: IdpUpdate[] = []; const licenseKeyUpdates: LicenseKeyUpdate[] = []; const certUpdates: CertUpdate[] = []; const streamingDestinationUpdates: StreamingDestinationUpdate[] = []; const webhookActionUpdates: WebhookActionUpdate[] = []; const aiProviderUpdates: AiProviderUpdate[] = []; + const virtualApiKeyUpdates: VirtualApiKeyUpdate[] = []; // Process idpOidcConfig entries for (const idpConfig of idpConfigs) { @@ -346,6 +354,29 @@ export const rotateServerSecret: CommandModule< } } + // Process virtualApiKeys entries (token) + for (const key of virtualKeys) { + try { + if (!key.token) { + continue; + } + + virtualApiKeyUpdates.push({ + virtualApiKeyId: key.virtualApiKeyId, + encryptedToken: encrypt( + decrypt(key.token, oldSecret), + newSecret + ) + }); + } catch (error) { + console.error( + `Error processing virtual API key ${key.virtualApiKeyId}:`, + error + ); + throw error; + } + } + // Perform all database updates in a single transaction console.log("\nUpdating database in transaction..."); await db.transaction(async (trx) => { @@ -427,6 +458,21 @@ export const rotateServerSecret: CommandModule< }) .where(eq(aiProviders.providerId, update.providerId)); } + + // Update virtual API key entries + for (const update of virtualApiKeyUpdates) { + await trx + .update(virtualApiKeys) + .set({ + token: update.encryptedToken + }) + .where( + eq( + virtualApiKeys.virtualApiKeyId, + update.virtualApiKeyId + ) + ); + } }); console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`); @@ -435,6 +481,7 @@ export const rotateServerSecret: CommandModule< console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`); console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`); console.log(`Rotated ${aiProviderUpdates.length} AI provider(s)`); + console.log(`Rotated ${virtualApiKeyUpdates.length} virtual API key(s)`); // Update config file with new secret console.log("\nUpdating config file..."); diff --git a/server/auth/actions.ts b/server/auth/actions.ts index f9d781169..385238e87 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -199,7 +199,12 @@ export enum ActionsEnum { deleteAiBudget = "deleteAiBudget", getAiBudget = "getAiBudget", listAiBudgets = "listAiBudgets", - updateAiBudget = "updateAiBudget" + updateAiBudget = "updateAiBudget", + createVirtualApiKey = "createVirtualApiKey", + deleteVirtualApiKey = "deleteVirtualApiKey", + getVirtualApiKey = "getVirtualApiKey", + listVirtualApiKeys = "listVirtualApiKeys", + updateVirtualApiKey = "updateVirtualApiKey" } export async function checkUserActionPermission( diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index c9a45618c..39dfb889e 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -12,6 +12,7 @@ import { serial, text, unique, + uniqueIndex, varchar } from "drizzle-orm/pg-core"; @@ -1242,6 +1243,52 @@ export const apiKeyOrg = pgTable("apiKeyOrg", { .notNull() }); +export const virtualApiKeys = pgTable( + "virtualApiKeys", + { + virtualApiKeyId: varchar("virtualApiKeyId").primaryKey(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + kind: varchar("kind").$type<"user" | "manual">().notNull(), + userId: varchar("userId").references(() => users.userId, { + onDelete: "cascade" + }), + name: varchar("name"), + description: varchar("description"), + token: varchar("token").notNull(), + lastChars: varchar("lastChars").notNull(), + allResources: boolean("allResources").notNull().default(false), + expiresAt: bigint("expiresAt", { mode: "number" }), + lastUsedAt: bigint("lastUsedAt", { mode: "number" }), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + createdByUserId: varchar("createdByUserId").references( + () => users.userId, + { onDelete: "set null" } + ) + }, + (t) => [ + uniqueIndex("virtual_api_key_user_identity_uniq") + .on(t.orgId, t.userId) + .where(sql`${t.kind} = 'user'`) + ] +); + +export const virtualApiKeyResources = pgTable( + "virtualApiKeyResources", + { + virtualApiKeyId: varchar("virtualApiKeyId") + .notNull() + .references(() => virtualApiKeys.virtualApiKeyId, { + onDelete: "cascade" + }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }) + }, + (t) => [primaryKey({ columns: [t.virtualApiKeyId, t.resourceId] })] +); + export const idpOrg = pgTable("idpOrg", { idpId: integer("idpId") .notNull() @@ -1907,6 +1954,10 @@ export type Idp = InferSelectModel; export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; +export type VirtualApiKey = InferSelectModel; +export type VirtualApiKeyResource = InferSelectModel< + typeof virtualApiKeyResources +>; export type Client = InferSelectModel; export type ClientSite = InferSelectModel; export type Olm = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 8017cd3b7..1c2236dfe 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -8,7 +8,8 @@ import { real, sqliteTable, text, - unique + unique, + uniqueIndex } from "drizzle-orm/sqlite-core"; export const domains = sqliteTable("domains", { @@ -1499,6 +1500,54 @@ export const apiKeyOrg = sqliteTable("apiKeyOrg", { .notNull() }); +export const virtualApiKeys = sqliteTable( + "virtualApiKeys", + { + virtualApiKeyId: text("virtualApiKeyId").primaryKey(), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + kind: text("kind").$type<"user" | "manual">().notNull(), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), + name: text("name"), + description: text("description"), + token: text("token").notNull(), + lastChars: text("lastChars").notNull(), + allResources: integer("allResources", { mode: "boolean" }) + .notNull() + .default(false), + expiresAt: integer("expiresAt"), + lastUsedAt: integer("lastUsedAt"), + createdAt: integer("createdAt").notNull(), + createdByUserId: text("createdByUserId").references( + () => users.userId, + { onDelete: "set null" } + ) + }, + (t) => [ + uniqueIndex("virtual_api_key_user_identity_uniq") + .on(t.orgId, t.userId) + .where(sql`${t.kind} = 'user'`) + ] +); + +export const virtualApiKeyResources = sqliteTable( + "virtualApiKeyResources", + { + virtualApiKeyId: text("virtualApiKeyId") + .notNull() + .references(() => virtualApiKeys.virtualApiKeyId, { + onDelete: "cascade" + }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }) + }, + (t) => [primaryKey({ columns: [t.virtualApiKeyId, t.resourceId] })] +); + export const idpOrg = sqliteTable("idpOrg", { idpId: integer("idpId") .notNull() @@ -1891,6 +1940,10 @@ export type Idp = InferSelectModel; export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; +export type VirtualApiKey = InferSelectModel; +export type VirtualApiKeyResource = InferSelectModel< + typeof virtualApiKeyResources +>; export type SiteResource = InferSelectModel; export type Network = InferSelectModel; export type OrgDomains = InferSelectModel; diff --git a/server/index.ts b/server/index.ts index 3eb16092a..c7b0a5b6e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,7 +17,8 @@ import { Session, SiteResource, User, - UserOrg + UserOrg, + VirtualApiKey } from "@server/db"; import config from "@server/lib/config"; import { setHostMeta } from "@server/lib/hostMeta"; @@ -94,6 +95,7 @@ declare global { aiProvider?: AiProvider; aiModel?: AiModel; aiBudget?: AiBudget; + virtualApiKey?: VirtualApiKey; orgPolicyAllowed?: boolean; } } diff --git a/server/lib/virtualApiKey.ts b/server/lib/virtualApiKey.ts new file mode 100644 index 000000000..15f9b52ff --- /dev/null +++ b/server/lib/virtualApiKey.ts @@ -0,0 +1,209 @@ +import { + generateId, + generateIdFromEntropySize +} from "@server/auth/sessions/app"; +import { + db, + resources, + virtualApiKeyResources, + virtualApiKeys, + type Transaction, + type VirtualApiKey +} from "@server/db"; +import config from "@server/lib/config"; +import { decrypt, encrypt } from "@server/lib/crypto"; +import { and, eq, inArray } from "drizzle-orm"; + +export type MintedVirtualApiKeySecret = { + virtualApiKeyId: string; + secret: string; + lastChars: string; +}; + +export type PublicVirtualApiKey = Omit & { + secret?: string; +}; + +export function mintVirtualApiKeySecret(): MintedVirtualApiKeySecret { + const secret = generateIdFromEntropySize(16); + return { + virtualApiKeyId: generateId(8), + secret, + lastChars: secret.slice(-4) + }; +} + +export function encryptVirtualApiKeyToken(secret: string): string { + return encrypt(secret, config.getRawConfig().server.secret!); +} + +export function decryptVirtualApiKeyToken(ciphertext: string): string { + return decrypt(ciphertext, config.getRawConfig().server.secret!); +} + +export function toPublicVirtualApiKey( + row: VirtualApiKey, + options?: { includeSecret?: boolean } +): PublicVirtualApiKey { + const { token, ...rest } = row; + if (!options?.includeSecret) { + return rest; + } + return { + ...rest, + secret: decryptVirtualApiKeyToken(token) + }; +} + +export async function assertManualKeyResourcesInOrg(params: { + allResources: boolean; + resourceIds: number[]; + orgId: string; +}): Promise<{ ok: true } | { ok: false; message: string }> { + const { allResources, resourceIds, orgId } = params; + + if (allResources || resourceIds.length === 0) { + return { ok: true }; + } + + const uniqueIds = [...new Set(resourceIds)]; + const rows = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where( + and( + eq(resources.orgId, orgId), + inArray(resources.resourceId, uniqueIds) + ) + ); + + if (rows.length !== uniqueIds.length) { + return { + ok: false, + message: "One or more resources are invalid for this organization" + }; + } + + return { ok: true }; +} + +export async function replaceVirtualApiKeyResources( + trx: Transaction | typeof db, + virtualApiKeyId: string, + resourceIds: number[] +): Promise { + await trx + .delete(virtualApiKeyResources) + .where(eq(virtualApiKeyResources.virtualApiKeyId, virtualApiKeyId)); + + const uniqueIds = [...new Set(resourceIds)]; + if (uniqueIds.length === 0) { + return; + } + + await trx.insert(virtualApiKeyResources).values( + uniqueIds.map((resourceId) => ({ + virtualApiKeyId, + resourceId + })) + ); +} + +async function selectUserVirtualApiKey( + orgId: string, + userId: string +): Promise { + const [existing] = await db + .select() + .from(virtualApiKeys) + .where( + and( + eq(virtualApiKeys.orgId, orgId), + eq(virtualApiKeys.userId, userId), + eq(virtualApiKeys.kind, "user") + ) + ) + .limit(1); + + return existing ?? null; +} + +export async function getOrCreateUserVirtualApiKey(params: { + orgId: string; + userId: string; + createdByUserId?: string | null; +}): Promise<{ key: VirtualApiKey; secret: string }> { + const { orgId, userId, createdByUserId } = params; + + const existing = await selectUserVirtualApiKey(orgId, userId); + if (existing) { + return { + key: existing, + secret: decryptVirtualApiKeyToken(existing.token) + }; + } + + const minted = mintVirtualApiKeySecret(); + const now = Date.now(); + + try { + const [created] = await db + .insert(virtualApiKeys) + .values({ + virtualApiKeyId: minted.virtualApiKeyId, + orgId, + kind: "user", + userId, + name: null, + description: null, + token: encryptVirtualApiKeyToken(minted.secret), + lastChars: minted.lastChars, + allResources: false, + expiresAt: null, + lastUsedAt: null, + createdAt: now, + createdByUserId: createdByUserId ?? null + }) + .returning(); + + return { key: created, secret: minted.secret }; + } catch { + const raced = await selectUserVirtualApiKey(orgId, userId); + if (raced) { + return { + key: raced, + secret: decryptVirtualApiKeyToken(raced.token) + }; + } + throw new Error("Failed to create user virtual API key"); + } +} + +export async function rotateUserVirtualApiKey(params: { + orgId: string; + userId: string; + createdByUserId?: string | null; +}): Promise<{ key: VirtualApiKey; secret: string }> { + const { orgId, userId, createdByUserId } = params; + const existing = await selectUserVirtualApiKey(orgId, userId); + + if (!existing) { + return getOrCreateUserVirtualApiKey(params); + } + + const minted = mintVirtualApiKeySecret(); + const [updated] = await db + .update(virtualApiKeys) + .set({ + token: encryptVirtualApiKeyToken(minted.secret), + lastChars: minted.lastChars, + createdByUserId: + createdByUserId !== undefined + ? createdByUserId + : existing.createdByUserId + }) + .where(eq(virtualApiKeys.virtualApiKeyId, existing.virtualApiKeyId)) + .returning(); + + return { key: updated, secret: minted.secret }; +} diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 7242ea0cf..cd09add8a 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -30,6 +30,7 @@ export * from "./verifyDomainAccess"; export * from "./verifyAiProviderAccess"; export * from "./verifyAiModelAccess"; export * from "./verifyAiBudgetAccess"; +export * from "./verifyVirtualApiKeyAccess"; export * from "./verifyUserIsOrgOwner"; export * from "./verifyUserFromResourceSession"; export * from "./verifySiteResourceAccess"; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index 63df4b2bb..99a76cdf8 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -20,3 +20,4 @@ export * from "./verifyApiKeyAiProviderAccess"; export * from "./verifyApiKeyAiModelAccess"; export * from "./verifyApiKeyResourcePolicyAccess"; export * from "./verifyApiKeySiteProvisioningKeyAccess"; +export * from "./verifyVirtualApiKeyAccess"; diff --git a/server/middlewares/integration/verifyVirtualApiKeyAccess.ts b/server/middlewares/integration/verifyVirtualApiKeyAccess.ts new file mode 100644 index 000000000..a7dd4706a --- /dev/null +++ b/server/middlewares/integration/verifyVirtualApiKeyAccess.ts @@ -0,0 +1,87 @@ +import { Request, Response, NextFunction } from "express"; +import { apiKeyOrg, db, virtualApiKeys } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyApiKeyVirtualApiKeyAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const virtualApiKeyId = getFirstString(req.params.virtualApiKeyId); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!virtualApiKeyId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid virtual API key ID" + ) + ); + } + + const [key] = await db + .select() + .from(virtualApiKeys) + .where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId)) + .limit(1); + + if (!key || key.kind !== "manual") { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Virtual API key with ID ${virtualApiKeyId} not found` + ) + ); + } + + if (apiKey.isRoot) { + req.virtualApiKey = key; + return next(); + } + + const orgId = key.orgId; + + if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + req.virtualApiKey = key; + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying virtual API key access" + ) + ); + } +} diff --git a/server/middlewares/verifyVirtualApiKeyAccess.ts b/server/middlewares/verifyVirtualApiKeyAccess.ts new file mode 100644 index 000000000..a2c27fd90 --- /dev/null +++ b/server/middlewares/verifyVirtualApiKeyAccess.ts @@ -0,0 +1,101 @@ +import { Request, Response, NextFunction } from "express"; +import { db, userOrgs, virtualApiKeys } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyVirtualApiKeyAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const virtualApiKeyId = getFirstString(req.params.virtualApiKeyId); + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!virtualApiKeyId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid virtual API key ID" + ) + ); + } + + const [key] = await db + .select() + .from(virtualApiKeys) + .where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId)) + .limit(1); + + if (!key || key.kind !== "manual") { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Virtual API key with ID ${virtualApiKeyId} not found` + ) + ); + } + + const orgId = key.orgId; + + if (!req.userOrg || req.userOrg.orgId !== orgId) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "" + (policyCheck.error || "Unknown error") + ) + ); + } + } + + req.userOrgId = orgId; + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); + req.virtualApiKey = key; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying virtual API key access" + ) + ); + } +} diff --git a/server/openApi.ts b/server/openApi.ts index 920aaa471..b4b7c1a7a 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -31,7 +31,8 @@ export enum OpenAPITags { PrivateResourceLegacy = "Private Resource (Legacy)", AiProvider = "AI Provider", AiModel = "AI Model", - AiBudget = "AI Budget" + AiBudget = "AI Budget", + VirtualApiKey = "Virtual API Key" } // Order here controls the order tags are displayed in Swagger UI diff --git a/server/routers/external.ts b/server/routers/external.ts index 9e1a5f210..1eb32ec0f 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -48,7 +48,8 @@ import { verifyResourcePolicyAccess, verifyAiProviderAccess, verifyAiModelAccess, - verifyAiBudgetAccess + verifyAiBudgetAccess, + verifyVirtualApiKeyAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; @@ -60,6 +61,7 @@ import { checkRoundTripMessage } from "./ws"; import * as labels from "@server/routers/labels"; import * as aiProvider from "@server/routers/aiProvider"; import * as aiBudget from "@server/routers/aiBudget"; +import * as virtualApiKey from "@server/routers/virtualApiKey"; // Root routes export const unauthenticated = Router(); @@ -1633,6 +1635,44 @@ authenticated.delete( aiBudget.deleteAiBudget ); +authenticated.put( + "/org/:orgId/virtual-api-key", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createVirtualApiKey), + logActionAudit(ActionsEnum.createVirtualApiKey), + virtualApiKey.createVirtualApiKey +); + +authenticated.get( + "/org/:orgId/virtual-api-keys", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listVirtualApiKeys), + virtualApiKey.listVirtualApiKeys +); + +authenticated.get( + "/virtual-api-key/:virtualApiKeyId", + verifyVirtualApiKeyAccess, + verifyUserHasAction(ActionsEnum.getVirtualApiKey), + virtualApiKey.getVirtualApiKey +); + +authenticated.post( + "/virtual-api-key/:virtualApiKeyId", + verifyVirtualApiKeyAccess, + verifyUserHasAction(ActionsEnum.updateVirtualApiKey), + logActionAudit(ActionsEnum.updateVirtualApiKey), + virtualApiKey.updateVirtualApiKey +); + +authenticated.delete( + "/virtual-api-key/:virtualApiKeyId", + verifyVirtualApiKeyAccess, + verifyUserHasAction(ActionsEnum.deleteVirtualApiKey), + logActionAudit(ActionsEnum.deleteVirtualApiKey), + virtualApiKey.deleteVirtualApiKey +); + authenticated.get( "/ai-provider/:providerId/ai-budgets", verifyAiProviderAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index e08c14920..24c9955da 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -14,6 +14,7 @@ import * as idp from "./idp"; import * as logs from "./auditLogs"; import * as siteResource from "./siteResource"; import * as aiProvider from "./aiProvider"; +import * as virtualApiKey from "./virtualApiKey"; import { verifyApiKey, verifyApiKeyOrgAccess, @@ -34,6 +35,7 @@ import { verifyApiKeyResourcePolicyAccess, verifyApiKeyAiProviderAccess, verifyApiKeyAiModelAccess, + verifyApiKeyVirtualApiKeyAccess, verifyUserHasAction } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; @@ -1633,3 +1635,41 @@ authenticated.delete( logActionAudit(ActionsEnum.deleteAiModel), aiProvider.deleteAiModel ); + +authenticated.put( + "/org/:orgId/virtual-api-key", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createVirtualApiKey), + logActionAudit(ActionsEnum.createVirtualApiKey), + virtualApiKey.createVirtualApiKey +); + +authenticated.get( + "/org/:orgId/virtual-api-keys", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listVirtualApiKeys), + virtualApiKey.listVirtualApiKeys +); + +authenticated.get( + "/virtual-api-key/:virtualApiKeyId", + verifyApiKeyVirtualApiKeyAccess, + verifyApiKeyHasAction(ActionsEnum.getVirtualApiKey), + virtualApiKey.getVirtualApiKey +); + +authenticated.post( + "/virtual-api-key/:virtualApiKeyId", + verifyApiKeyVirtualApiKeyAccess, + verifyApiKeyHasAction(ActionsEnum.updateVirtualApiKey), + logActionAudit(ActionsEnum.updateVirtualApiKey), + virtualApiKey.updateVirtualApiKey +); + +authenticated.delete( + "/virtual-api-key/:virtualApiKeyId", + verifyApiKeyVirtualApiKeyAccess, + verifyApiKeyHasAction(ActionsEnum.deleteVirtualApiKey), + logActionAudit(ActionsEnum.deleteVirtualApiKey), + virtualApiKey.deleteVirtualApiKey +); diff --git a/server/routers/virtualApiKey/createVirtualApiKey.ts b/server/routers/virtualApiKey/createVirtualApiKey.ts new file mode 100644 index 000000000..5e49a5717 --- /dev/null +++ b/server/routers/virtualApiKey/createVirtualApiKey.ts @@ -0,0 +1,177 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, userOrgs, virtualApiKeys } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import { createDate, TimeSpan } from "oslo"; +import { + assertManualKeyResourcesInOrg, + encryptVirtualApiKeyToken, + mintVirtualApiKeySecret, + replaceVirtualApiKeyResources, + toPublicVirtualApiKey +} from "@server/lib/virtualApiKey"; +import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { createVirtualApiKeyBodySchema } from "@server/routers/virtualApiKey/validation"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/virtual-api-key", + description: "Create a manual virtual API key for an organization.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: createVirtualApiKeyBodySchema + } + } + } + }, + responses: { + 201: { + description: "Successful response" + } + } +}); + +export async function createVirtualApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = createVirtualApiKeyBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { + name, + description, + userId, + allResources, + resourceIds, + validForSeconds + } = parsedBody.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (userId) { + const [membership] = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) + ) + .limit(1); + + if (!membership) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User is not a member of this organization" + ) + ); + } + } + + const assignedResourceIds = allResources ? [] : (resourceIds ?? []); + const resourceCheck = await assertManualKeyResourcesInOrg({ + allResources, + resourceIds: assignedResourceIds, + orgId + }); + if (!resourceCheck.ok) { + return next( + createHttpError(HttpCode.BAD_REQUEST, resourceCheck.message) + ); + } + + const minted = mintVirtualApiKeySecret(); + const expiresAt = validForSeconds + ? createDate(new TimeSpan(validForSeconds, "s")).getTime() + : null; + const now = Date.now(); + + const created = await db.transaction(async (trx) => { + const [row] = await trx + .insert(virtualApiKeys) + .values({ + virtualApiKeyId: minted.virtualApiKeyId, + orgId, + kind: "manual", + userId: userId ?? null, + name, + description: description ?? null, + token: encryptVirtualApiKeyToken(minted.secret), + lastChars: minted.lastChars, + allResources, + expiresAt, + lastUsedAt: null, + createdAt: now, + createdByUserId: req.user?.userId ?? null + }) + .returning(); + + await replaceVirtualApiKeyResources( + trx, + row.virtualApiKeyId, + assignedResourceIds + ); + + return row; + }); + + return response(res, { + data: { + virtualApiKey: { + ...toPublicVirtualApiKey(created, { includeSecret: true }), + resourceIds: assignedResourceIds + } + }, + success: true, + error: false, + message: "Virtual API key created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/deleteVirtualApiKey.ts b/server/routers/virtualApiKey/deleteVirtualApiKey.ts new file mode 100644 index 000000000..c5223633a --- /dev/null +++ b/server/routers/virtualApiKey/deleteVirtualApiKey.ts @@ -0,0 +1,87 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, virtualApiKeys } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq } from "drizzle-orm"; + +const paramsSchema = z.strictObject({ + virtualApiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "delete", + path: "/virtual-api-key/{virtualApiKeyId}", + description: "Delete a manual virtual API key.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function deleteVirtualApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { virtualApiKeyId } = parsedParams.data; + + const [existing] = + req.virtualApiKey && + req.virtualApiKey.virtualApiKeyId === virtualApiKeyId + ? [req.virtualApiKey] + : await db + .select() + .from(virtualApiKeys) + .where( + eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId) + ) + .limit(1); + + if (!existing || existing.kind !== "manual") { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Virtual API key with ID ${virtualApiKeyId} not found` + ) + ); + } + + await db + .delete(virtualApiKeys) + .where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Virtual API key deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/getVirtualApiKey.ts b/server/routers/virtualApiKey/getVirtualApiKey.ts new file mode 100644 index 000000000..fddae3aef --- /dev/null +++ b/server/routers/virtualApiKey/getVirtualApiKey.ts @@ -0,0 +1,96 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, virtualApiKeyResources, virtualApiKeys } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq } from "drizzle-orm"; +import { toPublicVirtualApiKey } from "@server/lib/virtualApiKey"; +import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; + +const paramsSchema = z.strictObject({ + virtualApiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "get", + path: "/virtual-api-key/{virtualApiKeyId}", + description: + "Get a manual virtual API key by ID, including the decrypted secret.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function getVirtualApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { virtualApiKeyId } = parsedParams.data; + + const [key] = + req.virtualApiKey && + req.virtualApiKey.virtualApiKeyId === virtualApiKeyId + ? [req.virtualApiKey] + : await db + .select() + .from(virtualApiKeys) + .where( + eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId) + ) + .limit(1); + + if (!key || key.kind !== "manual") { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Virtual API key with ID ${virtualApiKeyId} not found` + ) + ); + } + + const resourceRows = await db + .select({ resourceId: virtualApiKeyResources.resourceId }) + .from(virtualApiKeyResources) + .where(eq(virtualApiKeyResources.virtualApiKeyId, virtualApiKeyId)); + + return response(res, { + data: { + virtualApiKey: { + ...toPublicVirtualApiKey(key, { includeSecret: true }), + resourceIds: resourceRows.map((row) => row.resourceId) + } + }, + success: true, + error: false, + message: "Virtual API key retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/index.ts b/server/routers/virtualApiKey/index.ts new file mode 100644 index 000000000..b80fba765 --- /dev/null +++ b/server/routers/virtualApiKey/index.ts @@ -0,0 +1,6 @@ +export * from "./createVirtualApiKey"; +export * from "./listVirtualApiKeys"; +export * from "./getVirtualApiKey"; +export * from "./updateVirtualApiKey"; +export * from "./deleteVirtualApiKey"; +export * from "./types"; diff --git a/server/routers/virtualApiKey/listVirtualApiKeys.ts b/server/routers/virtualApiKey/listVirtualApiKeys.ts new file mode 100644 index 000000000..be7250f1e --- /dev/null +++ b/server/routers/virtualApiKey/listVirtualApiKeys.ts @@ -0,0 +1,218 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + virtualApiKeyResources, + virtualApiKeys, + type VirtualApiKey +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, asc, eq, exists, inArray, like, or, sql } from "drizzle-orm"; +import { toPublicVirtualApiKey } from "@server/lib/virtualApiKey"; +import type { ListVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const listSchema = z.object({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() + .int() + .min(0) + .optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + search: z.string().optional(), + userId: z.string().optional(), + resourceId: z.coerce.number().int().positive().optional() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/virtual-api-keys", + description: "List manual virtual API keys for an organization.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema, + query: listSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listVirtualApiKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const { pageSize, page, search, userId, resourceId } = parsedQuery.data; + const conditions = [ + eq(virtualApiKeys.orgId, orgId), + eq(virtualApiKeys.kind, "manual") + ]; + + if (userId) { + conditions.push(eq(virtualApiKeys.userId, userId)); + } + + if (search) { + const term = "%" + search.toLowerCase() + "%"; + conditions.push( + or( + like(sql`LOWER(${virtualApiKeys.name})`, term), + like(sql`LOWER(${virtualApiKeys.description})`, term), + like(sql`LOWER(${virtualApiKeys.lastChars})`, term) + )! + ); + } + + if (resourceId !== undefined) { + conditions.push( + or( + eq(virtualApiKeys.allResources, true), + exists( + db + .select() + .from(virtualApiKeyResources) + .where( + and( + eq( + virtualApiKeyResources.virtualApiKeyId, + virtualApiKeys.virtualApiKeyId + ), + eq( + virtualApiKeyResources.resourceId, + resourceId + ) + ) + ) + ) + )! + ); + } + + const whereClause = and(...conditions); + + const [totalCount, rows] = await Promise.all([ + db.$count( + db + .select() + .from(virtualApiKeys) + .where(whereClause) + .as("filtered_virtual_api_keys") + ), + db + .select() + .from(virtualApiKeys) + .where(whereClause) + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + asc(virtualApiKeys.name), + asc(virtualApiKeys.createdAt) + ) + ]); + + const keyIds = rows.map((row) => row.virtualApiKeyId); + const resourceRows = + keyIds.length === 0 + ? [] + : await db + .select() + .from(virtualApiKeyResources) + .where( + inArray( + virtualApiKeyResources.virtualApiKeyId, + keyIds + ) + ); + + const resourceIdsByKey = new Map(); + for (const row of resourceRows) { + const existing = resourceIdsByKey.get(row.virtualApiKeyId) ?? []; + existing.push(row.resourceId); + resourceIdsByKey.set(row.virtualApiKeyId, existing); + } + + return response(res, { + data: { + virtualApiKeys: rows.map((row: VirtualApiKey) => ({ + ...toPublicVirtualApiKey(row), + resourceIds: resourceIdsByKey.get(row.virtualApiKeyId) ?? [] + })), + pagination: { + total: totalCount, + pageSize, + page + } + }, + success: true, + error: false, + message: "Virtual API keys retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/types.ts b/server/routers/virtualApiKey/types.ts new file mode 100644 index 000000000..28823577f --- /dev/null +++ b/server/routers/virtualApiKey/types.ts @@ -0,0 +1,16 @@ +import type { PublicVirtualApiKey } from "@server/lib/virtualApiKey"; +import type { PaginatedResponse } from "@server/types/Pagination"; + +export type { PublicVirtualApiKey }; + +export type ListVirtualApiKeysResponse = PaginatedResponse<{ + virtualApiKeys: (PublicVirtualApiKey & { resourceIds: number[] })[]; +}>; + +export type GetVirtualApiKeyResponse = { + virtualApiKey: PublicVirtualApiKey & { resourceIds: number[] }; +}; + +export type CreateOrEditVirtualApiKeyResponse = { + virtualApiKey: PublicVirtualApiKey & { resourceIds: number[] }; +}; diff --git a/server/routers/virtualApiKey/updateVirtualApiKey.ts b/server/routers/virtualApiKey/updateVirtualApiKey.ts new file mode 100644 index 000000000..42554803f --- /dev/null +++ b/server/routers/virtualApiKey/updateVirtualApiKey.ts @@ -0,0 +1,218 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + userOrgs, + virtualApiKeyResources, + virtualApiKeys +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import { createDate, TimeSpan } from "oslo"; +import { + assertManualKeyResourcesInOrg, + replaceVirtualApiKeyResources, + toPublicVirtualApiKey +} from "@server/lib/virtualApiKey"; +import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { updateVirtualApiKeyBodySchema } from "@server/routers/virtualApiKey/validation"; + +const paramsSchema = z.strictObject({ + virtualApiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "post", + path: "/virtual-api-key/{virtualApiKeyId}", + description: + "Update a manual virtual API key metadata and resource assignment.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: updateVirtualApiKeyBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function updateVirtualApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = updateVirtualApiKeyBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { virtualApiKeyId } = parsedParams.data; + const body = parsedBody.data; + + const [existing] = + req.virtualApiKey && + req.virtualApiKey.virtualApiKeyId === virtualApiKeyId + ? [req.virtualApiKey] + : await db + .select() + .from(virtualApiKeys) + .where( + eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId) + ) + .limit(1); + + if (!existing || existing.kind !== "manual") { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Virtual API key with ID ${virtualApiKeyId} not found` + ) + ); + } + + if (body.userId) { + const [membership] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, body.userId), + eq(userOrgs.orgId, existing.orgId) + ) + ) + .limit(1); + + if (!membership) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User is not a member of this organization" + ) + ); + } + } + + const nextAllResources = + body.allResources !== undefined + ? body.allResources + : existing.allResources; + + let nextResourceIds: number[] | undefined; + if (nextAllResources) { + nextResourceIds = []; + } else if (body.resourceIds !== undefined) { + nextResourceIds = body.resourceIds; + } + + if (nextResourceIds !== undefined) { + const resourceCheck = await assertManualKeyResourcesInOrg({ + allResources: nextAllResources, + resourceIds: nextResourceIds, + orgId: existing.orgId + }); + if (!resourceCheck.ok) { + return next( + createHttpError(HttpCode.BAD_REQUEST, resourceCheck.message) + ); + } + } + + const updates: Partial = {}; + + if (body.name !== undefined) { + updates.name = body.name; + } + if (body.description !== undefined) { + updates.description = body.description; + } + if (body.userId !== undefined) { + updates.userId = body.userId; + } + if (body.allResources !== undefined) { + updates.allResources = body.allResources; + } + if (body.validForSeconds !== undefined) { + updates.expiresAt = + body.validForSeconds === null + ? null + : createDate( + new TimeSpan(body.validForSeconds, "s") + ).getTime(); + } + + const updated = await db.transaction(async (trx) => { + let row = existing; + + if (Object.keys(updates).length > 0) { + const [updatedRow] = await trx + .update(virtualApiKeys) + .set(updates) + .where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId)) + .returning(); + row = updatedRow; + } + + if (nextResourceIds !== undefined) { + await replaceVirtualApiKeyResources( + trx, + virtualApiKeyId, + nextResourceIds + ); + } + + return row; + }); + + const resourceRows = await db + .select({ resourceId: virtualApiKeyResources.resourceId }) + .from(virtualApiKeyResources) + .where(eq(virtualApiKeyResources.virtualApiKeyId, virtualApiKeyId)); + + return response(res, { + data: { + virtualApiKey: { + ...toPublicVirtualApiKey(updated), + resourceIds: resourceRows.map((row) => row.resourceId) + } + }, + success: true, + error: false, + message: "Virtual API key updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/validation.ts b/server/routers/virtualApiKey/validation.ts new file mode 100644 index 000000000..a1c9ae671 --- /dev/null +++ b/server/routers/virtualApiKey/validation.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +export const virtualApiKeyResourceIdsSchema = z + .array(z.coerce.number().int().positive()) + .optional(); + +export const createVirtualApiKeyBodySchema = z.strictObject({ + name: z.string().nonempty(), + description: z.string().optional().nullable(), + userId: z.string().optional().nullable(), + allResources: z.boolean().optional().default(false), + resourceIds: virtualApiKeyResourceIdsSchema, + validForSeconds: z.int().positive().optional() +}); + +export const updateVirtualApiKeyBodySchema = z.strictObject({ + name: z.string().nonempty().optional(), + description: z.string().optional().nullable(), + userId: z.string().optional().nullable(), + allResources: z.boolean().optional(), + resourceIds: virtualApiKeyResourceIdsSchema, + validForSeconds: z.int().positive().optional().nullable() +}); From 4c1f7f624376a65207ff92d8f88c3b7c29748501 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 17:38:49 -0400 Subject: [PATCH 278/494] add caching to budget check --- server/lib/aiBudgetEnforcement.ts | 132 ++++++++++++++++++++++++++- server/lib/cache.ts | 2 +- server/routers/aiGateway/pipeline.ts | 31 ++++++- 3 files changed, 155 insertions(+), 10 deletions(-) diff --git a/server/lib/aiBudgetEnforcement.ts b/server/lib/aiBudgetEnforcement.ts index 8f70340fa..2a516695d 100644 --- a/server/lib/aiBudgetEnforcement.ts +++ b/server/lib/aiBudgetEnforcement.ts @@ -10,6 +10,7 @@ import { } from "@server/db"; import { modelKeyMatches } from "@server/lib/aiModelKeyMatch"; import type { AiUsage } from "@server/lib/aiUsageExtraction"; +import { regionalCache as cache } from "#dynamic/lib/cache"; import logger from "@server/logger"; type BudgetPeriod = AiBudget["period"]; @@ -22,6 +23,39 @@ const PERIOD_DURATIONS_MS: Record, number> = { yearly: 365 * 24 * 60 * 60 * 1000 }; +// Budgets are cheap to be a little stale about (enforcement is already +// check-then-act, not transactional). Re-derive each budget's usage sum +// from aiUsageRecords at most this often; in between, completed requests +// just add their own contribution onto the cached sum instead of +// re-querying/re-aggregating from scratch. +const BUDGET_CACHE_REFRESH_MS = 8_000; +// Redis-level TTL is only a safety net for eviction if a budget stops +// seeing traffic - the actual staleness check is the computedAt timestamp +// stored in the cached value, compared against BUDGET_CACHE_REFRESH_MS. +const BUDGET_CACHE_SAFETY_TTL_SEC = 60; + +function applicableBudgetsCacheKey(ctx: BudgetScopeContext): string { + const roleKey = [...ctx.roleIds].sort((a, b) => a - b).join(","); + return [ + "aiBudget:applicable", + ctx.orgId, + ctx.providerId, + ctx.requestedModel, + ctx.resourceId ?? "", + ctx.siteResourceId ?? "", + roleKey + ].join(":"); +} + +function budgetUsageCacheKey(budgetId: number): string { + return `aiBudget:usage:${budgetId}`; +} + +type CachedBudgetUsage = { + sum: number; + computedAt: number; +}; + // Budget periods are trailing windows from "now", not calendar-aligned // (e.g. "daily" = last 24h). "lifetime" has no lower bound. function windowStart(period: BudgetPeriod, now: number): number { @@ -45,10 +79,26 @@ export type BudgetScopeContext = { * Every budget that could apply to this request: the provider itself, any * model on that provider whose (possibly wildcarded) modelKey matches the * requested model, the target resource/site-resource, and any role the - * requesting user holds in the org. + * requesting user holds in the org. Cached for BUDGET_CACHE_REFRESH_MS since + * budget/model config changes are rare and a request-scoped org/provider/ + * model/resource/role combination repeats constantly under real traffic. */ export async function resolveApplicableBudgets( ctx: BudgetScopeContext +): Promise { + const cacheKey = applicableBudgetsCacheKey(ctx); + const cached = await cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const budgets = await fetchApplicableBudgets(ctx); + await cache.set(cacheKey, budgets, BUDGET_CACHE_REFRESH_MS / 1000); + return budgets; +} + +async function fetchApplicableBudgets( + ctx: BudgetScopeContext ): Promise { const providerModels = await db .select({ modelId: aiModels.modelId, modelKey: aiModels.modelKey }) @@ -217,6 +267,74 @@ export async function sumUsageForBudget( return 0; } +/** + * Cached wrapper around sumUsageForBudget. Reuses a per-budget cached sum + * for up to BUDGET_CACHE_REFRESH_MS, and otherwise falls through to the DB + * aggregation and reseeds the cache. Completed requests within that window + * top the cached sum up via applyUsageToBudgetCache below rather than + * forcing a re-aggregation on every request. + */ +async function getBudgetUsage( + budget: AiBudget, + ctx: BudgetScopeContext, + now: number +): Promise { + const cacheKey = budgetUsageCacheKey(budget.budgetId); + const cached = await cache.get(cacheKey); + if (cached && now - cached.computedAt < BUDGET_CACHE_REFRESH_MS) { + return cached.sum; + } + + const sum = await sumUsageForBudget(budget, ctx, now); + await cache.set( + cacheKey, + { sum, computedAt: now } satisfies CachedBudgetUsage, + BUDGET_CACHE_SAFETY_TTL_SEC + ); + return sum; +} + +/** + * Called once a request's actual usage is known, for every budget that was + * resolved as applicable to it (i.e. checkBudgets' returned `budgets`). + * Adds this request's contribution directly onto each budget's cached sum + * so the next request in the same refresh window doesn't need to re-query + * or re-aggregate. If there's no warm cache entry, or it's already due for + * a refresh, this is a no-op - the next reader re-derives from the DB, + * which by then already includes this request's row via recordUsage. + */ +export async function applyUsageToBudgetCache( + budgets: AiBudget[], + usage: { usd: number; tokens: number } +): Promise { + await Promise.all( + budgets.map(async (budget) => { + const delta = budget.unit === "usd" ? usage.usd : usage.tokens; + if (!delta) { + return; + } + + const cacheKey = budgetUsageCacheKey(budget.budgetId); + const cached = await cache.get(cacheKey); + if ( + !cached || + Date.now() - cached.computedAt >= BUDGET_CACHE_REFRESH_MS + ) { + return; + } + + await cache.set( + cacheKey, + { + sum: cached.sum + delta, + computedAt: cached.computedAt + } satisfies CachedBudgetUsage, + BUDGET_CACHE_SAFETY_TTL_SEC + ); + }) + ); +} + // Throttled to one durable event per budget per breach window, so a soft // budget being exceeded doesn't write a row on every subsequent request // while it stays over. @@ -265,6 +383,10 @@ async function recordBreachEventIfNew( export type BudgetCheckResult = { blocked: boolean; blockingBudget?: AiBudget; + // Every budget resolved as applicable to this request, regardless of + // whether it was breached - pass to applyUsageToBudgetCache once this + // request's actual usage is known. + budgets: AiBudget[]; }; export async function checkBudgets( @@ -272,14 +394,14 @@ export async function checkBudgets( ): Promise { const budgets = await resolveApplicableBudgets(ctx); if (budgets.length === 0) { - return { blocked: false }; + return { blocked: false, budgets: [] }; } const now = Date.now(); let blockingBudget: AiBudget | undefined; for (const budget of budgets) { - const usage = await sumUsageForBudget(budget, ctx, now); + const usage = await getBudgetUsage(budget, ctx, now); if (usage < budget.amount) { continue; } @@ -292,8 +414,8 @@ export async function checkBudgets( } return blockingBudget - ? { blocked: true, blockingBudget } - : { blocked: false }; + ? { blocked: true, blockingBudget, budgets } + : { blocked: false, budgets }; } export type UsageRecordInput = { diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 498d0dd3f..0113099e1 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -168,5 +168,5 @@ class AdaptiveCache { // Export singleton instance export const cache = new AdaptiveCache(); -export const regionalCache = cache; // Alias for compatability with the private version +export const regionalCache = cache; // Alias for compatibility with the private version export default cache; diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 802f5f453..22274e049 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import { and, eq, inArray } from "drizzle-orm"; import { + AiBudget, AiProvider, aiModels, aiProviders, @@ -51,7 +52,11 @@ import { } from "@server/lib/aiModelKeyMatch"; import { aiGatewayUpstreamFetch } from "@server/lib/aiGatewayUpstreamFetch"; import { getModelPricing, calculateAiCost } from "@server/lib/aiModelPricing"; -import { checkBudgets, recordUsage } from "@server/lib/aiBudgetEnforcement"; +import { + applyUsageToBudgetCache, + checkBudgets, + recordUsage +} from "@server/lib/aiBudgetEnforcement"; import { extractUsage, estimateUsage, @@ -533,6 +538,7 @@ function logAiUsageAndCost(args: { resourceId: number | null; siteResourceId: number | null; requestUserId: string | null; + budgets: AiBudget[]; }): void { const { capability, @@ -545,7 +551,8 @@ function logAiUsageAndCost(args: { orgId, resourceId, siteResourceId, - requestUserId + requestUserId, + budgets } = args; let usage: AiUsage | null = extractUsage( @@ -588,6 +595,18 @@ function logAiUsageAndCost(args: { usage, costUsd: cost?.totalCost ?? null }); + + if (budgets.length > 0) { + void applyUsageToBudgetCache(budgets, { + usd: cost?.totalCost ?? 0, + tokens: + usage.promptTokens + + usage.cacheReadTokens + + usage.cacheWriteTokens + + usage.completionTokens + + usage.reasoningTokens + }); + } } } @@ -666,6 +685,7 @@ export async function handleAiGatewayProxy( const { provider } = selection; + let appliedBudgets: AiBudget[] = []; if (orgId) { const budgetCheck = await checkBudgets({ orgId, @@ -676,6 +696,7 @@ export async function handleAiGatewayProxy( roleIds: requestUser?.roleIds ?? [], requestUserId: requestUser?.userId ?? null }); + appliedBudgets = budgetCheck.budgets; if (budgetCheck.blocked) { logger.warn("AI gateway request blocked by budget", { @@ -876,7 +897,8 @@ export async function handleAiGatewayProxy( orgId, resourceId, siteResourceId, - requestUserId: requestUser?.userId ?? null + requestUserId: requestUser?.userId ?? null, + budgets: appliedBudgets }); } return; @@ -895,7 +917,8 @@ export async function handleAiGatewayProxy( orgId, resourceId, siteResourceId, - requestUserId: requestUser?.userId ?? null + requestUserId: requestUser?.userId ?? null, + budgets: appliedBudgets }); return res.send(text); } catch (error) { From 6d45486bb5fe986e8b8d62b9c8e51f51bea67f1d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 10 Aug 2026 21:44:22 -0400 Subject: [PATCH 279/494] add manual virtual api key create ui --- messages/en-US.json | 54 ++ server/lib/virtualApiKey.ts | 14 +- server/routers/resource/listResources.ts | 13 +- server/routers/virtualApiKey/validation.ts | 25 +- .../settings/virtual-api-keys/page.tsx | 153 ++++++ src/app/navigation.tsx | 10 + src/components/CreateVirtualApiKeyForm.tsx | 419 ++++++++++++++ src/components/EditVirtualApiKeyForm.tsx | 396 ++++++++++++++ src/components/PermissionsSelectBox.tsx | 8 + src/components/ViewVirtualApiKeySecret.tsx | 134 +++++ src/components/VirtualApiKeysDataTable.tsx | 41 ++ src/components/VirtualApiKeysTable.tsx | 516 ++++++++++++++++++ src/components/multi-resource-selector.tsx | 142 +++++ src/components/resource-selector.tsx | 19 +- src/components/user-selector.tsx | 38 +- src/lib/queries.ts | 10 +- 16 files changed, 1974 insertions(+), 18 deletions(-) create mode 100644 src/app/[orgId]/settings/virtual-api-keys/page.tsx create mode 100644 src/components/CreateVirtualApiKeyForm.tsx create mode 100644 src/components/EditVirtualApiKeyForm.tsx create mode 100644 src/components/ViewVirtualApiKeySecret.tsx create mode 100644 src/components/VirtualApiKeysDataTable.tsx create mode 100644 src/components/VirtualApiKeysTable.tsx create mode 100644 src/components/multi-resource-selector.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 4ff3f9da2..b614bfd38 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1436,6 +1436,11 @@ "actionGetAiModel": "Get AI Model", "actionListAiModels": "List AI Models", "actionUpdateAiModel": "Update AI Model", + "actionCreateVirtualApiKey": "Create Virtual API Key", + "actionDeleteVirtualApiKey": "Delete Virtual API Key", + "actionGetVirtualApiKey": "Get Virtual API Key", + "actionListVirtualApiKeys": "List Virtual API Keys", + "actionUpdateVirtualApiKey": "Update Virtual API Key", "actionApplyBlueprint": "Apply Blueprint", "actionListBlueprints": "List Blueprints", "actionGetBlueprint": "Get Blueprint", @@ -1637,6 +1642,53 @@ "sidebarAiGateway": "AI Gateway", "sidebarAiProviders": "Providers", "commandAiProviders": "AI Providers", + "sidebarVirtualApiKeys": "Virtual API Keys", + "commandVirtualApiKeys": "Virtual API Keys", + "virtualApiKeysTitle": "Manage Virtual API Keys", + "virtualApiKeysDescription": "Create and manage manual API keys for AI Gateway access to public inference resources", + "virtualApiKeys": "Virtual API Keys", + "virtualApiKeysSearch": "Search keys...", + "virtualApiKeysCreate": "Create Virtual API Key", + "virtualApiKeysCreateDescription": "Mint a manual key that can call public inference resources in this organization", + "virtualApiKeysCreateButton": "Create Key", + "virtualApiKeysEmpty": "No virtual API keys yet", + "virtualApiKeysName": "Name", + "virtualApiKeysDescriptionOptional": "Description (optional)", + "virtualApiKeysAssociateUserOptional": "Associate User (optional)", + "virtualApiKeysAssociateUserDescription": "Attribution only. Does not grant access by itself.", + "virtualApiKeysAllResources": "All public inference resources", + "virtualApiKeysAllResourcesDescription": "Allow this key to access every public inference resource in the organization", + "virtualApiKeysSelectResources": "Public Inference Resources", + "virtualApiKeysSelectResourcesPlaceholder": "Select resources", + "virtualApiKeysSelectResourcesDescription": "Choose which public inference resources this key can access", + "virtualApiKeysNoResources": "No resources", + "virtualApiKeysSecret": "Key", + "virtualApiKeysSeeOnce": "Copy this key now. You can also view it again later from the table.", + "virtualApiKeysSecretHint": "Use this value as a Bearer token: vk-[id].[secret]", + "virtualApiKeysViewSecret": "View Secret", + "virtualApiKeysViewSecretTitle": "Virtual API Key Secret", + "virtualApiKeysViewSecretDescription": "This secret grants access to the public inference resources assigned to this key", + "virtualApiKeysEdit": "Edit Virtual API Key", + "virtualApiKeysEditDescription": "Update the associated user and public inference resource access for this key", + "virtualApiKeysSaveButton": "Save Changes", + "virtualApiKeysSelectResourcesRequired": "Select at least one public inference resource, or enable all public inference resources", + "virtualApiKeysUpdated": "Virtual API key updated", + "virtualApiKeysUpdatedDescription": "The virtual API key has been updated", + "virtualApiKeysErrorUpdate": "Error updating virtual API key", + "virtualApiKeysErrorUpdateDescription": "Failed to update virtual API key", + "virtualApiKeysErrorCreate": "Error creating virtual API key", + "virtualApiKeysErrorCreateDescription": "Failed to create virtual API key", + "virtualApiKeysErrorDelete": "Error deleting virtual API key", + "virtualApiKeysErrorDeleteMessage": "Failed to delete virtual API key", + "virtualApiKeysDeleted": "Virtual API key deleted", + "virtualApiKeysDeletedDescription": "The virtual API key has been deleted", + "virtualApiKeysDelete": "Delete Virtual API Key", + "virtualApiKeysDeleteConfirm": "Delete Key", + "virtualApiKeysQuestionRemove": "Are you sure you want to delete this virtual API key?", + "virtualApiKeysMessageRemove": "Clients using this key will lose access immediately.", + "virtualApiKeysErrorFetchSecret": "Error loading secret", + "virtualApiKeysErrorFetchSecretDescription": "Failed to load the virtual API key secret", + "virtualApiKeysFilterUnassigned": "Unassigned", "aiProvidersTitle": "AI Providers", "aiProvidersDescription": "Connect model providers for AI workloads in this organization", "aiProvidersAdd": "Add Provider", @@ -2430,6 +2482,8 @@ "subnetPlaceholder": "Subnet", "addressDescription": "The internal address of the client. Must fall within the organization's subnet.", "selectSites": "Select sites", + "selectResources": "Select resources", + "multiResourcesSelectorResourcesCount": "{count, plural, one {# resource} other {# resources}}", "selectLabels": "Select labels", "sitesDescription": "The client will have connectivity to the selected sites", "clientInstallOlm": "Install Machine Client", diff --git a/server/lib/virtualApiKey.ts b/server/lib/virtualApiKey.ts index 15f9b52ff..66ebb4b5a 100644 --- a/server/lib/virtualApiKey.ts +++ b/server/lib/virtualApiKey.ts @@ -62,10 +62,18 @@ export async function assertManualKeyResourcesInOrg(params: { }): Promise<{ ok: true } | { ok: false; message: string }> { const { allResources, resourceIds, orgId } = params; - if (allResources || resourceIds.length === 0) { + if (allResources) { return { ok: true }; } + if (resourceIds.length === 0) { + return { + ok: false, + message: + "Select at least one public inference resource, or enable all public inference resources" + }; + } + const uniqueIds = [...new Set(resourceIds)]; const rows = await db .select({ resourceId: resources.resourceId }) @@ -73,6 +81,7 @@ export async function assertManualKeyResourcesInOrg(params: { .where( and( eq(resources.orgId, orgId), + eq(resources.mode, "inference"), inArray(resources.resourceId, uniqueIds) ) ); @@ -80,7 +89,8 @@ export async function assertManualKeyResourcesInOrg(params: { if (rows.length !== uniqueIds.length) { return { ok: false, - message: "One or more resources are invalid for this organization" + message: + "One or more resources are invalid public inference resources for this organization" }; } diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index e5408f630..124c480c2 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -124,12 +124,21 @@ const listResourcesSchema = z.strictObject({ "Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status." }), protocol: z - .enum(["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"]) + .enum(["http", "https", "tcp", "udp", "ssh", "rdp", "vnc", "inference"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"], + enum: [ + "http", + "https", + "tcp", + "udp", + "ssh", + "rdp", + "vnc", + "inference" + ], description: "Filter resources by protocol. `http` and `https` match HTTP resources without and with SSL respectively." }), diff --git a/server/routers/virtualApiKey/validation.ts b/server/routers/virtualApiKey/validation.ts index a1c9ae671..42964b3ba 100644 --- a/server/routers/virtualApiKey/validation.ts +++ b/server/routers/virtualApiKey/validation.ts @@ -4,14 +4,23 @@ export const virtualApiKeyResourceIdsSchema = z .array(z.coerce.number().int().positive()) .optional(); -export const createVirtualApiKeyBodySchema = z.strictObject({ - name: z.string().nonempty(), - description: z.string().optional().nullable(), - userId: z.string().optional().nullable(), - allResources: z.boolean().optional().default(false), - resourceIds: virtualApiKeyResourceIdsSchema, - validForSeconds: z.int().positive().optional() -}); +export const createVirtualApiKeyBodySchema = z + .strictObject({ + name: z.string().nonempty(), + description: z.string().optional().nullable(), + userId: z.string().optional().nullable(), + allResources: z.boolean().optional().default(false), + resourceIds: virtualApiKeyResourceIdsSchema, + validForSeconds: z.int().positive().optional() + }) + .refine( + (data) => data.allResources || (data.resourceIds?.length ?? 0) > 0, + { + message: + "Select at least one public inference resource, or enable all public inference resources", + path: ["resourceIds"] + } + ); export const updateVirtualApiKeyBodySchema = z.strictObject({ name: z.string().nonempty().optional(), diff --git a/src/app/[orgId]/settings/virtual-api-keys/page.tsx b/src/app/[orgId]/settings/virtual-api-keys/page.tsx new file mode 100644 index 000000000..ba8f4ea01 --- /dev/null +++ b/src/app/[orgId]/settings/virtual-api-keys/page.tsx @@ -0,0 +1,153 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { redirect } from "next/navigation"; +import { cache } from "react"; +import { GetOrgResponse } from "@server/routers/org"; +import OrgProvider from "@app/providers/OrgProvider"; +import VirtualApiKeysTable, { + type VirtualApiKeyRow +} from "@app/components/VirtualApiKeysTable"; +import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; +import type { ListVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types"; +import type { ListUsersResponse } from "@server/routers/user"; +import type { ListResourcesResponse } from "@server/routers/resource"; + +export const metadata: Metadata = { + title: "Virtual API Keys" +}; + +type VirtualApiKeysPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function VirtualApiKeysPage( + props: VirtualApiKeysPageProps +) { + const params = await props.params; + const cookieHeader = await authCookieHeader(); + const t = await getTranslations(); + + let keys: ListVirtualApiKeysResponse["virtualApiKeys"] = []; + let users: { + userId: string; + email: string | null; + name: string | null; + username: string | null; + }[] = []; + let resources: { + resourceId: number; + name: string; + niceId: string; + }[] = []; + + try { + const [keysRes, usersRes, resourcesRes] = await Promise.all([ + internal.get>( + `/org/${params.orgId}/virtual-api-keys?page=1&pageSize=1000`, + cookieHeader + ), + internal.get>( + `/org/${params.orgId}/users?page=1&pageSize=1000`, + cookieHeader + ), + internal.get>( + `/org/${params.orgId}/resources?page=1&pageSize=1000`, + cookieHeader + ) + ]); + + keys = keysRes.data.data.virtualApiKeys ?? []; + users = (usersRes.data.data.users ?? []).map((u) => ({ + userId: u.id, + email: u.email ?? null, + name: u.name ?? null, + username: u.username ?? null + })); + resources = (resourcesRes.data.data.resources ?? []).map((r) => ({ + resourceId: r.resourceId, + name: r.name, + niceId: r.niceId + })); + } catch { + // leave empty; page still renders + } + + let org = null; + try { + const getOrg = cache(async () => + internal.get>( + `/org/${params.orgId}`, + cookieHeader + ) + ); + const res = await getOrg(); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/resources`); + } + + if (!org) { + redirect(`/${params.orgId}/settings/resources`); + } + + const userById = new Map(users.map((u) => [u.userId, u])); + const resourceById = new Map(resources.map((r) => [r.resourceId, r])); + + const rows: VirtualApiKeyRow[] = keys.map((key) => { + const user = key.userId ? userById.get(key.userId) : undefined; + const keyResources = key.resourceIds + .map((id) => resourceById.get(id)) + .filter(Boolean) as { + resourceId: number; + name: string; + niceId: string; + }[]; + + const resourceNames = key.allResources + ? t("virtualApiKeysAllResources") + : keyResources.map((r) => r.name).join(", ") || + t("virtualApiKeysNoResources"); + + return { + virtualApiKeyId: key.virtualApiKeyId, + orgId: key.orgId, + kind: key.kind, + userId: key.userId, + name: key.name, + description: key.description, + lastChars: key.lastChars, + allResources: key.allResources, + expiresAt: key.expiresAt, + lastUsedAt: key.lastUsedAt, + createdAt: key.createdAt, + createdByUserId: key.createdByUserId, + resourceIds: key.resourceIds, + userName: user?.name ?? null, + username: user?.username ?? null, + userEmail: user?.email ?? null, + resourceNames, + resources: keyResources + }; + }); + + return ( + <> + + + + + + + ); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index e6971bc94..20af6d05d 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -195,6 +195,11 @@ export const orgNavSections = ( title: "sidebarAiProviders", href: "/{orgId}/settings/ai-providers", icon: + }, + { + title: "sidebarVirtualApiKeys", + href: "/{orgId}/settings/virtual-api-keys", + icon: } ] }, @@ -490,6 +495,11 @@ export const commandBarNavSections = ( title: "commandAiProviders", href: "/{orgId}/settings/ai-providers", icon: + }, + { + title: "commandVirtualApiKeys", + href: "/{orgId}/settings/virtual-api-keys", + icon: } ] }, diff --git a/src/components/CreateVirtualApiKeyForm.tsx b/src/components/CreateVirtualApiKeyForm.tsx new file mode 100644 index 000000000..32a3877aa --- /dev/null +++ b/src/components/CreateVirtualApiKeyForm.tsx @@ -0,0 +1,419 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { toast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AxiosResponse } from "axios"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { formatAxiosError, createApiClient } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { CaretSortIcon } from "@radix-ui/react-icons"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { useTranslations } from "next-intl"; +import { UserSelector, type SelectedUser } from "@app/components/user-selector"; +import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { + MultiResourcesSelector, + formatMultiResourcesSelectorLabel +} from "@app/components/multi-resource-selector"; +import type { SelectedResource } from "@app/components/resource-selector"; + +export type CreatedVirtualApiKey = { + virtualApiKeyId: string; + orgId: string; + kind: "manual" | "user"; + userId: string | null; + name: string | null; + description: string | null; + lastChars: string; + allResources: boolean; + expiresAt: number | null; + lastUsedAt: number | null; + createdAt: number; + createdByUserId: string | null; + resourceIds: number[]; + userName?: string | null; + username?: string | null; + userEmail?: string | null; + resourceNames: string; + resources: { resourceId: number; name: string; niceId: string }[]; +}; + +type FormProps = { + open: boolean; + setOpen: (open: boolean) => void; + onCreated?: (result: CreatedVirtualApiKey) => void; +}; + +export default function CreateVirtualApiKeyForm({ + open, + setOpen, + onCreated +}: FormProps) { + const { org } = useOrgContext(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const t = useTranslations(); + + const [credential, setCredential] = useState(null); + const [loading, setLoading] = useState(false); + const [allResources, setAllResources] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedResources, setSelectedResources] = useState< + SelectedResource[] + >([]); + + const formSchema = z.object({ + name: z.string().min(1), + description: z.string().optional() + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + description: "" + } + }); + + function resetLocalState() { + setCredential(null); + setLoading(false); + setAllResources(false); + setSelectedUser(null); + setSelectedResources([]); + form.reset(); + } + + async function onSubmit(values: z.infer) { + setLoading(true); + + const res = await api + .put>( + `/org/${org.org.orgId}/virtual-api-key`, + { + name: values.name, + description: values.description || null, + userId: selectedUser?.id ?? null, + allResources, + resourceIds: allResources + ? [] + : selectedResources.map((r) => r.resourceId) + } + ) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: t("virtualApiKeysErrorCreate"), + description: formatAxiosError( + e, + t("virtualApiKeysErrorCreateDescription") + ) + }); + }); + + if (res?.data.data.virtualApiKey) { + const key = res.data.data.virtualApiKey; + if (key.secret) { + setCredential(`vk-${key.virtualApiKeyId}.${key.secret}`); + } + + const resourceLookup = new Map( + selectedResources.map((r) => [ + r.resourceId, + { name: r.name, niceId: r.niceId } + ]) + ); + const resourceNames = key.allResources + ? t("virtualApiKeysAllResources") + : key.resourceIds + .map((id) => resourceLookup.get(id)?.name) + .filter(Boolean) + .join(", ") || t("virtualApiKeysNoResources"); + + onCreated?.({ + virtualApiKeyId: key.virtualApiKeyId, + orgId: key.orgId, + kind: key.kind, + userId: key.userId, + name: key.name, + description: key.description, + lastChars: key.lastChars, + allResources: key.allResources, + expiresAt: key.expiresAt, + lastUsedAt: key.lastUsedAt, + createdAt: key.createdAt, + createdByUserId: key.createdByUserId, + resourceIds: key.resourceIds, + userName: selectedUser?.text ?? null, + username: null, + userEmail: null, + resourceNames, + resources: key.resourceIds.map((id) => ({ + resourceId: id, + name: resourceLookup.get(id)?.name ?? String(id), + niceId: resourceLookup.get(id)?.niceId ?? "" + })) + }); + } + + setLoading(false); + } + + return ( + { + setOpen(val); + if (!val) { + resetLocalState(); + } + }} + > + + + {t("virtualApiKeysCreate")} + + {t("virtualApiKeysCreateDescription")} + + + +
+ {!credential && ( +
+ + ( + + + {t("virtualApiKeysName")} + + + + + + + )} + /> + + ( + + + {t( + "virtualApiKeysDescriptionOptional" + )} + + + + + + + )} + /> + +
+ + {t( + "virtualApiKeysAssociateUserOptional" + )} + + + + + + + + + +

+ {t( + "virtualApiKeysAssociateUserDescription" + )} +

+
+ +
+
+ { + setAllResources( + val as boolean + ); + if (val) { + setSelectedResources( + [] + ); + } + }} + className="mt-0.5" + /> +
+ +

+ {t( + "virtualApiKeysAllResourcesDescription" + )} +

+
+
+ + {!allResources && ( +
+ + {t( + "virtualApiKeysSelectResources" + )} + + + + + + + + 0 + } + onClear={() => + setSelectedResources( + [] + ) + } + /> + + + + {t( + "virtualApiKeysSelectResourcesDescription" + )} + +
+ )} +
+ + + )} + {credential && ( +
+

{t("virtualApiKeysSeeOnce")}

+ +
+ )} +
+
+ + + + + + +
+
+ ); +} diff --git a/src/components/EditVirtualApiKeyForm.tsx b/src/components/EditVirtualApiKeyForm.tsx new file mode 100644 index 000000000..22a479df3 --- /dev/null +++ b/src/components/EditVirtualApiKeyForm.tsx @@ -0,0 +1,396 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { toast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AxiosResponse } from "axios"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { formatAxiosError, createApiClient } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { CaretSortIcon } from "@radix-ui/react-icons"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { useTranslations } from "next-intl"; +import { UserSelector, type SelectedUser } from "@app/components/user-selector"; +import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { + MultiResourcesSelector, + formatMultiResourcesSelectorLabel +} from "@app/components/multi-resource-selector"; +import type { SelectedResource } from "@app/components/resource-selector"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import type { CreatedVirtualApiKey } from "@app/components/CreateVirtualApiKeyForm"; + +type FormProps = { + open: boolean; + setOpen: (open: boolean) => void; + virtualApiKey: CreatedVirtualApiKey | null; + onUpdated?: (result: CreatedVirtualApiKey) => void; +}; + +function resourcesFromRow(key: CreatedVirtualApiKey): SelectedResource[] { + return key.resources.map((r) => ({ + resourceId: r.resourceId, + name: r.name, + niceId: r.niceId, + fullDomain: null, + ssl: false, + wildcard: false + })); +} + +function userFromRow(key: CreatedVirtualApiKey): SelectedUser | null { + if (!key.userId) { + return null; + } + return { + id: key.userId, + text: getUserDisplayName({ + email: key.userEmail, + name: key.userName, + username: key.username + }) + }; +} + +export default function EditVirtualApiKeyForm({ + open, + setOpen, + virtualApiKey, + onUpdated +}: FormProps) { + const { org } = useOrgContext(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const t = useTranslations(); + + const [loading, setLoading] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedResources, setSelectedResources] = useState< + SelectedResource[] + >([]); + + const formSchema = z + .object({ + allResources: z.boolean() + }) + .superRefine((data, ctx) => { + if (!data.allResources && selectedResources.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("virtualApiKeysSelectResourcesRequired"), + path: ["allResources"] + }); + } + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + allResources: false + } + }); + + const allResources = form.watch("allResources"); + + useEffect(() => { + if (!open || !virtualApiKey) { + return; + } + setLoading(false); + setSelectedUser(userFromRow(virtualApiKey)); + setSelectedResources( + virtualApiKey.allResources ? [] : resourcesFromRow(virtualApiKey) + ); + form.reset({ + allResources: virtualApiKey.allResources + }); + }, [open, virtualApiKey, form]); + + async function onSubmit(values: z.infer) { + if (!virtualApiKey) { + return; + } + + setLoading(true); + + const res = await api + .post>( + `/virtual-api-key/${virtualApiKey.virtualApiKeyId}`, + { + userId: selectedUser?.id ?? null, + allResources: values.allResources, + resourceIds: values.allResources + ? [] + : selectedResources.map((r) => r.resourceId) + } + ) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: t("virtualApiKeysErrorUpdate"), + description: formatAxiosError( + e, + t("virtualApiKeysErrorUpdateDescription") + ) + }); + }); + + if (res?.data.data.virtualApiKey) { + const key = res.data.data.virtualApiKey; + const resourceLookup = new Map( + selectedResources.map((r) => [ + r.resourceId, + { name: r.name, niceId: r.niceId } + ]) + ); + const resourceNames = key.allResources + ? t("virtualApiKeysAllResources") + : key.resourceIds + .map((id) => resourceLookup.get(id)?.name) + .filter(Boolean) + .join(", ") || t("virtualApiKeysNoResources"); + + onUpdated?.({ + ...virtualApiKey, + userId: key.userId, + allResources: key.allResources, + resourceIds: key.resourceIds, + userName: selectedUser?.text ?? null, + username: null, + userEmail: null, + resourceNames, + resources: key.resourceIds.map((id) => ({ + resourceId: id, + name: resourceLookup.get(id)?.name ?? String(id), + niceId: resourceLookup.get(id)?.niceId ?? "" + })) + }); + + toast({ + title: t("virtualApiKeysUpdated"), + description: t("virtualApiKeysUpdatedDescription") + }); + setOpen(false); + } + + setLoading(false); + } + + return ( + { + setOpen(val); + }} + > + + + {t("virtualApiKeysEdit")} + + {t("virtualApiKeysEditDescription")} + + + +
+
+ +
+ + {t( + "virtualApiKeysAssociateUserOptional" + )} + + + + + + + + + +

+ {t( + "virtualApiKeysAssociateUserDescription" + )} +

+
+ +
+ ( + +
+ + { + field.onChange( + val as boolean + ); + if (val) { + setSelectedResources( + [] + ); + } + }} + className="mt-0.5" + /> + +
+ +

+ {t( + "virtualApiKeysAllResourcesDescription" + )} +

+
+
+ +
+ )} + /> + + {!allResources && ( +
+ + {t( + "virtualApiKeysSelectResources" + )} + + + + + + + + 0 + } + onClear={() => + setSelectedResources( + [] + ) + } + /> + + + + {t( + "virtualApiKeysSelectResourcesRequired" + )} + +
+ )} +
+
+ +
+
+ + + + + + +
+
+ ); +} diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index fe33337d4..ef78d4c71 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -166,6 +166,14 @@ function getActionsCategories(root: boolean) { [t("actionGetAiModel")]: "getAiModel", [t("actionListAiModels")]: "listAiModels", [t("actionUpdateAiModel")]: "updateAiModel" + }, + + "Virtual API Key": { + [t("actionCreateVirtualApiKey")]: "createVirtualApiKey", + [t("actionDeleteVirtualApiKey")]: "deleteVirtualApiKey", + [t("actionGetVirtualApiKey")]: "getVirtualApiKey", + [t("actionListVirtualApiKeys")]: "listVirtualApiKeys", + [t("actionUpdateVirtualApiKey")]: "updateVirtualApiKey" } }; diff --git a/src/components/ViewVirtualApiKeySecret.tsx b/src/components/ViewVirtualApiKeySecret.tsx new file mode 100644 index 000000000..800fb3484 --- /dev/null +++ b/src/components/ViewVirtualApiKeySecret.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { AxiosResponse } from "axios"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; + +type ViewVirtualApiKeySecretProps = { + open: boolean; + setOpen: (open: boolean) => void; + virtualApiKeyId: string | null; + name?: string | null; +}; + +export default function ViewVirtualApiKeySecret({ + open, + setOpen, + virtualApiKeyId, + name +}: ViewVirtualApiKeySecretProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); + const [credential, setCredential] = useState(null); + + useEffect(() => { + if (!open || !virtualApiKeyId) { + return; + } + + let cancelled = false; + setLoading(true); + setCredential(null); + + api.get>( + `/virtual-api-key/${virtualApiKeyId}` + ) + .then((res) => { + if (cancelled) { + return; + } + const key = res.data.data.virtualApiKey; + if (key.secret) { + setCredential(`vk-${key.virtualApiKeyId}.${key.secret}`); + } else { + toast({ + variant: "destructive", + title: t("virtualApiKeysErrorFetchSecret"), + description: t( + "virtualApiKeysErrorFetchSecretDescription" + ) + }); + } + }) + .catch((e) => { + if (cancelled) { + return; + } + toast({ + variant: "destructive", + title: t("virtualApiKeysErrorFetchSecret"), + description: formatAxiosError( + e, + t("virtualApiKeysErrorFetchSecretDescription") + ) + }); + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [open, virtualApiKeyId]); + + return ( + { + setOpen(val); + if (!val) { + setCredential(null); + setLoading(false); + } + }} + > + + + + {t("virtualApiKeysViewSecretTitle")} + + + {name ? name : t("virtualApiKeysViewSecretDescription")} + + + +
+ {loading && ( +

+ {t("loading")} +

+ )} + {!loading && credential && ( + + )} +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/VirtualApiKeysDataTable.tsx b/src/components/VirtualApiKeysDataTable.tsx new file mode 100644 index 000000000..d691102eb --- /dev/null +++ b/src/components/VirtualApiKeysDataTable.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +type DataTableProps = { + columns: ColumnDef[]; + data: TData[]; + createVirtualApiKey?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; +}; + +export function VirtualApiKeysDataTable({ + columns, + data, + createVirtualApiKey, + onRefresh, + isRefreshing +}: DataTableProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/VirtualApiKeysTable.tsx b/src/components/VirtualApiKeysTable.tsx new file mode 100644 index 000000000..34b670328 --- /dev/null +++ b/src/components/VirtualApiKeysTable.tsx @@ -0,0 +1,516 @@ +"use client"; + +import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { VirtualApiKeysDataTable } from "@app/components/VirtualApiKeysDataTable"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { Badge } from "@app/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + ArrowRight, + ArrowUpDown, + ArrowUpRight, + Funnel, + MoreHorizontal +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { formatAxiosError, createApiClient } from "@app/lib/api"; +import { toast } from "@app/hooks/useToast"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import moment from "moment"; +import CreateVirtualApiKeyForm, { + type CreatedVirtualApiKey +} from "@app/components/CreateVirtualApiKeyForm"; +import EditVirtualApiKeyForm from "@app/components/EditVirtualApiKeyForm"; +import ViewVirtualApiKeySecret from "@app/components/ViewVirtualApiKeySecret"; +import { useTranslations } from "next-intl"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { UserSelector, type SelectedUser } from "@app/components/user-selector"; +import { + ResourceSelector, + type SelectedResource +} from "@app/components/resource-selector"; +import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; + +export type VirtualApiKeyRow = CreatedVirtualApiKey; + +type VirtualApiKeysTableProps = { + virtualApiKeys: VirtualApiKeyRow[]; + orgId: string; +}; + +export default function VirtualApiKeysTable({ + virtualApiKeys, + orgId +}: VirtualApiKeysTableProps) { + const router = useRouter(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isViewSecretOpen, setIsViewSecretOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [selectedKey, setSelectedKey] = useState( + null + ); + const [rows, setRows] = useState(virtualApiKeys); + const [isRefreshing, setIsRefreshing] = useState(false); + + const [userFilterOpen, setUserFilterOpen] = useState(false); + const [resourceFilterOpen, setResourceFilterOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedResource, setSelectedResource] = + useState(null); + const [unassignedOnly, setUnassignedOnly] = useState(false); + + useEffect(() => { + setRows(virtualApiKeys); + }, [virtualApiKeys]); + + const filteredRows = useMemo(() => { + return rows.filter((row) => { + if (unassignedOnly && row.userId) { + return false; + } + if (selectedUser && row.userId !== selectedUser.id) { + return false; + } + if (selectedResource) { + if ( + !row.allResources && + !row.resourceIds.includes(selectedResource.resourceId) + ) { + return false; + } + } + return true; + }); + }, [rows, selectedUser, selectedResource, unassignedOnly]); + + const refreshData = async () => { + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + async function deleteKey(id: string) { + await api.delete(`/virtual-api-key/${id}`).catch((e) => { + toast({ + title: t("virtualApiKeysErrorDelete"), + description: formatAxiosError( + e, + t("virtualApiKeysErrorDeleteMessage") + ) + }); + throw e; + }); + + setRows((prev) => prev.filter((r) => r.virtualApiKeyId !== id)); + + toast({ + title: t("virtualApiKeysDeleted"), + description: t("virtualApiKeysDeletedDescription") + }); + } + + const clearUserFilter = () => { + setSelectedUser(null); + setUnassignedOnly(false); + setUserFilterOpen(false); + }; + + const clearResourceFilter = () => { + setSelectedResource(null); + setResourceFilterOpen(false); + }; + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("virtualApiKeysName"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => row.original.name || "-" + }, + { + id: "resources", + accessorFn: (row) => row.resourceNames, + friendlyName: t("resource"), + header: () => ( + + + + + + { + setSelectedResource(resource); + setResourceFilterOpen(false); + }} + /> + + + ), + cell: ({ row }) => { + const r = row.original; + if (r.allResources) { + return t("virtualApiKeysAllResources"); + } + if (r.resources.length === 0) { + return {t("virtualApiKeysNoResources")}; + } + if (r.resources.length === 1) { + const resource = r.resources[0]; + if (!resource.niceId) { + return resource.name; + } + return ( + + + + ); + } + return r.resourceNames; + } + }, + { + accessorKey: "userId", + friendlyName: t("user"), + header: () => ( + + + + + + { + setSelectedUser(null); + setUnassignedOnly(true); + setUserFilterOpen(false); + } + }} + onSelectUser={(user) => { + setSelectedUser(user); + setUnassignedOnly(false); + setUserFilterOpen(false); + }} + /> + + + ), + cell: ({ row }) => { + const r = row.original; + if (!r.userId) { + return -; + } + return ( + + + + ); + } + }, + { + accessorKey: "lastChars", + friendlyName: t("virtualApiKeysSecret"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => + `vk-${row.original.virtualApiKeyId}.••••${row.original.lastChars}` + }, + { + accessorKey: "createdAt", + friendlyName: t("created"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => moment(row.original.createdAt).format("lll") + }, + { + accessorKey: "expiresAt", + friendlyName: t("expires"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const expiresAt = row.original.expiresAt; + if (expiresAt) { + return moment(expiresAt).format("lll"); + } + return t("never"); + } + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const keyRow = row.original; + return ( +
+ + + + + + { + setSelectedKey(keyRow); + setIsViewSecretOpen(true); + }} + > + {t("virtualApiKeysViewSecret")} + + { + setSelectedKey(keyRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + +
+ ); + } + } + ]; + + return ( + <> + {selectedKey && ( + { + setIsDeleteModalOpen(val); + if (!val) setSelectedKey(null); + }} + dialog={ +
+

{t("virtualApiKeysQuestionRemove")}

+

{t("virtualApiKeysMessageRemove")}

+
+ } + buttonText={t("virtualApiKeysDeleteConfirm")} + onConfirm={async () => + deleteKey(selectedKey.virtualApiKeyId) + } + string={selectedKey.name || selectedKey.virtualApiKeyId} + title={t("virtualApiKeysDelete")} + /> + )} + + { + setIsViewSecretOpen(val); + if (!val) setSelectedKey(null); + }} + virtualApiKeyId={selectedKey?.virtualApiKeyId ?? null} + name={selectedKey?.name} + /> + + { + setRows([val, ...rows]); + }} + /> + + { + setIsEditModalOpen(val); + if (!val) setSelectedKey(null); + }} + virtualApiKey={selectedKey} + onUpdated={(val) => { + setRows((prev) => + prev.map((row) => + row.virtualApiKeyId === val.virtualApiKeyId + ? val + : row + ) + ); + }} + /> + + { + setIsCreateModalOpen(true); + }} + onRefresh={refreshData} + isRefreshing={isRefreshing} + /> + + ); +} diff --git a/src/components/multi-resource-selector.tsx b/src/components/multi-resource-selector.tsx new file mode 100644 index 000000000..dc2685fef --- /dev/null +++ b/src/components/multi-resource-selector.tsx @@ -0,0 +1,142 @@ +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "./ui/command"; +import { Checkbox } from "./ui/checkbox"; +import { useTranslations } from "next-intl"; +import { useDebounce } from "use-debounce"; +import { type SelectedResource } from "./resource-selector"; + +export type MultiResourcesSelectorProps = { + orgId: string; + selectedResources: SelectedResource[]; + onSelectionChange: (resources: SelectedResource[]) => void; + excludeWildcard?: boolean; + onClear?: () => void; + showClear?: boolean; + protocol?: string; +}; + +export function formatMultiResourcesSelectorLabel( + selectedResources: SelectedResource[], + t: (key: string, values?: { count: number }) => string, + emptyLabelKey = "selectResources" +): string { + if (selectedResources.length === 0) { + return t(emptyLabelKey); + } + if (selectedResources.length === 1) { + return selectedResources[0]!.name; + } + return t("multiResourcesSelectorResourcesCount", { + count: selectedResources.length + }); +} + +export function MultiResourcesSelector({ + orgId, + selectedResources, + onSelectionChange, + excludeWildcard = false, + onClear, + showClear = false, + protocol +}: MultiResourcesSelectorProps) { + const t = useTranslations(); + const [resourceSearchQuery, setResourceSearchQuery] = useState(""); + const [debouncedQuery] = useDebounce(resourceSearchQuery, 150); + + const { data: resources = [] } = useQuery( + orgQueries.proxyResources({ + orgId, + query: debouncedQuery, + perPage: 10, + protocol + }) + ); + + const resourcesShown = useMemo(() => { + const base: SelectedResource[] = excludeWildcard + ? resources.filter((r) => !r.wildcard) + : [...resources]; + if ( + debouncedQuery.trim().length === 0 && + selectedResources.length > 0 + ) { + const selectedNotInBase = selectedResources.filter( + (sel) => + !base.some((r) => r.resourceId === sel.resourceId) && + !(excludeWildcard && sel.wildcard) + ); + return [...selectedNotInBase, ...base]; + } + return base; + }, [debouncedQuery, resources, selectedResources, excludeWildcard]); + + const selectedIds = useMemo( + () => new Set(selectedResources.map((r) => r.resourceId)), + [selectedResources] + ); + + const toggleResource = (resource: SelectedResource) => { + if (selectedIds.has(resource.resourceId)) { + onSelectionChange( + selectedResources.filter( + (r) => r.resourceId !== resource.resourceId + ) + ); + } else { + onSelectionChange([...selectedResources, resource]); + } + }; + + return ( + + setResourceSearchQuery(v)} + /> + + {t("resourcesNotFound")} + + {showClear && onClear && ( + + {t("accessFilterClear")} + + )} + {resourcesShown.map((resource) => ( + { + toggleResource(resource); + }} + > + {}} + aria-hidden + tabIndex={-1} + /> + + {resource.name} + + + ))} + + + + ); +} diff --git a/src/components/resource-selector.tsx b/src/components/resource-selector.tsx index 625bb2d64..5dad2bf0e 100644 --- a/src/components/resource-selector.tsx +++ b/src/components/resource-selector.tsx @@ -25,13 +25,19 @@ export type ResourceSelectorProps = { selectedResource?: SelectedResource | null; onSelectResource: (resource: SelectedResource) => void; excludeWildcard?: boolean; + showClear?: boolean; + onClear?: () => void; + protocol?: string; }; export function ResourceSelector({ orgId, selectedResource, onSelectResource, - excludeWildcard = false + excludeWildcard = false, + showClear = false, + onClear, + protocol }: ResourceSelectorProps) { const t = useTranslations(); const [resourceSearchQuery, setResourceSearchQuery] = useState(""); @@ -42,7 +48,8 @@ export function ResourceSelector({ orgQueries.proxyResources({ orgId: orgId, query: debouncedSearchQuery, - perPage: 10 + perPage: 10, + protocol }) ); @@ -75,6 +82,14 @@ export function ResourceSelector({ {t("resourcesNotFound")} + {showClear && onClear && ( + + {t("accessFilterClear")} + + )} {resourcesShown.map((r) => ( void; allowClear?: boolean; + showClear?: boolean; + onClear?: () => void; + unassignedOption?: { + label: string; + selected: boolean; + onSelect: () => void; + }; }; export function UserSelector({ orgId, selectedUser, onSelectUser, - allowClear = true + allowClear = true, + showClear = false, + onClear, + unassignedOption }: UserSelectorProps) { const t = useTranslations(); const [userSearchQuery, setUserSearchQuery] = useState(""); const [debouncedValue] = useDebounce(userSearchQuery, 150); const { data: users = [] } = useQuery( - orgQueries.users({ orgId, perPage: 10, query: debouncedValue }) + orgQueries.users({ orgId, perPage: 10, term: debouncedValue }) ); const usersShown = useMemo(() => { @@ -64,6 +74,14 @@ export function UserSelector({ {t("usersNotFound")} + {showClear && onClear && ( + + {t("accessFilterClear")} + + )} {allowClear && ( )} + {unassignedOption && ( + + + {unassignedOption.label} + + )} {usersShown.map((user) => ( queryOptions({ queryKey: [ "ORG", orgId, "PROXY_RESOURCES", - { query, perPage } + { query, perPage, protocol } ] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams({ @@ -404,6 +406,10 @@ export const orgQueries = { sp.set("query", query); } + if (protocol) { + sp.set("protocol", protocol); + } + const res = await meta!.api.get< AxiosResponse >(`/org/${orgId}/resources?${sp.toString()}`, { signal }); From 02e4fe8b48fdbe2872266a717f87ce0b9929436e Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 11 Aug 2026 09:40:55 -0400 Subject: [PATCH 280/494] Show the provided domains when changing properly --- src/components/DomainPicker.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index e06c3e57d..bf25fcfce 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -53,7 +53,7 @@ import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@/hooks/usePaidStatus"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { toUnicode } from "punycode"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useUserContext } from "@app/hooks/useUserContext"; type AvailableOption = { @@ -166,8 +166,19 @@ export default function DomainPicker({ const [selectedProvidedDomain, setSelectedProvidedDomain] = useState(null); + // Only run the initial base-domain selection once the domains have + // loaded. This must not re-run on later `defaultDomainId`/`defaultSubdomain` + // changes, because selecting a provided (namespace) domain calls + // onDomainChange(null), which the parent form echoes back as + // defaultDomainId/defaultSubdomain becoming undefined — re-running this + // effect on that change would immediately snap the selector back to the + // organization domain, making provided domains unselectable whenever one + // was already set. + const didSelectInitialDomainRef = useRef(false); + useEffect(() => { - if (!loadingDomains) { + if (!loadingDomains && !didSelectInitialDomainRef.current) { + didSelectInitialDomainRef.current = true; let domainOptionToSelect: DomainOption | null = null; if (organizationDomains.length > 0) { // Select the first organization domain or the one provided from props From 732fd4eba16e267f0fcf266221991d47ffb7f8da Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 11 Aug 2026 10:46:22 -0400 Subject: [PATCH 281/494] allow retrieving api key secret --- messages/en-US.json | 4 +- src/components/CreateVirtualApiKeyForm.tsx | 2 +- src/components/EditVirtualApiKeyForm.tsx | 63 +++++++++++++++-- src/components/VirtualApiKeysTable.tsx | 64 ++++++++++++++++- src/components/ui/data-table.tsx | 82 ++++++++++++---------- 5 files changed, 165 insertions(+), 50 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 5dbbc09f7..abb6bf405 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1658,7 +1658,7 @@ "virtualApiKeysName": "Name", "virtualApiKeysDescriptionOptional": "Description (optional)", "virtualApiKeysAssociateUserOptional": "Associate User (optional)", - "virtualApiKeysAssociateUserDescription": "Attribution only. Does not grant access by itself.", + "virtualApiKeysAssociateUserDescription": "Associate this key with a user to track usage.", "virtualApiKeysAllResources": "All public inference resources", "virtualApiKeysAllResourcesDescription": "Allow this key to access every public inference resource in the organization", "virtualApiKeysSelectResources": "Public Inference Resources", @@ -1666,7 +1666,7 @@ "virtualApiKeysSelectResourcesDescription": "Choose which public inference resources this key can access", "virtualApiKeysNoResources": "No resources", "virtualApiKeysSecret": "Key", - "virtualApiKeysSeeOnce": "Copy this key now. You can also view it again later from the table.", + "virtualApiKeysCopyKey": "Copy this key. You can view it again later from the table or when editing.", "virtualApiKeysSecretHint": "Use this value as a Bearer token: vk-[id].[secret]", "virtualApiKeysViewSecret": "View Secret", "virtualApiKeysViewSecretTitle": "Virtual API Key Secret", diff --git a/src/components/CreateVirtualApiKeyForm.tsx b/src/components/CreateVirtualApiKeyForm.tsx index 32a3877aa..5a3e88ce8 100644 --- a/src/components/CreateVirtualApiKeyForm.tsx +++ b/src/components/CreateVirtualApiKeyForm.tsx @@ -391,7 +391,7 @@ export default function CreateVirtualApiKeyForm({ )} {credential && (
-

{t("virtualApiKeysSeeOnce")}

+

{t("virtualApiKeysCopyKey")}

([]); + const [credential, setCredential] = useState(null); + const [credentialLoading, setCredentialLoading] = useState(false); const formSchema = z .object({ @@ -132,6 +136,55 @@ export default function EditVirtualApiKeyForm({ form.reset({ allResources: virtualApiKey.allResources }); + + let cancelled = false; + setCredentialLoading(true); + setCredential(null); + + api.get>( + `/virtual-api-key/${virtualApiKey.virtualApiKeyId}` + ) + .then((res) => { + if (cancelled) { + return; + } + const secret = res.data.data.virtualApiKey.secret; + if (secret) { + setCredential( + `vk-${virtualApiKey.virtualApiKeyId}.${secret}` + ); + } else { + toast({ + variant: "destructive", + title: t("virtualApiKeysErrorFetchSecret"), + description: t( + "virtualApiKeysErrorFetchSecretDescription" + ) + }); + } + }) + .catch((e) => { + if (cancelled) { + return; + } + toast({ + variant: "destructive", + title: t("virtualApiKeysErrorFetchSecret"), + description: formatAxiosError( + e, + t("virtualApiKeysErrorFetchSecretDescription") + ) + }); + }) + .finally(() => { + if (!cancelled) { + setCredentialLoading(false); + } + }); + + return () => { + cancelled = true; + }; }, [open, virtualApiKey, form]); async function onSubmit(values: z.infer) { @@ -228,11 +281,11 @@ export default function EditVirtualApiKeyForm({ id="edit-virtual-api-key-form" >
- + + - - - {addActions.map((action, i) => ( - action.onSelect()}> - {action.label} - - ))} - - - ) : onAdd && addButtonText ? ( - - ) : null; + const addAction = + addActions && addActions.length > 0 && addButtonText ? ( + + + + + + {addActions.map((action, i) => ( + action.onSelect()} + > + {action.label} + + ))} + + + ) : onAdd && addButtonText ? ( + + ) : null; return (
@@ -674,7 +685,9 @@ export function DataTable({
- ) - : undefined + showAddActionInEmptyState ? ( +
+ {addAction} +
+ ) : undefined } /> )} From 2187f23588b57c3b206e62125f2074d4f7bce829 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 11 Aug 2026 11:53:39 -0400 Subject: [PATCH 282/494] add page to retrieve user virtual api keys --- messages/en-US.json | 19 +- server/routers/external.ts | 12 + .../virtualApiKey/getMyVirtualApiKey.ts | 113 +++++++ server/routers/virtualApiKey/index.ts | 2 + .../virtualApiKey/listMyVirtualApiKeys.ts | 219 ++++++++++++++ server/routers/virtualApiKey/types.ts | 19 +- src/app/[orgId]/keys/page.tsx | 113 +++++++ .../resource/[resourceGuid]/keys/page.tsx | 121 ++++++++ src/components/UserVirtualApiKeys.tsx | 277 ++++++++++++++++++ 9 files changed, 891 insertions(+), 4 deletions(-) create mode 100644 server/routers/virtualApiKey/getMyVirtualApiKey.ts create mode 100644 server/routers/virtualApiKey/listMyVirtualApiKeys.ts create mode 100644 src/app/[orgId]/keys/page.tsx create mode 100644 src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx create mode 100644 src/components/UserVirtualApiKeys.tsx diff --git a/messages/en-US.json b/messages/en-US.json index abb6bf405..7943138d9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1667,7 +1667,6 @@ "virtualApiKeysNoResources": "No resources", "virtualApiKeysSecret": "Key", "virtualApiKeysCopyKey": "Copy this key. You can view it again later from the table or when editing.", - "virtualApiKeysSecretHint": "Use this value as a Bearer token: vk-[id].[secret]", "virtualApiKeysViewSecret": "View Secret", "virtualApiKeysViewSecretTitle": "Virtual API Key Secret", "virtualApiKeysViewSecretDescription": "This secret grants access to the public inference resources assigned to this key", @@ -1692,6 +1691,24 @@ "virtualApiKeysErrorFetchSecret": "Error loading secret", "virtualApiKeysErrorFetchSecretDescription": "Failed to load the virtual API key secret", "virtualApiKeysFilterUnassigned": "Unassigned", + "myVirtualApiKeysTitle": "Your API Keys", + "myVirtualApiKeysDescription": "View your identity key and any virtual API keys attributed to you in this organization", + "myVirtualApiKeysResourceTitle": "Your API Keys for This Resource", + "myVirtualApiKeysResourceDescription": "View your identity key and virtual API keys attributed to you that can access this resource", + "myVirtualApiKeysIdentityTitle": "Identity Key", + "myVirtualApiKeysIdentityHeadline": "Your Personal API Key", + "myVirtualApiKeysIdentityDescription": "Your personal key for this organization. It is unique to your account and used to identify you when calling AI Gateway resources.", + "myVirtualApiKeysIdentityResourceHeadline": "Your Personal API Key for This Resource", + "myVirtualApiKeysIdentityResourceDescription": "Your personal key for this organization. Use it to call this AI Gateway resource.", + "myVirtualApiKeysManualTitle": "Attributed Keys", + "myVirtualApiKeysManualDescription": "Manual virtual API keys an admin associated with your account", + "myVirtualApiKeysManualResourceDescription": "Manual virtual API keys associated with your account that can access this resource", + "myVirtualApiKeysManualEmpty": "No attributed keys yet", + "myVirtualApiKeysKindUser": "Identity", + "myVirtualApiKeysKindManual": "Manual", + "myVirtualApiKeysUnnamed": "Unnamed key", + "myVirtualApiKeysRevealSecret": "Reveal Secret", + "myVirtualApiKeysViewSecretDescription": "This secret authenticates you to AI Gateway resources", "aiProvidersTitle": "AI Providers", "aiProvidersDescription": "Connect model providers for AI workloads in this organization", "aiProvidersAdd": "Add Provider", diff --git a/server/routers/external.ts b/server/routers/external.ts index 1eb32ec0f..141660f0f 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1650,6 +1650,18 @@ authenticated.get( virtualApiKey.listVirtualApiKeys ); +authenticated.get( + "/org/:orgId/my-virtual-api-keys", + verifyOrgAccess, + virtualApiKey.listMyVirtualApiKeys +); + +authenticated.get( + "/org/:orgId/my-virtual-api-keys/:virtualApiKeyId", + verifyOrgAccess, + virtualApiKey.getMyVirtualApiKey +); + authenticated.get( "/virtual-api-key/:virtualApiKeyId", verifyVirtualApiKeyAccess, diff --git a/server/routers/virtualApiKey/getMyVirtualApiKey.ts b/server/routers/virtualApiKey/getMyVirtualApiKey.ts new file mode 100644 index 000000000..2c8887f55 --- /dev/null +++ b/server/routers/virtualApiKey/getMyVirtualApiKey.ts @@ -0,0 +1,113 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, virtualApiKeyResources, virtualApiKeys } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import { toPublicVirtualApiKey } from "@server/lib/virtualApiKey"; +import type { GetMyVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + virtualApiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/my-virtual-api-keys/{virtualApiKeyId}", + description: + "Get a virtual API key owned by the signed-in user, including the decrypted secret.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function getMyVirtualApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, virtualApiKeyId } = parsedParams.data; + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const [key] = await db + .select() + .from(virtualApiKeys) + .where( + and( + eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId), + eq(virtualApiKeys.orgId, orgId), + eq(virtualApiKeys.userId, userId) + ) + ) + .limit(1); + + if (!key) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Virtual API key with ID ${virtualApiKeyId} not found` + ) + ); + } + + const resourceRows = await db + .select({ resourceId: virtualApiKeyResources.resourceId }) + .from(virtualApiKeyResources) + .where(eq(virtualApiKeyResources.virtualApiKeyId, virtualApiKeyId)); + + return response(res, { + data: { + virtualApiKey: { + ...toPublicVirtualApiKey(key, { includeSecret: true }), + resourceIds: resourceRows.map((row) => row.resourceId) + } + }, + success: true, + error: false, + message: "Virtual API key retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/index.ts b/server/routers/virtualApiKey/index.ts index b80fba765..2cb06a5ca 100644 --- a/server/routers/virtualApiKey/index.ts +++ b/server/routers/virtualApiKey/index.ts @@ -1,6 +1,8 @@ export * from "./createVirtualApiKey"; export * from "./listVirtualApiKeys"; +export * from "./listMyVirtualApiKeys"; export * from "./getVirtualApiKey"; +export * from "./getMyVirtualApiKey"; export * from "./updateVirtualApiKey"; export * from "./deleteVirtualApiKey"; export * from "./types"; diff --git a/server/routers/virtualApiKey/listMyVirtualApiKeys.ts b/server/routers/virtualApiKey/listMyVirtualApiKeys.ts new file mode 100644 index 000000000..244377ef1 --- /dev/null +++ b/server/routers/virtualApiKey/listMyVirtualApiKeys.ts @@ -0,0 +1,219 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + resources, + virtualApiKeyResources, + virtualApiKeys, + type VirtualApiKey +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, asc, eq, exists, inArray, or } from "drizzle-orm"; +import { + getOrCreateUserVirtualApiKey, + toPublicVirtualApiKey +} from "@server/lib/virtualApiKey"; +import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const querySchema = z.object({ + resourceGuid: z.string().nonempty().optional() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/my-virtual-api-keys", + description: + "List the signed-in user's identity virtual API key and manual keys attributed to them.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema, + query: querySchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +async function resourceIdsForKeys( + keyIds: string[] +): Promise> { + const resourceIdsByKey = new Map(); + if (keyIds.length === 0) { + return resourceIdsByKey; + } + + const resourceRows = await db + .select() + .from(virtualApiKeyResources) + .where(inArray(virtualApiKeyResources.virtualApiKeyId, keyIds)); + + for (const row of resourceRows) { + const existing = resourceIdsByKey.get(row.virtualApiKeyId) ?? []; + existing.push(row.resourceId); + resourceIdsByKey.set(row.virtualApiKeyId, existing); + } + + return resourceIdsByKey; +} + +function toKeyWithResources( + row: VirtualApiKey, + resourceIdsByKey: Map +) { + return { + ...toPublicVirtualApiKey(row), + resourceIds: resourceIdsByKey.get(row.virtualApiKeyId) ?? [] + }; +} + +export async function listMyVirtualApiKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { resourceGuid } = parsedQuery.data; + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + let resourceId: number | undefined; + if (resourceGuid) { + const [resource] = await db + .select({ + resourceId: resources.resourceId + }) + .from(resources) + .where( + and( + eq(resources.resourceGuid, resourceGuid), + eq(resources.orgId, orgId) + ) + ) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with GUID ${resourceGuid} not found` + ) + ); + } + + resourceId = resource.resourceId; + } + + const { key: userKeyRow } = await getOrCreateUserVirtualApiKey({ + orgId, + userId, + createdByUserId: userId + }); + + const manualConditions = [ + eq(virtualApiKeys.orgId, orgId), + eq(virtualApiKeys.userId, userId), + eq(virtualApiKeys.kind, "manual") + ]; + + if (resourceId !== undefined) { + manualConditions.push( + or( + eq(virtualApiKeys.allResources, true), + exists( + db + .select() + .from(virtualApiKeyResources) + .where( + and( + eq( + virtualApiKeyResources.virtualApiKeyId, + virtualApiKeys.virtualApiKeyId + ), + eq( + virtualApiKeyResources.resourceId, + resourceId + ) + ) + ) + ) + )! + ); + } + + const manualRows = await db + .select() + .from(virtualApiKeys) + .where(and(...manualConditions)) + .orderBy(asc(virtualApiKeys.name), asc(virtualApiKeys.createdAt)); + + const allKeyIds = [ + userKeyRow.virtualApiKeyId, + ...manualRows.map((row) => row.virtualApiKeyId) + ]; + const resourceIdsByKey = await resourceIdsForKeys(allKeyIds); + + return response(res, { + data: { + userKey: toKeyWithResources(userKeyRow, resourceIdsByKey), + manualKeys: manualRows.map((row) => + toKeyWithResources(row, resourceIdsByKey) + ) + }, + success: true, + error: false, + message: "Virtual API keys retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/types.ts b/server/routers/virtualApiKey/types.ts index 28823577f..73e475632 100644 --- a/server/routers/virtualApiKey/types.ts +++ b/server/routers/virtualApiKey/types.ts @@ -3,14 +3,27 @@ import type { PaginatedResponse } from "@server/types/Pagination"; export type { PublicVirtualApiKey }; +export type VirtualApiKeyWithResources = PublicVirtualApiKey & { + resourceIds: number[]; +}; + export type ListVirtualApiKeysResponse = PaginatedResponse<{ - virtualApiKeys: (PublicVirtualApiKey & { resourceIds: number[] })[]; + virtualApiKeys: VirtualApiKeyWithResources[]; }>; export type GetVirtualApiKeyResponse = { - virtualApiKey: PublicVirtualApiKey & { resourceIds: number[] }; + virtualApiKey: VirtualApiKeyWithResources; }; export type CreateOrEditVirtualApiKeyResponse = { - virtualApiKey: PublicVirtualApiKey & { resourceIds: number[] }; + virtualApiKey: VirtualApiKeyWithResources; +}; + +export type ListMyVirtualApiKeysResponse = { + userKey: VirtualApiKeyWithResources; + manualKeys: VirtualApiKeyWithResources[]; +}; + +export type GetMyVirtualApiKeyResponse = { + virtualApiKey: VirtualApiKeyWithResources; }; diff --git a/src/app/[orgId]/keys/page.tsx b/src/app/[orgId]/keys/page.tsx new file mode 100644 index 000000000..007ed2869 --- /dev/null +++ b/src/app/[orgId]/keys/page.tsx @@ -0,0 +1,113 @@ +import { Layout } from "@app/components/Layout"; +import UserVirtualApiKeys from "@app/components/UserVirtualApiKeys"; +import { commandBarNavSections } from "@app/app/navigation"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { verifySession } from "@app/lib/auth/verifySession"; +import { pullEnv } from "@app/lib/pullEnv"; +import UserProvider from "@app/providers/UserProvider"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; +import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types"; +import { AxiosResponse } from "axios"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { + title: t("myVirtualApiKeysTitle") + }; +} + +type KeysPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function KeysPage(props: KeysPageProps) { + const params = await props.params; + const orgId = params.orgId; + + if (!orgId) { + redirect(`/`); + } + + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect("/"); + } + + const cookieHeader = await authCookieHeader(); + + let overview: GetOrgOverviewResponse | undefined; + try { + const res = await internal.get>( + `/org/${orgId}/overview`, + cookieHeader + ); + overview = res.data.data; + } catch { + // leave undefined + } + + let orgs: ListUserOrgsResponse["orgs"] = []; + try { + const getOrgs = cache(async () => + internal.get>( + `/user/${user.userId}/orgs`, + cookieHeader + ) + ); + const res = await getOrgs(); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch { + // leave empty + } + + if (!orgs.some((org) => org.orgId === orgId)) { + redirect("/"); + } + + let keysData: ListMyVirtualApiKeysResponse | null = null; + try { + const res = await internal.get< + AxiosResponse + >(`/org/${orgId}/my-virtual-api-keys`, cookieHeader); + keysData = res.data.data; + } catch { + redirect(`/${orgId}`); + } + + if (!keysData) { + redirect(`/${orgId}`); + } + + const env = pullEnv(); + const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg; + const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner); + + return ( + + + + + + ); +} diff --git a/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx b/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx new file mode 100644 index 000000000..e85bff9fb --- /dev/null +++ b/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx @@ -0,0 +1,121 @@ +import { Layout } from "@app/components/Layout"; +import UserVirtualApiKeys from "@app/components/UserVirtualApiKeys"; +import { commandBarNavSections } from "@app/app/navigation"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { verifySession } from "@app/lib/auth/verifySession"; +import { pullEnv } from "@app/lib/pullEnv"; +import UserProvider from "@app/providers/UserProvider"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; +import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types"; +import { AxiosResponse } from "axios"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { + title: t("myVirtualApiKeysResourceTitle") + }; +} + +type ResourceKeysPageProps = { + params: Promise<{ orgId: string; resourceGuid: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ResourceKeysPage(props: ResourceKeysPageProps) { + const params = await props.params; + const orgId = params.orgId; + const resourceGuid = params.resourceGuid; + + if (!orgId || !resourceGuid) { + redirect(`/`); + } + + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect("/"); + } + + const cookieHeader = await authCookieHeader(); + + let overview: GetOrgOverviewResponse | undefined; + try { + const res = await internal.get>( + `/org/${orgId}/overview`, + cookieHeader + ); + overview = res.data.data; + } catch { + // leave undefined + } + + let orgs: ListUserOrgsResponse["orgs"] = []; + try { + const getOrgs = cache(async () => + internal.get>( + `/user/${user.userId}/orgs`, + cookieHeader + ) + ); + const res = await getOrgs(); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch { + // leave empty + } + + if (!orgs.some((org) => org.orgId === orgId)) { + redirect("/"); + } + + let keysData: ListMyVirtualApiKeysResponse | null = null; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${orgId}/my-virtual-api-keys?resourceGuid=${encodeURIComponent(resourceGuid)}`, + cookieHeader + ); + keysData = res.data.data; + } catch { + redirect(`/${orgId}/keys`); + } + + if (!keysData) { + redirect(`/${orgId}/keys`); + } + + const env = pullEnv(); + const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg; + const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner); + + return ( + + + + + + ); +} diff --git a/src/components/UserVirtualApiKeys.tsx b/src/components/UserVirtualApiKeys.tsx new file mode 100644 index 000000000..5513b59e4 --- /dev/null +++ b/src/components/UserVirtualApiKeys.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { AxiosResponse } from "axios"; +import moment from "moment"; +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import CopyTextBox from "@app/components/CopyTextBox"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle as SectionTitle +} from "@app/components/Settings"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import type { + GetMyVirtualApiKeyResponse, + ListMyVirtualApiKeysResponse, + VirtualApiKeyWithResources +} from "@server/routers/virtualApiKey/types"; + +type UserVirtualApiKeysProps = { + orgId: string; + resourceGuid?: string; + initialData: ListMyVirtualApiKeysResponse; +}; + +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 OwnedKeySecret({ + 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 IdentityKeyCenterpiece({ + orgId, + virtualApiKeyId, + lastChars, + resourceGuid +}: { + orgId: string; + virtualApiKeyId: string; + lastChars: string; + resourceGuid?: string; +}) { + const t = useTranslations(); + const preview = keyPreview(virtualApiKeyId, lastChars); + const { credential, loading, revealSecret } = useRevealSecret( + orgId, + virtualApiKeyId + ); + const displayValue = credential ?? preview; + const headline = resourceGuid + ? t("myVirtualApiKeysIdentityResourceHeadline") + : t("myVirtualApiKeysIdentityHeadline"); + const description = resourceGuid + ? t("myVirtualApiKeysIdentityResourceDescription") + : t("myVirtualApiKeysIdentityDescription"); + + return ( +
+

+ {headline} +

+

+ {description} +

+
+
+ +
+ {!credential ? ( +
+ +
+ ) : null} +
+
+ ); +} + +function ManualKeyRow({ + orgId, + keyRow +}: { + orgId: string; + keyRow: VirtualApiKeyWithResources; +}) { + const t = useTranslations(); + + return ( +
+
+
+

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

+
+ {keyRow.description ? ( +

+ {keyRow.description} +

+ ) : null} +
+ +
+

+ {t("created")} {moment(keyRow.createdAt).format("lll")} +

+
+
+ ); +} + +export default function UserVirtualApiKeys({ + orgId, + resourceGuid, + initialData +}: UserVirtualApiKeysProps) { + const t = useTranslations(); + + const title = resourceGuid + ? t("myVirtualApiKeysResourceTitle") + : t("myVirtualApiKeysTitle"); + const description = resourceGuid + ? t("myVirtualApiKeysResourceDescription") + : t("myVirtualApiKeysDescription"); + + return ( + <> + + + + {initialData.manualKeys.length > 0 ? ( + + + + {t("myVirtualApiKeysManualTitle")} + + + {resourceGuid + ? t( + "myVirtualApiKeysManualResourceDescription" + ) + : t("myVirtualApiKeysManualDescription")} + + + + + {initialData.manualKeys.map((keyRow) => ( + + + + ))} + + + + ) : null} + + + ); +} From b33e776072018b3bfa2c57ba1c7862e058793c53 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 11 Aug 2026 12:09:11 -0400 Subject: [PATCH 283/494] add virtual keys link in header on launcher --- messages/en-US.json | 2 + src/app/[orgId]/keys/page.tsx | 1 + .../resource/[resourceGuid]/keys/page.tsx | 1 + src/app/navigation.tsx | 5 ++ src/components/Layout.tsx | 14 ++++- src/components/LayoutHeader.tsx | 44 +++++++++++++- src/components/LayoutMobileMenu.tsx | 59 +++++++++++++++++-- .../useCommandPaletteActions.tsx | 6 ++ 8 files changed, 123 insertions(+), 9 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 7943138d9..b2594efcb 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1646,6 +1646,8 @@ "sidebarAiProviders": "Providers", "commandAiProviders": "AI Providers", "sidebarVirtualApiKeys": "Virtual API Keys", + "sidebarMyApiKeys": "Your API Keys", + "sidebarAccount": "Launcher", "commandVirtualApiKeys": "Virtual API Keys", "virtualApiKeysTitle": "Manage Virtual API Keys", "virtualApiKeysDescription": "Create and manage manual API keys for AI Gateway access to public inference resources", diff --git a/src/app/[orgId]/keys/page.tsx b/src/app/[orgId]/keys/page.tsx index 007ed2869..e8341dd29 100644 --- a/src/app/[orgId]/keys/page.tsx +++ b/src/app/[orgId]/keys/page.tsx @@ -104,6 +104,7 @@ export default async function KeysPage(props: KeysPageProps) { isPrimaryOrg: primaryOrg })} showSidebar={false} + launcherMode showViewAsAdmin={isAdminOrOwner} > diff --git a/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx b/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx index e85bff9fb..29e11eb92 100644 --- a/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx +++ b/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx @@ -108,6 +108,7 @@ export default async function ResourceKeysPage(props: ResourceKeysPageProps) { isPrimaryOrg: primaryOrg })} showSidebar={false} + launcherMode showViewAsAdmin={isAdminOrOwner} > + }, + { + title: "sidebarMyApiKeys", + href: "/{orgId}/keys", + icon: } ]; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 085abbfac..7adc2b5a0 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,10 +1,12 @@ import React from "react"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; -import type { - CommandBarNavSection, - SidebarNavSection +import { + orgLangingNavItems, + type CommandBarNavSection, + type SidebarNavSection } from "@app/app/navigation"; +import type { SidebarNavItem } from "@app/components/SidebarNav"; import { LayoutSidebar } from "@app/components/LayoutSidebar"; import { LayoutHeader } from "@app/components/LayoutHeader"; import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu"; @@ -46,6 +48,10 @@ export async function Layout({ sidebarStateCookie === "collapsed" || (sidebarStateCookie !== "expanded" && defaultSidebarCollapsed); + const launcherNavItems: SidebarNavItem[] = launcherMode + ? orgLangingNavItems + : []; + return ( )} diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index 2440d2494..ac265ff08 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import ProfileIcon from "@app/components/ProfileIcon"; import ThemeSwitcher from "@app/components/ThemeSwitcher"; import { useTheme } from "next-themes"; @@ -13,6 +14,8 @@ import { LauncherOrgSelector } from "@app/components/resource-launcher/LauncherO import { Button } from "@app/components/ui/button"; import { useTranslations } from "next-intl"; import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger"; +import type { SidebarNavItem } from "@app/components/SidebarNav"; +import { cn } from "@app/lib/cn"; type LayoutHeaderProps = { showTopBar: boolean; @@ -20,6 +23,7 @@ type LayoutHeaderProps = { orgId?: string; orgs?: ListUserOrgsResponse["orgs"]; showViewAsAdmin?: boolean; + launcherNavItems?: SidebarNavItem[]; }; export function LayoutHeader({ @@ -27,13 +31,15 @@ export function LayoutHeader({ launcherMode = false, orgId, orgs, - showViewAsAdmin = false + showViewAsAdmin = false, + launcherNavItems = [] }: LayoutHeaderProps) { const { theme } = useTheme(); const [path, setPath] = useState(""); const { env } = useEnvContext(); const { isUnlocked } = useLicenseStatusContext(); const t = useTranslations(); + const pathname = usePathname(); const logoWidth = isUnlocked() ? env.branding.logo?.navbar?.width || 98 @@ -85,6 +91,42 @@ export function LayoutHeader({ orgId={orgId} orgs={orgs} /> + {orgId + ? launcherNavItems + .filter((item) => item.href) + .map((item) => { + const href = + item.href!.replace( + "{orgId}", + orgId + ); + const isActive = + href === `/${orgId}` + ? pathname === href + : pathname === href || + pathname?.startsWith( + `${href}/` + ); + + return ( + + ); + }) + : null} {showViewAsAdmin && orgId ? (
- {showViewAsAdmin && orgId ? ( -
+
+ {orgId + ? launcherNavItems + .filter( + (item) => + item.href + ) + .map((item) => { + const href = + item.href!.replace( + "{orgId}", + orgId + ); + return ( +
+ + setIsMobileMenuOpen( + false + ) + } + > + {item.icon ? ( + + { + item.icon + } + + ) : null} + + {t( + item.title + )} + + +
+ ); + }) + : null} + {showViewAsAdmin && orgId ? (
-
- ) : null} + ) : null} +
) : ( <> diff --git a/src/components/command-palette/useCommandPaletteActions.tsx b/src/components/command-palette/useCommandPaletteActions.tsx index da44e5d2a..87e102307 100644 --- a/src/components/command-palette/useCommandPaletteActions.tsx +++ b/src/components/command-palette/useCommandPaletteActions.tsx @@ -75,6 +75,12 @@ export function useCommandPaletteActions( }); } } else if (orgId) { + actions.push({ + id: "my-api-keys", + label: t("sidebarMyApiKeys"), + icon: , + href: `/${orgId}/keys` + }); actions.push({ id: "create-site", label: t("commandPaletteCreateSite"), From b60390d805eb91f26d3129931dfcd438ccdb1217 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 11 Aug 2026 11:10:30 -0400 Subject: [PATCH 284/494] batch up usage records for insert --- server/cleanup.ts | 2 + server/lib/aiBudgetEnforcement.ts | 106 +++++++++++++++++++++++++++++- server/private/cleanup.ts | 2 + 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/server/cleanup.ts b/server/cleanup.ts index 10e9f4cc3..3e7a335b4 100644 --- a/server/cleanup.ts +++ b/server/cleanup.ts @@ -3,12 +3,14 @@ import { flushConnectionLogToDb } from "#dynamic/routers/newt"; import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth"; import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator"; import { cleanup as wsCleanup } from "#dynamic/routers/ws"; +import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement"; async function cleanup() { await stopPingAccumulator(); await flushBandwidthToDb(); await flushConnectionLogToDb(); await flushSiteBandwidthToDb(); + await shutdownUsageRecorder(); await wsCleanup(); process.exit(0); diff --git a/server/lib/aiBudgetEnforcement.ts b/server/lib/aiBudgetEnforcement.ts index 2a516695d..4177af9b7 100644 --- a/server/lib/aiBudgetEnforcement.ts +++ b/server/lib/aiBudgetEnforcement.ts @@ -1,4 +1,4 @@ -import { and, eq, gte, inArray, isNull, or, sql, SQL } from "drizzle-orm"; +import { and, eq, gte, inArray, isNull, or, sql, SQL, type InferInsertModel } from "drizzle-orm"; import { AiBudget, aiBudgetBreachEvents, @@ -430,6 +430,91 @@ export type UsageRecordInput = { createdAt?: number; }; +type AiUsageRecordInsert = InferInsertModel; + +// In-memory buffer for batching AI usage record inserts, mirroring the +// approach in server/routers/badger/logRequestAudit.ts. Usage rows are read +// back on every budget-cache miss (see getBudgetUsage above), which happens +// at least every BUDGET_CACHE_REFRESH_MS, so this buffer is flushed much +// more aggressively than the request audit log to keep the table from +// lagging behind what budget enforcement needs. Unlike the audit log, there +// is no retention/cleanup job for this table - usage history is kept +// indefinitely for billing and historical reporting. +const usageRecordBuffer: AiUsageRecordInsert[] = []; + +const USAGE_BATCH_SIZE = 20; // Write to DB every 20 records +const USAGE_BATCH_INTERVAL_MS = 1000; // Or every 1 second, whichever comes first +const USAGE_MAX_BUFFER_SIZE = 5000; // Prevent unbounded memory growth +let usageFlushTimer: NodeJS.Timeout | null = null; +let isUsageFlushInProgress = false; + +async function flushUsageRecords() { + if (usageRecordBuffer.length === 0 || isUsageFlushInProgress) { + return; + } + + isUsageFlushInProgress = true; + + const recordsToWrite = usageRecordBuffer.splice(0, usageRecordBuffer.length); + + try { + // Use a transaction to ensure all inserts succeed or fail together + await db.transaction(async (tx) => { + // Batch insert in groups to avoid overwhelming the database + const DB_BATCH_SIZE = 25; + for (let i = 0; i < recordsToWrite.length; i += DB_BATCH_SIZE) { + const batch = recordsToWrite.slice(i, i + DB_BATCH_SIZE); + await tx.insert(aiUsageRecords).values(batch); + } + }); + logger.debug(`Flushed ${recordsToWrite.length} AI usage records to database`); + } catch (error) { + logger.error("Error flushing AI usage records:", error); + // On transaction error, put records back at the front of the buffer + // to retry, but only if the buffer isn't too large + if (usageRecordBuffer.length < USAGE_MAX_BUFFER_SIZE - recordsToWrite.length) { + usageRecordBuffer.unshift(...recordsToWrite); + logger.info(`Re-queued ${recordsToWrite.length} AI usage records for retry`); + } else { + logger.error(`Buffer full, dropped ${recordsToWrite.length} AI usage records`); + } + } finally { + isUsageFlushInProgress = false; + // If buffer filled up while we were flushing, flush again + if (usageRecordBuffer.length >= USAGE_BATCH_SIZE) { + flushUsageRecords().catch((err) => + logger.error("Error in follow-up AI usage flush:", err) + ); + } + } +} + +function scheduleUsageFlush() { + if (usageFlushTimer === null) { + usageFlushTimer = setTimeout(() => { + usageFlushTimer = null; + flushUsageRecords().catch((err) => + logger.error("Error in scheduled AI usage flush:", err) + ); + }, USAGE_BATCH_INTERVAL_MS); + } +} + +/** + * Gracefully flush all pending AI usage records (call this on shutdown). + */ +export async function shutdownUsageRecorder() { + if (usageFlushTimer) { + clearTimeout(usageFlushTimer); + usageFlushTimer = null; + } + // Force flush even if one is in progress by waiting and retrying + while (isUsageFlushInProgress) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + await flushUsageRecords(); +} + export async function recordUsage(input: UsageRecordInput): Promise { try { const { usage } = input; @@ -440,7 +525,15 @@ export async function recordUsage(input: UsageRecordInput): Promise { usage.completionTokens + usage.reasoningTokens; - await db.insert(aiUsageRecords).values({ + // Prevent unbounded buffer growth - drop oldest entries if buffer is too large + if (usageRecordBuffer.length >= USAGE_MAX_BUFFER_SIZE) { + const dropped = usageRecordBuffer.splice(0, USAGE_BATCH_SIZE); + logger.warn( + `AI usage record buffer exceeded max size (${USAGE_MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries` + ); + } + + usageRecordBuffer.push({ orgId: input.orgId, providerId: input.providerId, resourceId: input.resourceId, @@ -457,6 +550,15 @@ export async function recordUsage(input: UsageRecordInput): Promise { estimated: usage.estimated, createdAt: input.createdAt ?? Date.now() }); + + // Flush immediately if buffer is full, otherwise schedule a flush + if (usageRecordBuffer.length >= USAGE_BATCH_SIZE) { + flushUsageRecords().catch((err) => + logger.error("Error flushing AI usage records:", err) + ); + } else { + scheduleUsageFlush(); + } } catch (error) { logger.error("Failed to record AI usage", { error }); } diff --git a/server/private/cleanup.ts b/server/private/cleanup.ts index 6c934cb0b..a1ad24976 100644 --- a/server/private/cleanup.ts +++ b/server/private/cleanup.ts @@ -18,12 +18,14 @@ import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthM import { flushConnectionLogToDb } from "#private/routers/newt"; import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth"; import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator"; +import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement"; async function cleanup() { await stopPingAccumulator(); await flushBandwidthToDb(); await flushConnectionLogToDb(); await flushSiteBandwidthToDb(); + await shutdownUsageRecorder(); await rateLimitService.cleanup(); await wsCleanup(); await logStreamingManager.shutdown(); From 68a84181d9c0801009db7cf367720451561849e2 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 11 Aug 2026 12:11:21 -0400 Subject: [PATCH 285/494] remove excess logging --- server/lib/cache.ts | 10 +++++----- server/private/lib/cache.ts | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 0113099e1..4e2b7c782 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -48,11 +48,11 @@ class AdaptiveCache { async get(key: string): Promise { // Use local cache as fallback or primary const value = localCache.get(key); - if (value !== undefined) { - logger.debug(`Cache hit in local cache: ${key}`); - } else { - logger.debug(`Cache miss in local cache: ${key}`); - } + // if (value !== undefined) { + // logger.debug(`Cache hit in local cache: ${key}`); + // } else { + // logger.debug(`Cache miss in local cache: ${key}`); + // } return value; } diff --git a/server/private/lib/cache.ts b/server/private/lib/cache.ts index 9c94109c2..fe7b771c9 100644 --- a/server/private/lib/cache.ts +++ b/server/private/lib/cache.ts @@ -111,11 +111,11 @@ class AdaptiveCache { // Use local cache as fallback or primary const value = localCache.get(key); - if (value !== undefined) { - logger.debug(`Cache hit in local cache: ${key}`); - } else { - logger.debug(`Cache miss in local cache: ${key}`); - } + // if (value !== undefined) { + // logger.debug(`Cache hit in local cache: ${key}`); + // } else { + // logger.debug(`Cache miss in local cache: ${key}`); + // } return value; } @@ -367,11 +367,11 @@ class RegionalAdaptiveCache { } const value = regionalLocalCache.get(key); - if (value !== undefined) { - logger.debug(`[regional] Cache hit in local cache: ${key}`); - } else { - logger.debug(`[regional] Cache miss in local cache: ${key}`); - } + // if (value !== undefined) { + // logger.debug(`[regional] Cache hit in local cache: ${key}`); + // } else { + // logger.debug(`[regional] Cache miss in local cache: ${key}`); + // } return value; } From 48c4b44f7289be80827225da92da0bb0f518222d Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 11 Aug 2026 12:19:17 -0400 Subject: [PATCH 286/494] log chat sessions to database --- messages/en-US.json | 2 + server/cleanup.ts | 2 + server/db/pg/schema/schema.ts | 70 ++++++ server/db/sqlite/schema/schema.ts | 74 ++++++ server/lib/cleanupLogs.ts | 21 +- server/private/cleanup.ts | 2 + .../routers/billing/featureLifecycle.ts | 11 + server/routers/aiGateway/logAiSession.ts | 238 ++++++++++++++++++ server/routers/aiGateway/pipeline.ts | 142 +++++------ .../aiGateway/streamAiGatewayResponse.ts | 86 +++++++ server/routers/aiGateway/targetRouting.ts | 88 ++++--- server/routers/org/updateOrg.ts | 20 ++ .../settings/general/security/page.tsx | 109 +++++++- 13 files changed, 749 insertions(+), 116 deletions(-) create mode 100644 server/routers/aiGateway/logAiSession.ts create mode 100644 server/routers/aiGateway/streamAiGatewayResponse.ts diff --git a/messages/en-US.json b/messages/en-US.json index b2594efcb..c7db842a7 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3397,6 +3397,8 @@ "logRetentionActionDescription": "How long to retain action logs", "logRetentionConnectionLabel": "Network Log Retention", "logRetentionConnectionDescription": "How long to retain connection logs", + "logRetentionAISessionsLabel": "AI Gateway Session Log Retention", + "logRetentionAISessionsDescription": "How long to retain AI gateway prompt/response session logs", "logRetentionDisabled": "Disabled", "logRetention3Days": "3 days", "logRetention7Days": "7 days", diff --git a/server/cleanup.ts b/server/cleanup.ts index 3e7a335b4..3ff4ccf19 100644 --- a/server/cleanup.ts +++ b/server/cleanup.ts @@ -4,6 +4,7 @@ import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth" import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator"; import { cleanup as wsCleanup } from "#dynamic/routers/ws"; import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement"; +import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession"; async function cleanup() { await stopPingAccumulator(); @@ -11,6 +12,7 @@ async function cleanup() { await flushConnectionLogToDb(); await flushSiteBandwidthToDb(); await shutdownUsageRecorder(); + await shutdownAiSessionLogger(); await wsCleanup(); process.exit(0); diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 39dfb889e..7895b0baf 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -65,6 +65,11 @@ export const orgs = pgTable("orgs", { ) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), + settingsLogRetentionDaysAISessions: integer( + "settingsLogRetentionDaysAISessions" + ) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year + .notNull() + .default(0), sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format) isBillingOrg: boolean("isBillingOrg"), @@ -1899,6 +1904,70 @@ export const aiBudgetBreachEvents = pgTable( ] ); +// Logs the aggregated prompt + response for a single AI gateway request, for +// session replay. One row per request (not per streaming chunk). `sessionId` +// is a fresh random id per row for now - no cross-request correlation yet, +// but the column exists so a future pass can link multiple rows into a real +// multi-turn session. +export const aiSessionLog = pgTable( + "aiSessionLog", + { + id: serial("id").primaryKey(), + sessionId: varchar("sessionId").notNull(), + orgId: varchar("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + providerId: integer("providerId") + .notNull() + .references(() => aiProviders.providerId, { onDelete: "cascade" }), + capability: varchar("capability").notNull(), + resourceId: integer("resourceId").references( + () => resources.resourceId, + { onDelete: "cascade" } + ), + siteResourceId: integer("siteResourceId").references( + () => siteResources.siteResourceId, + { onDelete: "cascade" } + ), + userId: varchar("userId").references(() => users.userId, { + onDelete: "set null" + }), + requestedModel: varchar("requestedModel"), + isStream: boolean("isStream").notNull().default(false), + requestBody: text("requestBody"), + responseBody: text("responseBody"), + // True if requestBody/responseBody were cut short at + // AI_SESSION_LOG_MAX_BODY_CHARS before storage. + truncated: boolean("truncated").notNull().default(false), + statusCode: integer("statusCode"), + createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch ms + }, + (t) => [ + index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt), + index("idx_ai_session_log_org_provider_created").on( + t.orgId, + t.providerId, + t.createdAt + ), + index("idx_ai_session_log_org_resource_created").on( + t.orgId, + t.resourceId, + t.createdAt + ), + index("idx_ai_session_log_org_site_resource_created").on( + t.orgId, + t.siteResourceId, + t.createdAt + ), + index("idx_ai_session_log_org_user_created").on( + t.orgId, + t.userId, + t.createdAt + ), + index("idx_ai_session_log_session").on(t.sessionId) + ] +); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -1992,6 +2061,7 @@ export type AiModel = InferSelectModel; export type AiBudget = InferSelectModel; export type AiUsageRecord = InferSelectModel; export type AiBudgetBreachEvent = InferSelectModel; +export type AiSessionLog = InferSelectModel; export type ResourceAiProvider = InferSelectModel; export type SiteResourceAiProvider = InferSelectModel< typeof siteResourceAiProviders diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 1c2236dfe..3fb15c0d6 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -64,6 +64,11 @@ export const orgs = sqliteTable("orgs", { ) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), + settingsLogRetentionDaysAISessions: integer( + "settingsLogRetentionDaysAISessions" + ) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year + .notNull() + .default(0), sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format) isBillingOrg: integer("isBillingOrg", { mode: "boolean" }), @@ -1889,6 +1894,74 @@ export const aiBudgetBreachEvents = sqliteTable( ] ); +// Logs the aggregated prompt + response for a single AI gateway request, for +// session replay. One row per request (not per streaming chunk). `sessionId` +// is a fresh random id per row for now - no cross-request correlation yet, +// but the column exists so a future pass can link multiple rows into a real +// multi-turn session. +export const aiSessionLog = sqliteTable( + "aiSessionLog", + { + id: integer("id").primaryKey({ autoIncrement: true }), + sessionId: text("sessionId").notNull(), + orgId: text("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + providerId: integer("providerId") + .notNull() + .references(() => aiProviders.providerId, { onDelete: "cascade" }), + capability: text("capability").notNull(), + resourceId: integer("resourceId").references( + () => resources.resourceId, + { onDelete: "cascade" } + ), + siteResourceId: integer("siteResourceId").references( + () => siteResources.siteResourceId, + { onDelete: "cascade" } + ), + userId: text("userId").references(() => users.userId, { + onDelete: "set null" + }), + requestedModel: text("requestedModel"), + isStream: integer("isStream", { mode: "boolean" }) + .notNull() + .default(false), + requestBody: text("requestBody"), + responseBody: text("responseBody"), + // True if requestBody/responseBody were cut short at + // AI_SESSION_LOG_MAX_BODY_CHARS before storage. + truncated: integer("truncated", { mode: "boolean" }) + .notNull() + .default(false), + statusCode: integer("statusCode"), + createdAt: integer("createdAt").notNull() // epoch ms + }, + (t) => [ + index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt), + index("idx_ai_session_log_org_provider_created").on( + t.orgId, + t.providerId, + t.createdAt + ), + index("idx_ai_session_log_org_resource_created").on( + t.orgId, + t.resourceId, + t.createdAt + ), + index("idx_ai_session_log_org_site_resource_created").on( + t.orgId, + t.siteResourceId, + t.createdAt + ), + index("idx_ai_session_log_org_user_created").on( + t.orgId, + t.userId, + t.createdAt + ), + index("idx_ai_session_log_session").on(t.sessionId) + ] +); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -1980,6 +2053,7 @@ export type AiModel = InferSelectModel; export type AiBudget = InferSelectModel; export type AiUsageRecord = InferSelectModel; export type AiBudgetBreachEvent = InferSelectModel; +export type AiSessionLog = InferSelectModel; export type ResourceAiProvider = InferSelectModel; export type SiteResourceAiProvider = InferSelectModel< typeof siteResourceAiProviders diff --git a/server/lib/cleanupLogs.ts b/server/lib/cleanupLogs.ts index f5b6d8b2f..ae2e0cdb7 100644 --- a/server/lib/cleanupLogs.ts +++ b/server/lib/cleanupLogs.ts @@ -3,12 +3,14 @@ import { cleanUpOldLogs as cleanUpOldAccessLogs } from "#dynamic/lib/logAccessAu import { cleanUpOldLogs as cleanUpOldActionLogs } from "#dynamic/middlewares/logActionAudit"; import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit"; import { cleanUpOldLogs as cleanUpOldConnectionLogs } from "#dynamic/routers/newt"; +import { cleanUpOldLogs as cleanUpOldAiSessionLogs } from "@server/routers/aiGateway/logAiSession"; import { gt, or } from "drizzle-orm"; import { cleanUpOldFingerprintSnapshots } from "@server/routers/olm/fingerprintingUtils"; import { build } from "@server/build"; export function initLogCleanupInterval() { - if (build == "saas") { // skip log cleanup for saas builds + if (build == "saas") { + // skip log cleanup for saas builds return null; } return setInterval( @@ -23,7 +25,9 @@ export function initLogCleanupInterval() { settingsLogRetentionDaysRequest: orgs.settingsLogRetentionDaysRequest, settingsLogRetentionDaysConnection: - orgs.settingsLogRetentionDaysConnection + orgs.settingsLogRetentionDaysConnection, + settingsLogRetentionDaysAISessions: + orgs.settingsLogRetentionDaysAISessions }) .from(orgs) .where( @@ -31,7 +35,8 @@ export function initLogCleanupInterval() { gt(orgs.settingsLogRetentionDaysAction, 0), gt(orgs.settingsLogRetentionDaysAccess, 0), gt(orgs.settingsLogRetentionDaysRequest, 0), - gt(orgs.settingsLogRetentionDaysConnection, 0) + gt(orgs.settingsLogRetentionDaysConnection, 0), + gt(orgs.settingsLogRetentionDaysAISessions, 0) ) ); @@ -42,7 +47,8 @@ export function initLogCleanupInterval() { settingsLogRetentionDaysAction, settingsLogRetentionDaysAccess, settingsLogRetentionDaysRequest, - settingsLogRetentionDaysConnection + settingsLogRetentionDaysConnection, + settingsLogRetentionDaysAISessions } = org; if (settingsLogRetentionDaysAction > 0) { @@ -72,6 +78,13 @@ export function initLogCleanupInterval() { settingsLogRetentionDaysConnection ); } + + if (settingsLogRetentionDaysAISessions > 0) { + await cleanUpOldAiSessionLogs( + orgId, + settingsLogRetentionDaysAISessions + ); + } } await cleanUpOldFingerprintSnapshots(365); diff --git a/server/private/cleanup.ts b/server/private/cleanup.ts index a1ad24976..c949e4e0c 100644 --- a/server/private/cleanup.ts +++ b/server/private/cleanup.ts @@ -19,6 +19,7 @@ import { flushConnectionLogToDb } from "#private/routers/newt"; import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth"; import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator"; import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement"; +import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession"; async function cleanup() { await stopPingAccumulator(); @@ -26,6 +27,7 @@ async function cleanup() { await flushConnectionLogToDb(); await flushSiteBandwidthToDb(); await shutdownUsageRecorder(); + await shutdownAiSessionLogger(); await rateLimitService.cleanup(); await wsCleanup(); await logStreamingManager.shutdown(); diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index 75d11c756..84a7b4f5a 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -134,6 +134,17 @@ async function capRetentionDays( ); } + if ( + org.settingsLogRetentionDaysAISessions !== null && + org.settingsLogRetentionDaysAISessions > maxRetentionDays + ) { + updates.settingsLogRetentionDaysAISessions = maxRetentionDays; + needsUpdate = true; + logger.info( + `Capping AI session log retention from ${org.settingsLogRetentionDaysAISessions} to ${maxRetentionDays} days for org ${orgId}` + ); + } + // Apply updates if needed if (needsUpdate) { await db.update(orgs).set(updates).where(eq(orgs.orgId, orgId)); diff --git a/server/routers/aiGateway/logAiSession.ts b/server/routers/aiGateway/logAiSession.ts new file mode 100644 index 000000000..4c7384497 --- /dev/null +++ b/server/routers/aiGateway/logAiSession.ts @@ -0,0 +1,238 @@ +import { randomUUID } from "crypto"; +import { logsDb, db, orgs, aiSessionLog, type AiProvider } from "@server/db"; +import type { InferInsertModel } from "drizzle-orm"; +import logger from "@server/logger"; +import { and, eq, lt } from "drizzle-orm"; +import cache from "#dynamic/lib/cache"; +import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; +import { sanitizeString } from "@server/lib/sanitize"; +import type { AiCapability } from "@server/lib/aiCapabilities"; + +// Caps how much of the request/response body we keep per row, so a single +// huge multimodal payload can't blow up buffer memory or storage. +const AI_SESSION_LOG_MAX_BODY_CHARS = 200_000; + +type AiSessionLogInsert = InferInsertModel; + +// In-memory buffer for batching AI session log inserts, mirroring the +// approach in server/routers/badger/logRequestAudit.ts. +const sessionLogBuffer: AiSessionLogInsert[] = []; + +const BATCH_SIZE = 100; // Write to DB every 100 logs +const BATCH_INTERVAL_MS = 5000; // Or every 5 seconds, whichever comes first +const MAX_BUFFER_SIZE = 10000; // Prevent unbounded memory growth +let flushTimer: NodeJS.Timeout | null = null; +let isFlushInProgress = false; + +/** + * Flush buffered logs to database + */ +async function flushSessionLogs() { + if (sessionLogBuffer.length === 0 || isFlushInProgress) { + return; + } + + isFlushInProgress = true; + + // Take all current logs and clear buffer + const logsToWrite = sessionLogBuffer.splice(0, sessionLogBuffer.length); + + try { + // Use a transaction to ensure all inserts succeed or fail together + await logsDb.transaction(async (tx) => { + // Batch insert logs in groups of 25 to avoid overwhelming the database + const BATCH_DB_SIZE = 25; + for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) { + const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE); + await tx.insert(aiSessionLog).values(batch); + } + }); + logger.debug( + `Flushed ${logsToWrite.length} AI session logs to database` + ); + } catch (error) { + logger.error("Error flushing AI session logs:", error); + // On transaction error, put logs back at the front of the buffer to retry + // but only if buffer isn't too large + if (sessionLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) { + sessionLogBuffer.unshift(...logsToWrite); + logger.info( + `Re-queued ${logsToWrite.length} AI session logs for retry` + ); + } else { + logger.error( + `Buffer full, dropped ${logsToWrite.length} AI session logs` + ); + } + } finally { + isFlushInProgress = false; + // If buffer filled up while we were flushing, flush again + if (sessionLogBuffer.length >= BATCH_SIZE) { + flushSessionLogs().catch((err) => + logger.error("Error in follow-up AI session log flush:", err) + ); + } + } +} + +/** + * Schedule a flush if not already scheduled + */ +function scheduleFlush() { + if (flushTimer === null) { + flushTimer = setTimeout(() => { + flushTimer = null; + flushSessionLogs().catch((err) => + logger.error("Error in scheduled AI session log flush:", err) + ); + }, BATCH_INTERVAL_MS); + } +} + +/** + * Gracefully flush all pending logs (call this on shutdown) + */ +export async function shutdownAiSessionLogger() { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + // Force flush even if one is in progress by waiting and retrying + while (isFlushInProgress) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + await flushSessionLogs(); +} + +async function getRetentionDays(orgId: string): Promise { + // check cache first + const cached = await cache.get(`org_${orgId}_aiSessionsDays`); + if (cached !== undefined) { + return cached; + } + + const [org] = await db + .select({ + settingsLogRetentionDaysAISessions: + orgs.settingsLogRetentionDaysAISessions + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return 0; + } + + // store the result in cache + await cache.set( + `org_${orgId}_aiSessionsDays`, + org.settingsLogRetentionDaysAISessions, + 300 + ); + + return org.settingsLogRetentionDaysAISessions; +} + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + // calculateCutoffTimestamp returns a seconds-epoch cutoff (built for + // requestAuditLog.timestamp), but aiSessionLog.createdAt is ms-epoch to + // match aiUsageRecords - convert before comparing. + const cutoffTimestampMs = calculateCutoffTimestamp(retentionDays) * 1000; + + try { + await logsDb + .delete(aiSessionLog) + .where( + and( + lt(aiSessionLog.createdAt, cutoffTimestampMs), + eq(aiSessionLog.orgId, orgId) + ) + ); + } catch (error) { + logger.error("Error cleaning up old AI session logs:", error); + } +} + +function truncateBody(value: string): { value: string; truncated: boolean } { + if (value.length <= AI_SESSION_LOG_MAX_BODY_CHARS) { + return { value, truncated: false }; + } + return { + value: value.slice(0, AI_SESSION_LOG_MAX_BODY_CHARS), + truncated: true + }; +} + +export function logAiSession(data: { + capability: AiCapability; + provider: AiProvider; + requestedModel: string | undefined; + requestBody: unknown; + responseText: string; + isStream: boolean; + statusCode: number; + orgId: string | null; + resourceId: number | null; + siteResourceId: number | null; + requestUserId: string | null; +}): void { + (async () => { + try { + // Check retention before buffering any logs + if (data.orgId) { + const retentionDays = await getRetentionDays(data.orgId); + if (retentionDays === 0) { + // do not log + return; + } + } else { + // No org resolved for this request - nothing to govern + // retention with, so don't log it. + return; + } + + const requestBodyText = truncateBody( + JSON.stringify(data.requestBody ?? "") + ); + const responseBodyText = truncateBody(data.responseText ?? ""); + + // Prevent unbounded buffer growth - drop oldest entries if buffer is too large + if (sessionLogBuffer.length >= MAX_BUFFER_SIZE) { + const dropped = sessionLogBuffer.splice(0, BATCH_SIZE); + logger.warn( + `AI session log buffer exceeded max size (${MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries` + ); + } + + sessionLogBuffer.push({ + sessionId: randomUUID(), + orgId: sanitizeString(data.orgId), + providerId: data.provider.providerId, + capability: data.capability, + resourceId: data.resourceId ?? undefined, + siteResourceId: data.siteResourceId ?? undefined, + userId: sanitizeString(data.requestUserId ?? undefined), + requestedModel: sanitizeString(data.requestedModel), + isStream: data.isStream, + requestBody: sanitizeString(requestBodyText.value), + responseBody: sanitizeString(responseBodyText.value), + truncated: + requestBodyText.truncated || responseBodyText.truncated, + statusCode: data.statusCode, + createdAt: Date.now() + }); + + // Flush immediately if buffer is full, otherwise schedule a flush + if (sessionLogBuffer.length >= BATCH_SIZE) { + flushSessionLogs().catch((err) => + logger.error("Error flushing AI session logs:", err) + ); + } else { + scheduleFlush(); + } + } catch (error) { + logger.error("Failed to log AI session", { error }); + } + })(); +} diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 22274e049..9e1433832 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -63,10 +63,11 @@ import { isUsageEmpty, needsStreamUsageInjection, withStreamUsageOption, - stripInjectedUsageFrame, extractResponseModel, type AiUsage } from "@server/lib/aiUsageExtraction"; +import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse"; +import { logAiSession } from "@server/routers/aiGateway/logAiSession"; const EXIT_NODE_RANGES_CACHE_KEY = "aiGateway:exitNodeRanges"; const EXIT_NODE_RANGES_TTL_SEC = 6000; @@ -526,13 +527,20 @@ async function selectProvider( }; } -function logAiUsageAndCost(args: { +// Extracts usage/cost from a completed AI gateway request, records it for +// budget enforcement, and logs the aggregated prompt/response for session +// replay. Shared by both the direct-upstream path (below) and the +// "custom"/target routing-mode path (targetRouting.ts) so both get identical +// usage/cost tracking and session logging instead of only the direct-upstream +// path having it. +export function recordAiGatewayCompletion(args: { capability: AiCapability; provider: AiProvider; requestedModel: string | undefined; requestBody: unknown; responseText: string; isStream: boolean; + statusCode: number; headers: Headers; orgId: string | null; resourceId: number | null; @@ -547,6 +555,7 @@ function logAiUsageAndCost(args: { requestBody, responseText, isStream, + statusCode, headers, orgId, resourceId, @@ -608,6 +617,20 @@ function logAiUsageAndCost(args: { }); } } + + logAiSession({ + capability, + provider, + requestedModel, + requestBody, + responseText, + isStream, + statusCode, + orgId, + resourceId, + siteResourceId, + requestUserId + }); } export async function handleAiGatewayProxy( @@ -722,7 +745,14 @@ export async function handleAiGatewayProxy( res, provider, requestUser, - capability + capability, + { + orgId, + resourceId, + siteResourceId, + requestedModel, + budgets: appliedBudgets + } ); } @@ -843,84 +873,38 @@ export async function handleAiGatewayProxy( throw fetchError; } - const contentType = upstreamRes.headers.get("content-type") || ""; - const isStream = def.isStreaming(req, contentType); + const isStream = def.isStreaming( + req, + upstreamRes.headers.get("content-type") || "" + ); - res.status(upstreamRes.status); - res.setHeader("Content-Type", contentType || "application/json"); - - if (isStream && upstreamRes.body) { - res.flushHeaders(); - const reader = upstreamRes.body.getReader(); - const decoder = new TextDecoder(); - let fullText = ""; - // Frame-boundary buffer, only used when we need to filter the - // usage-only frame we injected out of what reaches the client. - let sseCarry = ""; - try { - while (!abortController.signal.aborted) { - const { done, value } = await reader.read(); - if (done) break; - const chunkText = decoder.decode(value, { stream: true }); - fullText += chunkText; - if (injectedUsageOurselves) { - sseCarry += chunkText; - const lastBoundary = sseCarry.lastIndexOf("\n\n"); - if (lastBoundary !== -1) { - const toEmit = sseCarry.slice(0, lastBoundary + 2); - sseCarry = sseCarry.slice(lastBoundary + 2); - res.write(stripInjectedUsageFrame(toEmit)); - } - } else { - res.write(value); - } - } - if (injectedUsageOurselves && sseCarry) { - res.write(stripInjectedUsageFrame(sseCarry)); - } - } finally { - await reader.cancel().catch(() => {}); - res.off("close", onClientClose); - } - if (!res.writableEnded) { - res.end(); - } - if (!abortController.signal.aborted) { - logAiUsageAndCost({ - capability, - provider, - requestedModel, - requestBody: req.body, - responseText: fullText, - isStream: true, - headers: upstreamRes.headers, - orgId, - resourceId, - siteResourceId, - requestUserId: requestUser?.userId ?? null, - budgets: appliedBudgets - }); - } - return; - } - - res.off("close", onClientClose); - const text = await upstreamRes.text(); - logAiUsageAndCost({ - capability, - provider, - requestedModel, - requestBody: req.body, - responseText: text, - isStream: false, - headers: upstreamRes.headers, - orgId, - resourceId, - siteResourceId, - requestUserId: requestUser?.userId ?? null, - budgets: appliedBudgets + const { fullText, aborted } = await streamAiGatewayResponse({ + res, + upstreamRes, + isStream, + injectedUsageOurselves, + abortController, + onClientClose }); - return res.send(text); + + if (!aborted) { + recordAiGatewayCompletion({ + capability, + provider, + requestedModel, + requestBody: outboundBody, + responseText: fullText, + isStream, + statusCode: upstreamRes.status, + headers: upstreamRes.headers, + orgId, + resourceId, + siteResourceId, + requestUserId: requestUser?.userId ?? null, + budgets: appliedBudgets + }); + } + return; } catch (error) { logger.error(error); return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ diff --git a/server/routers/aiGateway/streamAiGatewayResponse.ts b/server/routers/aiGateway/streamAiGatewayResponse.ts new file mode 100644 index 000000000..8cd369350 --- /dev/null +++ b/server/routers/aiGateway/streamAiGatewayResponse.ts @@ -0,0 +1,86 @@ +import { Response } from "express"; +import { stripInjectedUsageFrame } from "@server/lib/aiUsageExtraction"; + +/** + * Reads an upstream AI provider response, writes it through to the client + * (streaming or buffered), and returns the full response text once done, so + * the caller can extract usage/cost and log the completed session. Shared by + * both the direct-upstream path (pipeline.ts) and the "custom"/target + * routing-mode path (targetRouting.ts) so usage/cost tracking and session + * logging apply identically to both instead of each maintaining its own copy + * of this loop. + * + * Callers own fetching the upstream response and the AbortController/ + * `res.on("close", onClientClose)` wiring, since those differ meaningfully + * between the two transports (direct upstream fetch with TLS-skip support vs + * a plain fetch to gerbil) - only the "read the stream, write to the client, + * accumulate the full text" part is actually identical logic between them. + */ +export async function streamAiGatewayResponse(args: { + res: Response; + upstreamRes: globalThis.Response; + isStream: boolean; + // True when we injected stream_options.include_usage ourselves (the + // caller didn't ask for it) and need to strip the extra usage-only frame + // back out of what's forwarded to the client. + injectedUsageOurselves: boolean; + abortController: AbortController; + onClientClose: () => void; +}): Promise<{ fullText: string; aborted: boolean }> { + const { + res, + upstreamRes, + isStream, + injectedUsageOurselves, + abortController, + onClientClose + } = args; + + const contentType = upstreamRes.headers.get("content-type") || ""; + res.status(upstreamRes.status); + res.setHeader("Content-Type", contentType || "application/json"); + + if (isStream && upstreamRes.body) { + res.flushHeaders(); + const reader = upstreamRes.body.getReader(); + const decoder = new TextDecoder(); + let fullText = ""; + // Frame-boundary buffer, only used when we need to filter the + // usage-only frame we injected out of what reaches the client. + let sseCarry = ""; + try { + while (!abortController.signal.aborted) { + const { done, value } = await reader.read(); + if (done) break; + const chunkText = decoder.decode(value, { stream: true }); + fullText += chunkText; + if (injectedUsageOurselves) { + sseCarry += chunkText; + const lastBoundary = sseCarry.lastIndexOf("\n\n"); + if (lastBoundary !== -1) { + const toEmit = sseCarry.slice(0, lastBoundary + 2); + sseCarry = sseCarry.slice(lastBoundary + 2); + res.write(stripInjectedUsageFrame(toEmit)); + } + } else { + res.write(value); + } + } + if (injectedUsageOurselves && sseCarry) { + res.write(stripInjectedUsageFrame(sseCarry)); + } + } finally { + await reader.cancel().catch(() => {}); + res.off("close", onClientClose); + } + if (!res.writableEnded) { + res.end(); + } + return { fullText, aborted: abortController.signal.aborted }; + } + + res.off("close", onClientClose); + const text = await upstreamRes.text(); + res.send(text); + return { fullText: text, aborted: abortController.signal.aborted }; +} diff --git a/server/routers/aiGateway/targetRouting.ts b/server/routers/aiGateway/targetRouting.ts index a6d08d925..00ef39d12 100644 --- a/server/routers/aiGateway/targetRouting.ts +++ b/server/routers/aiGateway/targetRouting.ts @@ -1,6 +1,13 @@ import { Request, Response } from "express"; import { and, eq } from "drizzle-orm"; -import { AiProvider, db, exitNodes, sites, targets } from "@server/db"; +import { + AiBudget, + AiProvider, + db, + exitNodes, + sites, + targets +} from "@server/db"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { localCache } from "@server/lib/cache"; @@ -14,12 +21,18 @@ import { AI_CAPABILITY_DEFS, type AiCapability } from "@server/lib/aiCapabilities"; +import { + needsStreamUsageInjection, + withStreamUsageOption +} from "@server/lib/aiUsageExtraction"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { applyRequestUserHeaders, + recordAiGatewayCompletion, type RequestUser } from "@server/routers/aiGateway/pipeline"; +import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse"; // Short TTL: long enough to spare the DB on a burst of requests, short // enough that target/site changes (added, removed, exit node moved) show up @@ -157,7 +170,14 @@ export async function proxyAiGatewayToSiteTarget( res: Response, provider: AiProvider, requestUser: RequestUser | null, - capability: AiCapability + capability: AiCapability, + ctx: { + orgId: string | null; + resourceId: number | null; + siteResourceId: number | null; + requestedModel: string | undefined; + budgets: AiBudget[]; + } ): Promise { const providerTargets = await getProviderTargets(provider.providerId); if (providerTargets.length === 0) { @@ -205,7 +225,17 @@ export async function proxyAiGatewayToSiteTarget( headers[PANGOLIN_DEST_HEADER] = target.destination; headers[PANGOLIN_HOST_HEADER] = target.hostHeader; - const body = JSON.stringify(req.body); + // Same OpenAI stream_options.include_usage injection direct-upstream + // requests get (pipeline.ts) - needed here too now that target-routed + // requests get usage/cost tracking and session logging as well. + const injectedUsageOurselves = needsStreamUsageInjection( + capability, + req.body + ); + const outboundBody = injectedUsageOurselves + ? withStreamUsageOption(req.body) + : req.body; + const body = JSON.stringify(outboundBody); logger.debug("AI gateway target-routed request", { providerId: provider.providerId, @@ -214,7 +244,7 @@ export async function proxyAiGatewayToSiteTarget( hostHeader: target.hostHeader, url: gerbilUrl, headers, - body: req.body + body: outboundBody }); // Cancel the request to gerbil (which cascades to gerbil cancelling its @@ -259,35 +289,35 @@ export async function proxyAiGatewayToSiteTarget( return; } - const contentType = upstreamRes.headers.get("content-type") || ""; const isStream = AI_CAPABILITY_DEFS[capability].isStreaming( req, - contentType + upstreamRes.headers.get("content-type") || "" ); - res.status(upstreamRes.status); - res.setHeader("Content-Type", contentType || "application/json"); + const { fullText, aborted } = await streamAiGatewayResponse({ + res, + upstreamRes, + isStream, + injectedUsageOurselves, + abortController, + onClientClose + }); - if (isStream && upstreamRes.body) { - res.flushHeaders(); - const reader = upstreamRes.body.getReader(); - try { - while (!abortController.signal.aborted) { - const { done, value } = await reader.read(); - if (done) break; - res.write(value); - } - } finally { - await reader.cancel().catch(() => {}); - res.off("close", onClientClose); - } - if (!res.writableEnded) { - res.end(); - } - return; + if (!aborted) { + recordAiGatewayCompletion({ + capability, + provider, + requestedModel: ctx.requestedModel, + requestBody: outboundBody, + responseText: fullText, + isStream, + statusCode: upstreamRes.status, + headers: upstreamRes.headers, + orgId: ctx.orgId, + resourceId: ctx.resourceId, + siteResourceId: ctx.siteResourceId, + requestUserId: requestUser?.userId ?? null, + budgets: ctx.budgets + }); } - - res.off("close", onClientClose); - const text = await upstreamRes.text(); - res.send(text); } diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index f98bdec27..021448375 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -41,6 +41,10 @@ const updateOrgBodySchema = z .number() .min(build === "saas" ? 0 : -1) .optional(), + settingsLogRetentionDaysAISessions: z + .number() + .min(build === "saas" ? 0 : -1) + .optional(), settingsEnableGlobalNewtAutoUpdate: z.boolean().optional() }) .refine((data) => Object.keys(data).length > 0, { @@ -212,6 +216,19 @@ export async function updateOrg( ) ); } + if ( + parsedBody.data.settingsLogRetentionDaysAISessions !== + undefined && + parsedBody.data.settingsLogRetentionDaysAISessions > + maxRetentionDays + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription` + ) + ); + } } } @@ -230,6 +247,8 @@ export async function updateOrg( parsedBody.data.settingsLogRetentionDaysAction, settingsLogRetentionDaysConnection: parsedBody.data.settingsLogRetentionDaysConnection, + settingsLogRetentionDaysAISessions: + parsedBody.data.settingsLogRetentionDaysAISessions, settingsEnableGlobalNewtAutoUpdate: parsedBody.data.settingsEnableGlobalNewtAutoUpdate }) @@ -250,6 +269,7 @@ export async function updateOrg( await cache.del(`org_${orgId}_actionDays`); await cache.del(`org_${orgId}_accessDays`); await cache.del(`org_${orgId}_connectionDays`); + await cache.del(`org_${orgId}_aiSessionsDays`); return response(res, { data: updatedOrg[0], diff --git a/src/app/[orgId]/settings/general/security/page.tsx b/src/app/[orgId]/settings/general/security/page.tsx index 51afa6077..7f83711b5 100644 --- a/src/app/[orgId]/settings/general/security/page.tsx +++ b/src/app/[orgId]/settings/general/security/page.tsx @@ -80,7 +80,8 @@ const SecurityFormSchema = z.object({ settingsLogRetentionDaysRequest: z.number(), settingsLogRetentionDaysAccess: z.number(), settingsLogRetentionDaysAction: z.number(), - settingsLogRetentionDaysConnection: z.number() + settingsLogRetentionDaysConnection: z.number(), + settingsLogRetentionDaysAISessions: z.number() }); const LOG_RETENTION_OPTIONS = [ @@ -122,7 +123,8 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { settingsLogRetentionDaysRequest: true, settingsLogRetentionDaysAccess: true, settingsLogRetentionDaysAction: true, - settingsLogRetentionDaysConnection: true + settingsLogRetentionDaysConnection: true, + settingsLogRetentionDaysAISessions: true }) ), defaultValues: { @@ -133,7 +135,9 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { settingsLogRetentionDaysAction: org.settingsLogRetentionDaysAction ?? 15, settingsLogRetentionDaysConnection: - org.settingsLogRetentionDaysConnection ?? 15 + org.settingsLogRetentionDaysConnection ?? 15, + settingsLogRetentionDaysAISessions: + org.settingsLogRetentionDaysAISessions ?? 15 }, mode: "onChange" }); @@ -161,7 +165,9 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { settingsLogRetentionDaysAction: data.settingsLogRetentionDaysAction, settingsLogRetentionDaysConnection: - data.settingsLogRetentionDaysConnection + data.settingsLogRetentionDaysConnection, + settingsLogRetentionDaysAISessions: + data.settingsLogRetentionDaysAISessions } as any; // Update organization @@ -292,6 +298,101 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { )} /> + ( + + + {t("logRetentionAISessionsLabel")} + + + + + + + )} + /> + {!env.flags.disableEnterpriseFeatures && ( <> Date: Tue, 11 Aug 2026 14:05:58 -0400 Subject: [PATCH 287/494] quiet up some more logs --- server/lib/cache.ts | 18 +++++++++--------- server/private/lib/cache.ts | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 4e2b7c782..1aece08ac 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -10,12 +10,12 @@ export const localCache = new NodeCache({ }); // Log cache statistics periodically for monitoring -setInterval(() => { - const stats = localCache.getStats(); - logger.debug( - `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%` - ); -}, 300000); // Every 5 minutes +// setInterval(() => { +// const stats = localCache.getStats(); +// logger.debug( +// `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%` +// ); +// }, 300000); // Every 5 minutes /** * Adaptive cache that uses Redis when available in multi-node environments, @@ -34,9 +34,9 @@ class AdaptiveCache { // Use local cache as fallback or primary const success = localCache.set(key, value, effectiveTtl || 0); - if (success) { - logger.debug(`Set key in local cache: ${key}`); - } + // if (success) { + // logger.debug(`Set key in local cache: ${key}`); + // } return success; } diff --git a/server/private/lib/cache.ts b/server/private/lib/cache.ts index fe7b771c9..ad2710278 100644 --- a/server/private/lib/cache.ts +++ b/server/private/lib/cache.ts @@ -24,12 +24,12 @@ export const localCache = new NodeCache({ }); // Log cache statistics periodically for monitoring -setInterval(() => { - const stats = localCache.getStats(); - logger.debug( - `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%` - ); -}, 300000); // Every 5 minutes +// setInterval(() => { +// const stats = localCache.getStats(); +// logger.debug( +// `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%` +// ); +// }, 300000); // Every 5 minutes /** * Adaptive cache that uses Redis when available in multi-node environments, @@ -80,9 +80,9 @@ class AdaptiveCache { // Use local cache as fallback or primary const success = localCache.set(key, value, effectiveTtl || 0); - if (success) { - logger.debug(`Set key in local cache: ${key}`); - } + // if (success) { + // logger.debug(`Set key in local cache: ${key}`); + // } return success; } From e0a66e79bba032c4d5856beafd61abbae6a853e6 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 11 Aug 2026 14:07:33 -0400 Subject: [PATCH 288/494] Continue to quiet log messages --- server/private/lib/cache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/private/lib/cache.ts b/server/private/lib/cache.ts index ad2710278..7ddfeb554 100644 --- a/server/private/lib/cache.ts +++ b/server/private/lib/cache.ts @@ -344,7 +344,7 @@ class RegionalAdaptiveCache { } const success = regionalLocalCache.set(key, value, effectiveTtl || 0); - if (success) logger.debug(`[regional] Set key in local cache: ${key}`); + // if (success) logger.debug(`[regional] Set key in local cache: ${key}`); return success; } From 98f5e39a7f9a6d72ce36076c9e2749387d562dad Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 11 Aug 2026 14:41:07 -0400 Subject: [PATCH 289/494] 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; + } }) }; From bc2f291352c90f3f6051dad3ea477bf9509d7c33 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 11 Aug 2026 14:45:05 -0400 Subject: [PATCH 290/494] normalize the requests to also store in the log for viewing later --- server/db/pg/schema/schema.ts | 13 +- server/db/sqlite/schema/schema.ts | 13 +- server/lib/aiMessageNormalization.ts | 538 +++++++++++++++++++++++ server/lib/aiUsageExtraction.ts | 24 +- server/routers/aiGateway/logAiSession.ts | 35 +- 5 files changed, 611 insertions(+), 12 deletions(-) create mode 100644 server/lib/aiMessageNormalization.ts diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 7895b0baf..cbb1e60cb 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -69,7 +69,7 @@ export const orgs = pgTable("orgs", { "settingsLogRetentionDaysAISessions" ) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() - .default(0), + .default(7), sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format) isBillingOrg: boolean("isBillingOrg"), @@ -1936,8 +1936,15 @@ export const aiSessionLog = pgTable( isStream: boolean("isStream").notNull().default(false), requestBody: text("requestBody"), responseBody: text("responseBody"), - // True if requestBody/responseBody were cut short at - // AI_SESSION_LOG_MAX_BODY_CHARS before storage. + // Capability-agnostic message transcript (JSON-encoded + // NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts), + // computed at write time so search/display never need per-capability + // parsing logic. Null when normalization couldn't recognize the + // shape - callers fall back to requestBody/responseBody. + normalizedRequest: text("normalizedRequest"), + normalizedResponse: text("normalizedResponse"), + // True if any of the request/response (raw or normalized) fields + // were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage. truncated: boolean("truncated").notNull().default(false), statusCode: integer("statusCode"), createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch ms diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 3fb15c0d6..8934638d7 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -68,7 +68,7 @@ export const orgs = sqliteTable("orgs", { "settingsLogRetentionDaysAISessions" ) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() - .default(0), + .default(7), sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format) isBillingOrg: integer("isBillingOrg", { mode: "boolean" }), @@ -1928,8 +1928,15 @@ export const aiSessionLog = sqliteTable( .default(false), requestBody: text("requestBody"), responseBody: text("responseBody"), - // True if requestBody/responseBody were cut short at - // AI_SESSION_LOG_MAX_BODY_CHARS before storage. + // Capability-agnostic message transcript (JSON-encoded + // NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts), + // computed at write time so search/display never need per-capability + // parsing logic. Null when normalization couldn't recognize the + // shape - callers fall back to requestBody/responseBody. + normalizedRequest: text("normalizedRequest"), + normalizedResponse: text("normalizedResponse"), + // True if any of the request/response (raw or normalized) fields + // were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage. truncated: integer("truncated", { mode: "boolean" }) .notNull() .default(false), diff --git a/server/lib/aiMessageNormalization.ts b/server/lib/aiMessageNormalization.ts new file mode 100644 index 000000000..1153142e1 --- /dev/null +++ b/server/lib/aiMessageNormalization.ts @@ -0,0 +1,538 @@ +import type { AiCapability } from "@server/lib/aiCapabilities"; +import { sseDataFrames, tryParseJson } from "@server/lib/aiUsageExtraction"; +import logger from "@server/logger"; + +// Uniform, capability-agnostic representation of a chat message, used so +// the AI session log can be searched/displayed the same way regardless of +// which provider/capability produced it. Content is flattened to plain text +// - non-text parts (images, tool calls/results) are rendered as readable +// placeholders rather than preserved as structured data, which is enough for +// a transcript-style replay view without a per-capability renderer. +export type NormalizedRole = "system" | "user" | "assistant" | "tool"; + +export type NormalizedAiMessage = { + role: NormalizedRole; + content: string; +}; + +function normalizeRole(role: unknown): NormalizedRole { + if ( + role === "system" || + role === "user" || + role === "assistant" || + role === "tool" + ) { + return role; + } + if (role === "model") return "assistant"; // Gemini + if (role === "function") return "tool"; // OpenAI legacy function role + return "user"; +} + +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value ?? {}); + } catch { + return ""; + } +} + +/** + * Flattens one message "part"/"block" (OpenAI content parts, Anthropic + * content blocks, Gemini parts, Bedrock converse content blocks - they all + * follow the same rough shape) into readable text. + */ +function flattenContentPart(part: unknown): string { + if (typeof part === "string") return part; + if (part == null || typeof part !== "object") return ""; + const p = part as Record; + + if (typeof p.text === "string") return p.text; + + if ( + p.type === "image_url" || + p.type === "image" || + p.type === "input_image" || + p.type === "output_image" || + "inlineData" in p + ) { + return "[image]"; + } + + // Anthropic-style tool_use / tool_result blocks + if (p.type === "tool_use") { + const name = typeof p.name === "string" ? p.name : "tool"; + return `[tool_call: ${name}(${safeJsonStringify(p.input)})]`; + } + if (p.type === "tool_result") { + const content = p.content; + const text = + typeof content === "string" + ? content + : Array.isArray(content) + ? flattenContentParts(content) + : ""; + return `[tool_result: ${text}]`; + } + + // Gemini-style functionCall / functionResponse parts + if (p.functionCall && typeof p.functionCall === "object") { + const fc = p.functionCall as Record; + return `[tool_call: ${fc.name}(${safeJsonStringify(fc.args)})]`; + } + if (p.functionResponse && typeof p.functionResponse === "object") { + const fr = p.functionResponse as Record; + return `[tool_result: ${fr.name}(${safeJsonStringify(fr.response)})]`; + } + + // Bedrock converse-style toolUse / toolResult content blocks + if (p.toolUse && typeof p.toolUse === "object") { + const tu = p.toolUse as Record; + return `[tool_call: ${tu.name}(${safeJsonStringify(tu.input)})]`; + } + if (p.toolResult && typeof p.toolResult === "object") { + const tr = p.toolResult as Record; + const content = tr.content; + const text = Array.isArray(content) ? flattenContentParts(content) : ""; + return `[tool_result: ${text}]`; + } + + return ""; +} + +function flattenContentParts(parts: unknown[]): string { + return parts.map(flattenContentPart).join(""); +} + +function flattenContent(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) return flattenContentParts(content); + return ""; +} + +/** + * Best-effort scan for every `"text":"..."` JSON string value in raw text, + * concatenated in order. Fallback for streaming formats we can't fully parse + * as JSON/SSE (Gemini's array-JSON stream, Bedrock's binary event-stream + * framing) - same spirit as aiUsageExtraction's scanNumericFields. + */ +function scanTextFragments(text: string): string { + const out: string[] = []; + const re = /"text"\s*:\s*"((?:[^"\\]|\\.)*)"/g; + let match: RegExpExecArray | null; + while ((match = re.exec(text)) !== null) { + try { + out.push(JSON.parse(`"${match[1]}"`)); + } catch { + out.push(match[1]); + } + } + return out.join(""); +} + +// --------------------------------------------------------------------------- +// Request (input) normalizers - operate on the already-parsed outbound body. +// --------------------------------------------------------------------------- + +function normalizeOpenAiChatRequest(body: any): NormalizedAiMessage[] { + const messages = Array.isArray(body?.messages) ? body.messages : []; + return messages.map((m: any) => ({ + role: normalizeRole(m?.role), + content: flattenContent(m?.content) + })); +} + +function normalizeOpenAiResponsesRequest(body: any): NormalizedAiMessage[] { + const out: NormalizedAiMessage[] = []; + if (typeof body?.instructions === "string" && body.instructions) { + out.push({ role: "system", content: body.instructions }); + } + const input = body?.input; + if (typeof input === "string") { + out.push({ role: "user", content: input }); + } else if (Array.isArray(input)) { + for (const item of input) { + if (item?.role) { + out.push({ + role: normalizeRole(item.role), + content: flattenContent(item.content) + }); + } else if (typeof item?.type === "string") { + out.push({ role: "tool", content: `[${item.type}]` }); + } + } + } + return out; +} + +function normalizeAnthropicRequest(body: any): NormalizedAiMessage[] { + const out: NormalizedAiMessage[] = []; + if (body?.system) { + const sys = flattenContent(body.system); + if (sys) out.push({ role: "system", content: sys }); + } + const messages = Array.isArray(body?.messages) ? body.messages : []; + for (const m of messages) { + out.push({ + role: normalizeRole(m?.role), + content: flattenContent(m?.content) + }); + } + return out; +} + +function normalizeGeminiRequest(body: any): NormalizedAiMessage[] { + const out: NormalizedAiMessage[] = []; + const sysParts = body?.systemInstruction?.parts; + if (Array.isArray(sysParts)) { + const text = flattenContentParts(sysParts); + if (text) out.push({ role: "system", content: text }); + } + const contents = Array.isArray(body?.contents) ? body.contents : []; + for (const c of contents) { + out.push({ + role: normalizeRole(c?.role), + content: Array.isArray(c?.parts) ? flattenContentParts(c.parts) : "" + }); + } + return out; +} + +function normalizeBedrockConverseRequest(body: any): NormalizedAiMessage[] { + const out: NormalizedAiMessage[] = []; + if (Array.isArray(body?.system)) { + const text = flattenContentParts(body.system); + if (text) out.push({ role: "system", content: text }); + } + const messages = Array.isArray(body?.messages) ? body.messages : []; + for (const m of messages) { + out.push({ + role: normalizeRole(m?.role), + content: Array.isArray(m?.content) + ? flattenContentParts(m.content) + : "" + }); + } + return out; +} + +/** + * bedrock_model_invoke and google_raw_predict are passthroughs - the body + * shape depends entirely on the underlying model, not the capability. Try + * the two shapes we're most likely to see (Anthropic Claude, then plain + * OpenAI-style) and give up otherwise, same fallback spirit + * aiUsageExtraction.ts uses for these two capabilities' usage extraction. + */ +function normalizeBestEffortRequest(body: any): NormalizedAiMessage[] | null { + if (!Array.isArray(body?.messages)) return null; + const looksAnthropicShaped = body.messages.some((m: any) => + Array.isArray(m?.content) + ); + return looksAnthropicShaped + ? normalizeAnthropicRequest(body) + : normalizeOpenAiChatRequest(body); +} + +// --------------------------------------------------------------------------- +// Response (output) normalizers - operate on the raw response text, which +// may be a single JSON document (non-streaming) or provider-framed streaming +// text (SSE `data:` frames, a JSON-array stream, or binary event-stream +// framing with JSON payloads embedded in it). +// --------------------------------------------------------------------------- + +function normalizeOpenAiChatResponse( + text: string, + isStream: boolean +): NormalizedAiMessage[] | null { + if (isStream) { + let role: unknown = "assistant"; + let content = ""; + let found = false; + for (const frame of sseDataFrames(text)) { + const delta = tryParseJson(frame)?.choices?.[0]?.delta; + if (!delta) continue; + found = true; + if (typeof delta.role === "string") role = delta.role; + if (typeof delta.content === "string") content += delta.content; + } + return found ? [{ role: normalizeRole(role), content }] : null; + } + + const message = tryParseJson(text)?.choices?.[0]?.message; + if (!message) return null; + return [ + { + role: normalizeRole(message.role), + content: flattenContent(message.content) + } + ]; +} + +function extractOpenAiResponsesOutputText(response: any): string | null { + if (typeof response?.output_text === "string") return response.output_text; + const output = Array.isArray(response?.output) ? response.output : []; + const pieces: string[] = []; + for (const item of output) { + if (item?.type === "message" && Array.isArray(item.content)) { + pieces.push(flattenContentParts(item.content)); + } + } + return pieces.length > 0 ? pieces.join("") : null; +} + +function normalizeOpenAiResponsesResponse( + text: string, + isStream: boolean +): NormalizedAiMessage[] | null { + if (isStream) { + let content = ""; + let found = false; + for (const frame of sseDataFrames(text)) { + const parsed = tryParseJson(frame); + if (!parsed) continue; + if ( + parsed.type === "response.output_text.delta" && + typeof parsed.delta === "string" + ) { + content += parsed.delta; + found = true; + } else if ( + parsed.type === "response.completed" && + parsed.response + ) { + const outputText = extractOpenAiResponsesOutputText( + parsed.response + ); + if (outputText != null) { + content = outputText; + found = true; + } + } + } + return found ? [{ role: "assistant", content }] : null; + } + + const parsed = tryParseJson(text); + const outputText = extractOpenAiResponsesOutputText( + parsed?.response ?? parsed + ); + return outputText != null + ? [{ role: "assistant", content: outputText }] + : null; +} + +function normalizeAnthropicResponse( + text: string, + isStream: boolean +): NormalizedAiMessage[] | null { + if (isStream) { + let role: unknown = "assistant"; + let content = ""; + let found = false; + for (const frame of sseDataFrames(text)) { + const parsed = tryParseJson(frame); + if (!parsed) continue; + if (parsed.type === "message_start" && parsed.message?.role) { + role = parsed.message.role; + } + if ( + parsed.type === "content_block_start" && + parsed.content_block?.type === "tool_use" + ) { + const name = parsed.content_block.name ?? "tool"; + content += `[tool_call: ${name}]`; + found = true; + } + if ( + parsed.type === "content_block_delta" && + typeof parsed.delta?.text === "string" + ) { + content += parsed.delta.text; + found = true; + } + } + return found ? [{ role: normalizeRole(role), content }] : null; + } + + const parsed = tryParseJson(text); + if (!parsed || !Array.isArray(parsed.content)) return null; + return [ + { + role: normalizeRole(parsed.role ?? "assistant"), + content: flattenContentParts(parsed.content) + } + ]; +} + +function geminiCandidateParts(node: any): string { + const parts = node?.candidates?.[0]?.content?.parts; + return Array.isArray(parts) ? flattenContentParts(parts) : ""; +} + +function normalizeGeminiResponse( + text: string, + _isStream: boolean +): NormalizedAiMessage[] | null { + const frames = sseDataFrames(text); + let content = ""; + let role: unknown = "model"; + let found = false; + + if (frames.length > 0) { + for (const frame of frames) { + const parsed = tryParseJson(frame); + const piece = geminiCandidateParts(parsed); + if (piece) { + content += piece; + found = true; + } + const r = parsed?.candidates?.[0]?.content?.role; + if (r) role = r; + } + } else { + const parsed = tryParseJson(text); + if (Array.isArray(parsed)) { + for (const chunk of parsed) { + const piece = geminiCandidateParts(chunk); + if (piece) { + content += piece; + found = true; + } + const r = chunk?.candidates?.[0]?.content?.role; + if (r) role = r; + } + } else if (parsed) { + const piece = geminiCandidateParts(parsed); + if (piece) { + content = piece; + found = true; + } + const r = parsed?.candidates?.[0]?.content?.role; + if (r) role = r; + } + } + + if (!found) { + const scanned = scanTextFragments(text); + return scanned + ? [{ role: normalizeRole(role), content: scanned }] + : null; + } + return [{ role: normalizeRole(role), content }]; +} + +function normalizeBedrockConverseResponse( + text: string, + isStream: boolean +): NormalizedAiMessage[] | null { + if (!isStream) { + const message = tryParseJson(text)?.output?.message; + if (!message) return null; + return [ + { + role: normalizeRole(message.role ?? "assistant"), + content: Array.isArray(message.content) + ? flattenContentParts(message.content) + : "" + } + ]; + } + // converse-stream uses AWS's binary event-stream framing, but the JSON + // payload of each event survives intact inside it (same assumption + // aiUsageExtraction.ts makes for usage) - scan for the text pieces. + const scanned = scanTextFragments(text); + return scanned ? [{ role: "assistant", content: scanned }] : null; +} + +function normalizeBedrockModelInvokeResponse( + text: string, + isStream: boolean +): NormalizedAiMessage[] | null { + const anthropicStyle = normalizeAnthropicResponse(text, isStream); + if (anthropicStyle) return anthropicStyle; + const scanned = scanTextFragments(text); + return scanned ? [{ role: "assistant", content: scanned }] : null; +} + +function normalizeGoogleRawPredictResponse( + text: string, + isStream: boolean +): NormalizedAiMessage[] | null { + const anthropicStyle = normalizeAnthropicResponse(text, isStream); + if (anthropicStyle) return anthropicStyle; + const scanned = scanTextFragments(text); + return scanned ? [{ role: "assistant", content: scanned }] : null; +} + +const REQUEST_NORMALIZERS: Record< + AiCapability, + (body: any) => NormalizedAiMessage[] | null +> = { + openai_chat: normalizeOpenAiChatRequest, + openai_responses: normalizeOpenAiResponsesRequest, + anthropic_messages: normalizeAnthropicRequest, + gemini_generate_content: normalizeGeminiRequest, + google_generate_content: normalizeGeminiRequest, + google_raw_predict: normalizeBestEffortRequest, + bedrock_model_invoke: normalizeBestEffortRequest, + bedrock_converse: normalizeBedrockConverseRequest +}; + +const RESPONSE_NORMALIZERS: Record< + AiCapability, + (text: string, isStream: boolean) => NormalizedAiMessage[] | null +> = { + openai_chat: normalizeOpenAiChatResponse, + openai_responses: normalizeOpenAiResponsesResponse, + anthropic_messages: normalizeAnthropicResponse, + gemini_generate_content: normalizeGeminiResponse, + google_generate_content: normalizeGeminiResponse, + google_raw_predict: normalizeGoogleRawPredictResponse, + bedrock_model_invoke: normalizeBedrockModelInvokeResponse, + bedrock_converse: normalizeBedrockConverseResponse +}; + +/** + * Normalizes an outbound AI gateway request body into a uniform message + * transcript, regardless of capability/provider. Returns null if the body + * doesn't contain any recognizable messages (or parsing failed) - callers + * should fall back to showing the raw request body. + */ +export function normalizeAiRequest( + capability: AiCapability, + body: unknown +): NormalizedAiMessage[] | null { + try { + const result = REQUEST_NORMALIZERS[capability](body); + return result && result.length > 0 ? result : null; + } catch (error) { + logger.debug("Failed to normalize AI request messages", { + capability, + error + }); + return null; + } +} + +/** + * Normalizes a completed (non-streaming or fully-accumulated streaming) AI + * gateway response into a uniform message transcript. Returns null if + * nothing recognizable could be extracted - callers should fall back to + * showing the raw response body. + */ +export function normalizeAiResponse( + capability: AiCapability, + responseText: string, + isStream: boolean +): NormalizedAiMessage[] | null { + try { + const result = RESPONSE_NORMALIZERS[capability](responseText, isStream); + return result && result.length > 0 ? result : null; + } catch (error) { + logger.debug("Failed to normalize AI response messages", { + capability, + error + }); + return null; + } +} diff --git a/server/lib/aiUsageExtraction.ts b/server/lib/aiUsageExtraction.ts index f3fc92580..48f19f308 100644 --- a/server/lib/aiUsageExtraction.ts +++ b/server/lib/aiUsageExtraction.ts @@ -49,7 +49,10 @@ function scanNumericFields( return out; } -function sseDataFrames(text: string): string[] { +// Exported for reuse by server/lib/aiMessageNormalization.ts, which needs +// the same SSE-frame/JSON-parsing groundwork to extract message content +// instead of usage numbers. +export function sseDataFrames(text: string): string[] { const frames: string[] = []; for (const rawFrame of text.split(/\r?\n\r?\n/)) { for (const line of rawFrame.split(/\r?\n/)) { @@ -63,7 +66,7 @@ function sseDataFrames(text: string): string[] { return frames; } -function tryParseJson(text: string): any | null { +export function tryParseJson(text: string): any | null { try { return JSON.parse(text); } catch { @@ -115,7 +118,10 @@ function extractOpenAiResponses( if (isStream) { for (const frame of sseDataFrames(text)) { const parsed = tryParseJson(frame); - if (parsed?.type === "response.completed" && parsed?.response?.usage) { + if ( + parsed?.type === "response.completed" && + parsed?.response?.usage + ) { usage = parsed.response.usage; } else if (parsed?.usage) { usage = parsed.usage; @@ -244,7 +250,10 @@ function extractBedrockConverse( if (usage) { const cacheReadTokens = usage.cacheReadInputTokens ?? 0; return { - promptTokens: Math.max(0, (usage.inputTokens ?? 0) - cacheReadTokens), + promptTokens: Math.max( + 0, + (usage.inputTokens ?? 0) - cacheReadTokens + ), cacheReadTokens, cacheWriteTokens: usage.cacheWriteInputTokens ?? 0, completionTokens: usage.outputTokens ?? 0, @@ -437,7 +446,12 @@ export function stripInjectedUsageFrame(sseText: string): string { if (dataLine) { const data = dataLine.slice("data:".length).trim(); const parsed = data !== "[DONE]" ? tryParseJson(data) : null; - if (parsed && Array.isArray(parsed.choices) && parsed.choices.length === 0 && parsed.usage) { + if ( + parsed && + Array.isArray(parsed.choices) && + parsed.choices.length === 0 && + parsed.usage + ) { continue; } } diff --git a/server/routers/aiGateway/logAiSession.ts b/server/routers/aiGateway/logAiSession.ts index 4c7384497..92723bed4 100644 --- a/server/routers/aiGateway/logAiSession.ts +++ b/server/routers/aiGateway/logAiSession.ts @@ -7,6 +7,10 @@ import cache from "#dynamic/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; import { sanitizeString } from "@server/lib/sanitize"; import type { AiCapability } from "@server/lib/aiCapabilities"; +import { + normalizeAiRequest, + normalizeAiResponse +} from "@server/lib/aiMessageNormalization"; // Caps how much of the request/response body we keep per row, so a single // huge multimodal payload can't blow up buffer memory or storage. @@ -197,6 +201,26 @@ export function logAiSession(data: { ); const responseBodyText = truncateBody(data.responseText ?? ""); + // Uniform, capability-agnostic transcript for search/display - + // computed from the untruncated originals so normalization sees + // the full content; the normalized result gets its own + // (typically much smaller) truncation pass below. + const normalizedRequestMessages = normalizeAiRequest( + data.capability, + data.requestBody + ); + const normalizedResponseMessages = normalizeAiResponse( + data.capability, + data.responseText ?? "", + data.isStream + ); + const normalizedRequestText = normalizedRequestMessages + ? truncateBody(JSON.stringify(normalizedRequestMessages)) + : null; + const normalizedResponseText = normalizedResponseMessages + ? truncateBody(JSON.stringify(normalizedResponseMessages)) + : null; + // Prevent unbounded buffer growth - drop oldest entries if buffer is too large if (sessionLogBuffer.length >= MAX_BUFFER_SIZE) { const dropped = sessionLogBuffer.splice(0, BATCH_SIZE); @@ -217,8 +241,17 @@ export function logAiSession(data: { isStream: data.isStream, requestBody: sanitizeString(requestBodyText.value), responseBody: sanitizeString(responseBodyText.value), + normalizedRequest: normalizedRequestText + ? sanitizeString(normalizedRequestText.value) + : undefined, + normalizedResponse: normalizedResponseText + ? sanitizeString(normalizedResponseText.value) + : undefined, truncated: - requestBodyText.truncated || responseBodyText.truncated, + requestBodyText.truncated || + responseBodyText.truncated || + (normalizedRequestText?.truncated ?? false) || + (normalizedResponseText?.truncated ?? false), statusCode: data.statusCode, createdAt: Date.now() }); From 899c47e9a37263b63917ee6297d2ff4aa2d8b63f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 11 Aug 2026 20:52:13 +0200 Subject: [PATCH 291/494] =?UTF-8?q?=F0=9F=9A=A7=20write=20process=20test?= =?UTF-8?q?=20alert=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../private/lib/alerts/processTestAlerts.ts | 95 ++++++++++++------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/server/private/lib/alerts/processTestAlerts.ts b/server/private/lib/alerts/processTestAlerts.ts index f598dc633..7aa1691f0 100644 --- a/server/private/lib/alerts/processTestAlerts.ts +++ b/server/private/lib/alerts/processTestAlerts.ts @@ -1,11 +1,15 @@ +import { db, userOrgRoles, users } from "@server/db"; import logger from "@server/logger"; import type { EmailAlertAction, - TestAlertContext + TestAlertContext, + WebhookAlertConfig } from "@server/routers/alertRule/types"; +import { eq, inArray } from "drizzle-orm"; import { sendAlertEmail } from "./sendAlertEmail"; -import type { db, alertEmailRecipients, users, userOrgRoles } from "@server/db"; -import type { eq } from "drizzle-orm"; +import { decrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import { sendAlertWebhook } from "./sendAlertWebhook"; export async function processTestAlerts(context: TestAlertContext) { const emailActions = context.actions.filter( @@ -19,7 +23,38 @@ export async function processTestAlerts(context: TestAlertContext) { await sendAlertEmail(recipients, context); } } catch (err) { - logger.error(`processAlerts: failed to send alert email`, err); + logger.error(`processTestAlerts: failed to send alert email`, err); + } + } + + const webhookActions = context.actions.filter( + (action) => action.type === "webhook" + ); + const serverSecret = config.getRawConfig().server.secret!; + + for (const action of webhookActions) { + try { + let webhookConfig: WebhookAlertConfig = { authType: "none" }; + + if (action.config) { + try { + const decrypted = decrypt(action.config, serverSecret); + webhookConfig = JSON.parse(decrypted) as WebhookAlertConfig; + } catch (err) { + logger.error( + `processTestAlerts: failed to decrypt webhook`, + err + ); + continue; + } + } + + await sendAlertWebhook(action.webhookUrl, webhookConfig, context); + } catch (err) { + logger.error( + `processTestAlerts: failed to send alert webhook `, + err + ); } } } @@ -35,39 +70,31 @@ export async function processTestAlerts(context: TestAlertContext) { async function resolveEmailRecipients( action: EmailAlertAction ): Promise { - const emailSet = new Set(); + const emailList: string[] = []; - // for (const row of rows) { - // if (row.email) { - // emailSet.add(row.email); - // } + emailList.push(...(action.emails ?? [])); - // if (row.userId) { - // const [user] = await db - // .select({ email: users.email }) - // .from(users) - // .where(eq(users.userId, row.userId)) - // .limit(1); - // if (user?.email) { - // emailSet.add(user.email); - // } - // } + if (action.userIds && action.userIds?.length > 0) { + const userList = await db + .select({ email: users.email }) + .from(users) + .where(inArray(users.userId, action.userIds)); - // if (row.roleId) { - // // Find all users with this role via userOrgRoles - // const roleUsers = await db - // .select({ email: users.email }) - // .from(userOrgRoles) - // .innerJoin(users, eq(userOrgRoles.userId, users.userId)) - // .where(eq(userOrgRoles.roleId, Number(row.roleId))); + emailList.push( + ...userList.filter((u) => u.email !== null).map((u) => u.email!) + ); + } + if (action.roleIds && action.roleIds?.length > 0) { + const userList = await db + .select({ email: users.email }) + .from(userOrgRoles) + .innerJoin(users, eq(userOrgRoles.userId, users.userId)) + .where(inArray(userOrgRoles.roleId, action.roleIds.map(Number))); - // for (const u of roleUsers) { - // if (u.email) { - // emailSet.add(u.email); - // } - // } - // } - // } + emailList.push( + ...userList.filter((u) => u.email !== null).map((u) => u.email!) + ); + } - return Array.from(emailSet); + return [...new Set(emailList)]; } From 864ab9729393a91c4e2b45ef0a099a2b1e9642cd Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 11 Aug 2026 14:58:55 -0400 Subject: [PATCH 292/494] hide expire column --- src/components/VirtualApiKeysTable.tsx | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/components/VirtualApiKeysTable.tsx b/src/components/VirtualApiKeysTable.tsx index d5c2d3717..074d82f74 100644 --- a/src/components/VirtualApiKeysTable.tsx +++ b/src/components/VirtualApiKeysTable.tsx @@ -372,30 +372,6 @@ export default function VirtualApiKeysTable({ }, cell: ({ row }) => moment(row.original.createdAt).format("lll") }, - { - accessorKey: "expiresAt", - friendlyName: t("expires"), - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const expiresAt = row.original.expiresAt; - if (expiresAt) { - return moment(expiresAt).format("lll"); - } - return t("never"); - } - }, { id: "actions", enableHiding: false, From 896c362dce713b24c1ea143f59b553a97aec234b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 11 Aug 2026 15:35:28 -0400 Subject: [PATCH 293/494] only support sso on inference resource auth policy --- messages/en-US.json | 4 + .../resource-policy/PolicyAuthSsoSection.tsx | 42 +- .../PolicyAuthStackSectionEdit.tsx | 464 +++++++++++------- 3 files changed, 318 insertions(+), 192 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 0eaf60378..0742d1614 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -855,12 +855,16 @@ "authMethodsSave": "Save Settings", "policyAuthStackTitle": "Authentication", "policyAuthStackDescription": "Control which authentication methods are required to access this resource", + "policyAuthInferenceStackDescription": "Choose which users and roles can authenticate to this AI gateway", "policyAuthOrLogicTitle": "Multiple authentication methods active", "policyAuthOrLogicBanner": "Visitors may authenticate using any one of the active methods below. They do not need to complete all of them.", "policyAuthMethodActive": "Active", "policyAuthMethodOff": "Off", "policyAuthSsoTitle": "Platform SSO", "policyAuthSsoDescription": "Require sign-in through your organization's identity provider", + "policyAuthInferenceSsoDescription": "Selected users and roles can authenticate to the gateway using their identity API key", + "policyAuthInferenceIdentityKeyHelp": "Every user already has an identity API key, so you only need to create virtual API keys for non-user clients or shared access. Users can retrieve their key by signing in with their identity provider at , where it will be shown after login.", + "policyAuthInferenceIdentityKeyHelpNoUrl": "Every user already has an identity API key, so you only need to create virtual API keys for non-user clients or shared access. Users can retrieve their key by signing in with their identity provider at this resource's URL, where it will be shown after login.", "policyAuthSsoSummary": "{idp} · {users} users, {roles} roles", "policyAuthSsoDefaultIdp": "Default provider", "policyAuthAddDefaultIdentityProvider": "Add Default Identity Provider", diff --git a/src/components/resource-policy/PolicyAuthSsoSection.tsx b/src/components/resource-policy/PolicyAuthSsoSection.tsx index f7f8b3110..3cc2e54c4 100644 --- a/src/components/resource-policy/PolicyAuthSsoSection.tsx +++ b/src/components/resource-policy/PolicyAuthSsoSection.tsx @@ -29,6 +29,9 @@ export type PolicyAuthSsoSectionProps = { usersEditor: React.ReactNode; disabled?: boolean; idpDisabled?: boolean; + ssoLocked?: boolean; + title?: string; + description?: string; }; export function PolicyAuthSsoSection({ @@ -40,7 +43,10 @@ export function PolicyAuthSsoSection({ rolesEditor, usersEditor, disabled, - idpDisabled + idpDisabled, + ssoLocked, + title, + description }: PolicyAuthSsoSectionProps) { const t = useTranslations(); const [showIdpSelect, setShowIdpSelect] = useState(skipToIdpId != null); @@ -52,22 +58,34 @@ export function PolicyAuthSsoSection({ }, [skipToIdpId]); const idpSelectDisabled = idpDisabled ?? disabled; + const ssoActive = ssoLocked || sso; + const ssoTitle = title ?? t("policyAuthSsoTitle"); + const ssoDescription = description ?? t("policyAuthSsoDescription"); return ( - - - + {!ssoLocked && ( + + + + )} - {sso && ( + {ssoActive && ( <> + {ssoLocked && ssoDescription && ( + +

+ {ssoDescription} +

+
+ )} {t("roles")} diff --git a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx index aa7700948..51b718e88 100644 --- a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx +++ b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx @@ -15,6 +15,7 @@ import { } from "@app/components/roles-selector"; import { UsersSelector } from "@app/components/users-selector"; import { Button } from "@app/components/ui/button"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; import { Form, FormField } from "@app/components/ui/form"; import { toast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -28,8 +29,10 @@ import type { GetResourcePolicyResponse } from "@server/routers/policy"; import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; +import { ExternalLink, InfoIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; +import { toUnicode } from "punycode"; import { useActionState, useContext, @@ -108,9 +111,21 @@ export function PolicyAuthStackSectionEdit({ const resourceContext = useContext(ResourceContext); const api = createApiClient(useEnvContext()); + const isInferenceResource = resourceContext?.resource.mode === "inference"; const isResourceOverlay = resourceId !== undefined; const authReadonly = readonly || isResourceOverlay; + const inferenceResourceUrl = useMemo(() => { + if (!isInferenceResource || !resourceContext?.resource) { + return null; + } + const { ssl, fullDomain } = resourceContext.resource; + if (!fullDomain) { + return null; + } + return `${ssl ? "https" : "http"}://${toUnicode(fullDomain)}`; + }, [isInferenceResource, resourceContext?.resource]); + const policyRoleItems = useMemo( () => policy.roles.map((r) => ({ @@ -264,6 +279,12 @@ export function PolicyAuthStackSectionEdit({ const overlayRoles = combinedRoles.filter((r) => !r.isAdmin); const overlayUsers = combinedUsers; + useEffect(() => { + if (isInferenceResource && !form.getValues("sso")) { + form.setValue("sso", true); + } + }, [isInferenceResource, form]); + const [, formAction, isSubmitting] = useActionState(onSubmit, null); const [isSavingOverlay, setIsSavingOverlay] = useState(false); @@ -294,7 +315,7 @@ export function PolicyAuthStackSectionEdit({ .put( `/resource-policy/${policy.resourcePolicyId}/access-control`, { - sso: payload.sso, + sso: isInferenceResource ? true : payload.sso, userIds: payload.users.map((user) => user.id), roleIds: payload.roles.map((role) => Number(role.id)), skipToIdpId: payload.skipToIdpId @@ -302,94 +323,101 @@ export function PolicyAuthStackSectionEdit({ ) .catch(handleError) ); + policyUpdates.sso = isInferenceResource ? true : payload.sso; + + if (!isInferenceResource) { + if (passcodeActive && payload.password?.password) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/password`, + { password: payload.password.password } + ) + .catch(handleError) + ); + policyUpdates.passwordId = policy.passwordId ?? -1; + } else if (!passcodeActive && passcodeOnServerRef.current) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/password`, + { password: null } + ) + .catch(handleError) + ); + policyUpdates.passwordId = null; + } + + if (pinActive && payload.pincode?.pincode?.length === 6) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/pincode`, + { pincode: payload.pincode.pincode } + ) + .catch(handleError) + ); + policyUpdates.pincodeId = policy.pincodeId ?? -1; + } else if (!pinActive && pincodeOnServerRef.current) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/pincode`, + { pincode: null } + ) + .catch(handleError) + ); + policyUpdates.pincodeId = null; + } + + if ( + headerAuthActive && + payload.headerAuth?.user && + payload.headerAuth?.password + ) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/header-auth`, + { headerAuth: payload.headerAuth } + ) + .catch(handleError) + ); + policyUpdates.headerAuth = { + id: policy.headerAuth?.id ?? -1, + extendedCompability: + payload.headerAuth.extendedCompatibility ?? true + }; + } else if (!headerAuthActive && headerAuthOnServerRef.current) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/header-auth`, + { headerAuth: null } + ) + .catch(handleError) + ); + policyUpdates.headerAuth = { + id: null, + extendedCompability: null + } as unknown as GetResourcePolicyResponse["headerAuth"]; + } - if (passcodeActive && payload.password?.password) { requests.push( api .put( - `/resource-policy/${policy.resourcePolicyId}/password`, - { password: payload.password.password } + `/resource-policy/${policy.resourcePolicyId}/whitelist`, + { + emailWhitelistEnabled: + payload.emailWhitelistEnabled, + emails: payload.emails?.map((e) => e.text) ?? [] + } ) .catch(handleError) ); - policyUpdates.passwordId = policy.passwordId ?? -1; - } else if (!passcodeActive && passcodeOnServerRef.current) { - requests.push( - api - .put( - `/resource-policy/${policy.resourcePolicyId}/password`, - { password: null } - ) - .catch(handleError) - ); - policyUpdates.passwordId = null; + policyUpdates.emailWhitelistEnabled = payload.emailWhitelistEnabled; } - if (pinActive && payload.pincode?.pincode?.length === 6) { - requests.push( - api - .put( - `/resource-policy/${policy.resourcePolicyId}/pincode`, - { pincode: payload.pincode.pincode } - ) - .catch(handleError) - ); - policyUpdates.pincodeId = policy.pincodeId ?? -1; - } else if (!pinActive && pincodeOnServerRef.current) { - requests.push( - api - .put( - `/resource-policy/${policy.resourcePolicyId}/pincode`, - { pincode: null } - ) - .catch(handleError) - ); - policyUpdates.pincodeId = null; - } - - if ( - headerAuthActive && - payload.headerAuth?.user && - payload.headerAuth?.password - ) { - requests.push( - api - .put( - `/resource-policy/${policy.resourcePolicyId}/header-auth`, - { headerAuth: payload.headerAuth } - ) - .catch(handleError) - ); - policyUpdates.headerAuth = { - id: policy.headerAuth?.id ?? -1, - extendedCompability: - payload.headerAuth.extendedCompatibility ?? true - }; - } else if (!headerAuthActive && headerAuthOnServerRef.current) { - requests.push( - api - .put( - `/resource-policy/${policy.resourcePolicyId}/header-auth`, - { headerAuth: null } - ) - .catch(handleError) - ); - policyUpdates.headerAuth = { - id: null, - extendedCompability: null - } as unknown as GetResourcePolicyResponse["headerAuth"]; - } - - requests.push( - api - .put(`/resource-policy/${policy.resourcePolicyId}/whitelist`, { - emailWhitelistEnabled: payload.emailWhitelistEnabled, - emails: payload.emails?.map((e) => e.text) ?? [] - }) - .catch(handleError) - ); - policyUpdates.emailWhitelistEnabled = payload.emailWhitelistEnabled; - try { const results = await Promise.all(requests); if (results.every((res) => res && res.status === 200)) { @@ -411,13 +439,17 @@ export function PolicyAuthStackSectionEdit({ updatePolicy(policyUpdates); - resourceContext?.updateAuthInfo({ - sso: payload.sso, - whitelist: payload.emailWhitelistEnabled, - password: passcodeOnServerRef.current, - pincode: pincodeOnServerRef.current, - headerAuth: headerAuthOnServerRef.current - }); + resourceContext?.updateAuthInfo( + isInferenceResource + ? { sso: true } + : { + sso: payload.sso, + whitelist: payload.emailWhitelistEnabled, + password: passcodeOnServerRef.current, + pincode: pincodeOnServerRef.current, + headerAuth: headerAuthOnServerRef.current + } + ); toast({ title: t("success"), @@ -514,19 +546,53 @@ export function PolicyAuthStackSectionEdit({ {t("policyAuthStackTitle")} - {t("policyAuthStackDescription")} + {isInferenceResource + ? t("policyAuthInferenceStackDescription") + : t("policyAuthStackDescription")} {isResourceOverlay && ( )} + {isInferenceResource && ( + + + + {inferenceResourceUrl + ? t.rich( + "policyAuthInferenceIdentityKeyHelp", + { + resourceLink: () => ( +
+ {inferenceResourceUrl} + + + ) + } + ) + : t( + "policyAuthInferenceIdentityKeyHelpNoUrl" + )} + + + )} - form.setValue("sso", active) - } + sso={Boolean(sso) || isInferenceResource} + onSsoChange={(active) => { + if (isInferenceResource) { + return; + } + form.setValue("sso", active); + }} skipToIdpId={skipToIdpId} onSkipToIdpChange={(id) => form.setValue("skipToIdpId", id) @@ -534,6 +600,12 @@ export function PolicyAuthStackSectionEdit({ allIdps={allIdps} disabled={authReadonly} idpDisabled={authReadonly} + ssoLocked={isInferenceResource} + description={ + isInferenceResource + ? t("policyAuthInferenceSsoDescription") + : undefined + } rolesEditor={ isResourceOverlay ? ( - - handleToggle("pincode", active, () => { - setPinActive(false); - form.setValue("pincode", null); - }) - } - onTogglePasscode={(active) => - handleToggle("passcode", active, () => { - setPasscodeActive(false); - form.setValue("password", null); - }) - } - onToggleEmail={(active) => - handleToggle("email", active, () => - form.setValue( - "emailWhitelistEnabled", - false + {!isInferenceResource && ( + + handleToggle("pincode", active, () => { + setPinActive(false); + form.setValue("pincode", null); + }) + } + onTogglePasscode={(active) => + handleToggle("passcode", active, () => { + setPasscodeActive(false); + form.setValue("password", null); + }) + } + onToggleEmail={(active) => + handleToggle("email", active, () => + form.setValue( + "emailWhitelistEnabled", + false + ) ) - ) - } - onToggleHeaderAuth={(active) => - handleToggle("headerAuth", active, () => { - setHeaderAuthActive(false); - form.setValue("headerAuth", null); - }) - } - /> + } + onToggleHeaderAuth={(active) => + handleToggle( + "headerAuth", + active, + () => { + setHeaderAuthActive(false); + form.setValue( + "headerAuth", + null + ); + } + ) + } + /> + )} - !open && closeCredenza()} - defaultPincode={pincode?.pincode ?? ""} - onSave={(value) => { - form.setValue("pincode", { pincode: value }); - setPinActive(true); - }} - /> + {!isInferenceResource && ( + <> + + !open && closeCredenza() + } + defaultPincode={pincode?.pincode ?? ""} + onSave={(value) => { + form.setValue("pincode", { + pincode: value + }); + setPinActive(true); + }} + /> - !open && closeCredenza()} - defaultPassword={password?.password ?? ""} - existingConfigured={Boolean(policy.passwordId)} - onSave={(value) => { - form.setValue("password", { password: value }); - setPasscodeActive(true); - }} - /> + + !open && closeCredenza() + } + defaultPassword={password?.password ?? ""} + existingConfigured={Boolean( + policy.passwordId + )} + onSave={(value) => { + form.setValue("password", { + password: value + }); + setPasscodeActive(true); + }} + /> - !open && closeCredenza()} - emailEnabled={emailEnabled} - disabled={authReadonly} - emails={emails} - onSave={(value) => { - form.setValue("emails", value); - form.setValue("emailWhitelistEnabled", true); - }} - /> + + !open && closeCredenza() + } + emailEnabled={emailEnabled} + disabled={authReadonly} + emails={emails} + onSave={(value) => { + form.setValue("emails", value); + form.setValue( + "emailWhitelistEnabled", + true + ); + }} + /> - !open && closeCredenza()} - defaultValues={ - headerAuth - ? { - user: headerAuth.user, - password: headerAuth.password, - extendedCompatibility: - headerAuth.extendedCompatibility ?? - true - } - : undefined - } - existingConfigured={Boolean(policy.headerAuth?.id)} - onSave={(value) => { - form.setValue("headerAuth", value); - setHeaderAuthActive(true); - }} - /> + + !open && closeCredenza() + } + defaultValues={ + headerAuth + ? { + user: headerAuth.user, + password: headerAuth.password, + extendedCompatibility: + headerAuth.extendedCompatibility ?? + true + } + : undefined + } + existingConfigured={Boolean( + policy.headerAuth?.id + )} + onSave={(value) => { + form.setValue("headerAuth", value); + setHeaderAuthActive(true); + }} + /> + + )} + + ); + } + }, + { + accessorKey: "isStream", + header: ({ column }) => { + return ( +
+ + handleFilterChange("isStream", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.isStream ? ( + <> + + {t("streaming")} + + ) : ( + + {t("nonStreaming")} + + )} + + ); + } + }, + { + accessorKey: "userEmail", + header: ({ column }) => { + return ( +
+ ({ + value: user.id, + label: user.email || user.id + }))} + selectedValue={filters.actor} + onValueChange={(value) => + handleFilterChange("actor", value) + } + label={t("actor")} + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.userEmail ? ( + <> + + {row.original.userEmail} + + ) : ( + <>- + )} + + ); + } + } + ]; + + const renderExpandedRow = (row: any) => { + return ( +
+
+
+ {t("aiSessionId")} +

+ {row.sessionId} +

+
+
+ {t("statusCode")} +

+ {row.statusCode ?? "N/A"} +

+
+
+ {t("model")} +

+ {row.requestedModel || "N/A"} +

+
+
+ {t("capability")} +

+ {capabilityLabels[row.capability] || row.capability} +

+
+
+ +
+ ); + }; + + return ( + <> + + + refetch()} + isRefreshing={isFetching} + onExport={() => startTransition(exportData)} + isExporting={isExporting} + onDateRangeChange={handleDateRangeChange} + dateRange={{ + start: dateRange.startDate, + end: dateRange.endDate + }} + defaultSort={{ + id: "createdAt", + desc: true + }} + totalCount={totalCount} + currentPage={currentPage} + onPageChange={handlePageChange} + onPageSizeChange={handlePageSizeChange} + isLoading={isLoading} + pageSize={pageSize} + expandable={true} + renderExpandedRow={renderExpandedRow} + /> + + ); +} + +function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] { + const capabilities = Object.keys(capabilityLabels); + const providers = [ + { id: 1, name: "OpenAI Production" }, + { id: 2, name: "Anthropic Default" }, + { id: 3, name: "Vertex AI" } + ]; + const resourcesSample = [ + { id: 1, niceId: "resource-1", name: "Resource 1" }, + { id: 2, niceId: "resource-2", name: "Resource 2" } + ]; + const actors = ["alice@example.com", "bob@example.com", null]; + const models = ["gpt-4o", "claude-sonnet-5", "gemini-2.5-pro"]; + + const now = Date.now(); + const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000; + + return Array.from({ length: 10 }, (_, i) => { + const provider = providers[Math.floor(Math.random() * providers.length)]; + const resource = + resourcesSample[Math.floor(Math.random() * resourcesSample.length)]; + const actor = actors[Math.floor(Math.random() * actors.length)]; + + return { + id: i, + sessionId: `sample-session-${i}`, + orgId: "sample-org", + providerId: provider.id, + providerName: provider.name, + providerType: "openai", + capability: + capabilities[Math.floor(Math.random() * capabilities.length)], + resourceId: resource.id, + siteResourceId: null, + resourceName: resource.name, + resourceNiceId: resource.niceId, + resourceType: "public", + userId: actor ? `user-${i}` : null, + userEmail: actor, + requestedModel: models[Math.floor(Math.random() * models.length)], + isStream: Math.random() > 0.5, + requestBody: null, + responseBody: null, + normalizedRequest: null, + normalizedResponse: null, + truncated: false, + statusCode: 200, + createdAt: Math.floor( + sevenDaysAgoMs + Math.random() * (now - sevenDaysAgoMs) + ) + }; + }); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index a9ffcca55..7181fd1c6 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -3,6 +3,7 @@ import { Env } from "@app/lib/types/env"; import { build } from "@server/build"; import { BellRing, + Bot, Boxes, Building2, Cable, @@ -227,6 +228,11 @@ export const orgNavSections = ( ) }, + { + title: "sidebarLogsAi", + href: "/{orgId}/settings/logs/ai", + icon: + }, ...(!env?.flags.disableEnterpriseFeatures ? [ { @@ -521,6 +527,11 @@ export const commandBarNavSections = ( href: "/{orgId}/settings/logs/request", icon: }, + { + title: "commandLogsAi", + href: "/{orgId}/settings/logs/ai", + icon: + }, ...(!env?.flags.disableEnterpriseFeatures ? [ { diff --git a/src/components/AiSessionChatView.tsx b/src/components/AiSessionChatView.tsx new file mode 100644 index 000000000..0a279b27e --- /dev/null +++ b/src/components/AiSessionChatView.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { AlertTriangle, Bot, Terminal, User as UserIcon, Wrench } from "lucide-react"; +import type { NormalizedAiMessage } from "@server/lib/aiMessageNormalization"; + +type AiSessionChatViewProps = { + normalizedRequest: string | null; + normalizedResponse: string | null; + requestBody: string | null; + responseBody: string | null; + truncated: boolean; +}; + +function parseMessages(json: string | null): NormalizedAiMessage[] | null { + if (!json) return null; + try { + const parsed = JSON.parse(json); + return Array.isArray(parsed) ? (parsed as NormalizedAiMessage[]) : null; + } catch { + return null; + } +} + +function prettyRaw(raw: string | null): string | null { + if (!raw) return null; + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch { + return raw; + } +} + +function MessageBubble({ message }: { message: NormalizedAiMessage }) { + const isUser = message.role === "user"; + const isSystem = message.role === "system"; + const isTool = message.role === "tool"; + + if (isSystem) { + return ( +
+ +
+                    {message.content}
+                
+
+ ); + } + + return ( +
+
+ {isUser ? ( + + ) : isTool ? ( + + ) : ( + + )} +
+
+ {message.content || ( +   + )} +
+
+ ); +} + +function RawFallbackBlock({ + label, + raw, + noDataLabel, + unparsedLabel +}: { + label: string; + raw: string | null; + noDataLabel: string; + unparsedLabel: string; +}) { + const pretty = prettyRaw(raw); + return ( +
+
+ {label} + {pretty && ( + + {unparsedLabel} + + )} +
+
+                {pretty ?? noDataLabel}
+            
+
+ ); +} + +export function AiSessionChatView({ + normalizedRequest, + normalizedResponse, + requestBody, + responseBody, + truncated +}: AiSessionChatViewProps) { + const t = useTranslations(); + + const requestMessages = useMemo( + () => parseMessages(normalizedRequest), + [normalizedRequest] + ); + const responseMessages = useMemo( + () => parseMessages(normalizedResponse), + [normalizedResponse] + ); + + const hasRequestMessages = !!requestMessages && requestMessages.length > 0; + const hasResponseMessages = + !!responseMessages && responseMessages.length > 0; + + return ( +
+ {truncated && ( +
+ + {t("aiSessionLogTruncated")} +
+ )} +
+ {hasRequestMessages ? ( + requestMessages!.map((message, i) => ( + + )) + ) : ( + + )} + {hasResponseMessages ? ( + responseMessages!.map((message, i) => ( + + )) + ) : ( + + )} +
+
+ ); +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 02a9404e8..3a4c024d4 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -10,6 +10,7 @@ import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; import type { QueryAccessAuditLogResponse, QueryActionAuditLogResponse, + QueryAiSessionLogResponse, QueryConnectionAuditLogResponse, QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; @@ -1031,6 +1032,32 @@ export const connectionLogsFiltersSchema = z.object({ export type ConnectionLogFilters = z.output; +export const aiSessionLogsFiltersSchema = z.object({ + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeStart must be a valid ISO date string" + }) + .optional() + .catch(undefined), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeEnd must be a valid ISO date string" + }) + .optional() + .catch(undefined), + page: z.coerce.number().optional().catch(0).default(0), + pageSize: z.coerce.number().optional().catch(20).default(20), + providerId: z.string().optional().catch(undefined), + capability: z.string().optional().catch(undefined), + resourceId: z.string().optional().catch(undefined), + actor: z.string().optional().catch(undefined), + isStream: z.string().optional().catch(undefined) +}); + +export type AiSessionLogFilters = z.output; + export const logQueries = { requestAnalytics: ({ orgId, @@ -1180,6 +1207,37 @@ export const logQueries = { } return false; } + }), + + aiSessions: ({ + orgId, + filters + }: { + orgId: string; + filters: AiSessionLogFilters; + }) => + queryOptions({ + queryKey: ["AI_SESSION_LOGS", orgId, "ALL", filters] as const, + queryFn: async ({ signal, meta }) => { + const { page, pageSize, ...rest } = filters; + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/logs/ai`, { + params: { + ...rest, + limit: pageSize, + offset: page * pageSize + }, + signal + }); + return res.data.data; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; + } }) }; From e734cc93a10bddb2e833a29a981d302cee4363d4 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 11 Aug 2026 15:57:57 -0400 Subject: [PATCH 295/494] small visual adjustments and chat button --- messages/en-US.json | 2 + server/routers/auditLogs/queryAiSessionLog.ts | 19 +++- server/routers/auditLogs/types.ts | 1 + src/app/[orgId]/settings/logs/ai/page.tsx | 45 +++++---- src/components/AiSessionChatView.tsx | 95 +++++++++++++------ src/lib/queries.ts | 1 + 6 files changed, 113 insertions(+), 50 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 0784fdf2e..164f110e7 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3405,6 +3405,8 @@ "aiSessionNoData": "No data captured", "aiSessionCouldNotParse": "(raw, could not parse transcript)", "aiSessionLogTruncated": "This session was truncated before storage and may be incomplete.", + "aiSessionViewRaw": "View Raw JSON", + "aiSessionViewChat": "View Chat", "requestAnalyticsDescription": "View detailed request analytics for resources in this organization", "logRetentionRequestLabel": "HTTP Request Log Retention", "logRetentionRequestDescription": "How long to retain request logs", diff --git a/server/routers/auditLogs/queryAiSessionLog.ts b/server/routers/auditLogs/queryAiSessionLog.ts index 913eacf65..2412f8980 100644 --- a/server/routers/auditLogs/queryAiSessionLog.ts +++ b/server/routers/auditLogs/queryAiSessionLog.ts @@ -66,6 +66,7 @@ export const queryAiSessionLogsQuery = z.strictObject({ .pipe(z.int().positive()) .optional(), actor: z.string().optional(), + model: z.string().optional(), isStream: z .union([z.boolean(), z.string()]) .transform((val) => (typeof val === "string" ? val === "true" : val)) @@ -123,6 +124,9 @@ function getWhere(data: Q) { ) : undefined, data.actor ? eq(aiSessionLog.userId, data.actor) : undefined, + data.model + ? eq(aiSessionLog.requestedModel, data.model) + : undefined, data.isStream !== undefined ? eq(aiSessionLog.isStream, data.isStream) : undefined @@ -333,7 +337,8 @@ async function queryUniqueFilterAttributes( uniqueProviders, uniqueUsers, uniqueResources, - uniqueSiteResources + uniqueSiteResources, + uniqueModels ] = await Promise.all([ logsDb .selectDistinct({ id: aiSessionLog.providerId }) @@ -354,9 +359,18 @@ async function queryUniqueFilterAttributes( .selectDistinct({ id: aiSessionLog.siteResourceId }) .from(aiSessionLog) .where(and(baseConditions, isNull(aiSessionLog.resourceId))) + .limit(DISTINCT_LIMIT + 1), + logsDb + .selectDistinct({ model: aiSessionLog.requestedModel }) + .from(aiSessionLog) + .where(baseConditions) .limit(DISTINCT_LIMIT + 1) ]); + const models = uniqueModels + .map((row) => row.model) + .filter((model): model is string => model !== null); + const providerIds = uniqueProviders .map((row) => row.id) .filter((id): id is number => id !== null); @@ -440,7 +454,8 @@ async function queryUniqueFilterAttributes( return { providers: sortNamedFilterOptions(providers), resources: sortNamedFilterOptions(resourcesWithNames), - users: userList + users: userList, + models: models.sort() }; } diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index cf85e5a2f..42b93904e 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -138,6 +138,7 @@ export type QueryAiSessionLogResponse = { id: string; email: string | null; }[]; + models: string[]; }; }; diff --git a/src/app/[orgId]/settings/logs/ai/page.tsx b/src/app/[orgId]/settings/logs/ai/page.tsx index 2c4c2ed32..67d18b94c 100644 --- a/src/app/[orgId]/settings/logs/ai/page.tsx +++ b/src/app/[orgId]/settings/logs/ai/page.tsx @@ -50,12 +50,14 @@ export default function AiSessionLogsPage() { capability?: string; resourceId?: string; actor?: string; + model?: string; isStream?: string; }>({ providerId: searchParams.get("providerId") || undefined, capability: searchParams.get("capability") || undefined, resourceId: searchParams.get("resourceId") || undefined, actor: searchParams.get("actor") || undefined, + model: searchParams.get("model") || undefined, isStream: searchParams.get("isStream") || undefined }); @@ -132,7 +134,8 @@ export default function AiSessionLogsPage() { const filterAttributes = data?.filterAttributes ?? { providers: [], resources: [], - users: [] + users: [], + models: [] }; const handleDateRangeChange = ( @@ -309,15 +312,27 @@ export default function AiSessionLogsPage() { }, { accessorKey: "requestedModel", - header: ({ column }) => ( - {t("model")} - ), - cell: ({ row }) => { + header: ({ column }) => { return ( - - {row.original.requestedModel || "-"} - +
+ ({ + value: model, + label: model + }))} + selectedValue={filters.model} + onValueChange={(value) => + handleFilterChange("model", value) + } + label={t("model")} + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + /> +
); + }, + cell: ({ row }) => { + return {row.original.requestedModel || "-"}; } }, { @@ -444,7 +459,7 @@ export default function AiSessionLogsPage() { const renderExpandedRow = (row: any) => { return (
-
+
{t("aiSessionId")}

@@ -457,18 +472,6 @@ export default function AiSessionLogsPage() { {row.statusCode ?? "N/A"}

-
- {t("model")} -

- {row.requestedModel || "N/A"} -

-
-
- {t("capability")} -

- {capabilityLabels[row.capability] || row.capability} -

-
{label} - {pretty && ( + {pretty && unparsedLabel && ( {unparsedLabel} @@ -123,6 +132,7 @@ export function AiSessionChatView({ truncated }: AiSessionChatViewProps) { const t = useTranslations(); + const [rawMode, setRawMode] = useState(false); const requestMessages = useMemo( () => parseMessages(normalizedRequest), @@ -139,38 +149,69 @@ export function AiSessionChatView({ return (
- {truncated && ( -
- - {t("aiSessionLogTruncated")} -
- )} -
- {hasRequestMessages ? ( - requestMessages!.map((message, i) => ( - - )) +
+ {truncated ? ( +
+ + {t("aiSessionLogTruncated")} +
) : ( +
+ )} + +
+ {rawMode ? ( +
- )} - {hasResponseMessages ? ( - responseMessages!.map((message, i) => ( - - )) - ) : ( - )} -
+
+ ) : ( +
+ {hasRequestMessages ? ( + requestMessages!.map((message, i) => ( + + )) + ) : ( + + )} + {hasResponseMessages ? ( + responseMessages!.map((message, i) => ( + + )) + ) : ( + + )} +
+ )}
); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 3a4c024d4..0275d108b 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1053,6 +1053,7 @@ export const aiSessionLogsFiltersSchema = z.object({ capability: z.string().optional().catch(undefined), resourceId: z.string().optional().catch(undefined), actor: z.string().optional().catch(undefined), + model: z.string().optional().catch(undefined), isStream: z.string().optional().catch(undefined) }); From c42df737b0b737456e08932acdebde5e42db9166 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 11 Aug 2026 16:10:34 -0400 Subject: [PATCH 296/494] improve model provider selection algorithm --- docs/ai-gateway-provider-selection.md | 285 ++++++++++++++++++ server/lib/aiInferenceResource.ts | 161 ---------- server/lib/aiModelKeyMatch.ts | 23 -- server/lib/aiProviderSelection.ts | 97 ++++++ server/routers/aiGateway/pipeline.ts | 24 +- .../resource/addAiProviderToResource.ts | 3 +- .../resource/removeAiProviderFromResource.ts | 3 +- .../resource/setResourceAiProviders.ts | 3 +- .../addAiProviderToSiteResource.ts | 3 +- .../removeAiProviderFromSiteResource.ts | 3 +- .../setSiteResourceAiProviders.ts | 3 +- 11 files changed, 408 insertions(+), 200 deletions(-) create mode 100644 docs/ai-gateway-provider-selection.md create mode 100644 server/lib/aiProviderSelection.ts diff --git a/docs/ai-gateway-provider-selection.md b/docs/ai-gateway-provider-selection.md new file mode 100644 index 000000000..f955e25aa --- /dev/null +++ b/docs/ai-gateway-provider-selection.md @@ -0,0 +1,285 @@ +# AI Gateway Provider Selection + +How the AI gateway picks which attached provider handles a request when an +inference resource has more than one AI provider. + +**Code:** + +- Route → capability binding: `server/routers/aiGateway/createAiGatewayRouter.ts` +- Request pipeline: `server/routers/aiGateway/pipeline.ts` (`selectProvider`) +- Tie-break scoring: `server/lib/aiProviderSelection.ts` +- Allow/block matching: `server/lib/aiModelKeyMatch.ts` +- Model catalog: `server/lib/aiModelCatalog.ts` +- Default capabilities per provider type: `server/lib/aiProviderDefaults.ts` + +Overlapping model allows are permitted at save time. Selection happens at +request time. If the algorithm cannot confidently pick one provider, the +gateway returns `403` with an ambiguous-provider error. + +## Selection Pipeline + +Every gateway request runs through these steps in order. Each step narrows +the candidate set. Later steps only run when more than one provider remains. + +``` +1. Capability filter +2. Allow / block lists +3. Most specific allow pattern +4. Catalog ownership +5. Provider class preference +6. Ambiguous → error +``` + +### 1. Capability Filter + +The incoming path selects a capability before any provider logic runs. + +| Path | Capability | +|------|------------| +| `POST /v1/chat/completions` | `openai_chat` | +| `POST /v1/responses` | `openai_responses` | +| `POST /v1/messages` | `anthropic_messages` | +| Gemini / Vertex / Bedrock routes | their respective capability ids | + +Only attached providers that advertise that capability stay in the candidate +set. Default capabilities do not overlap for native OpenAI vs Anthropic: + +| Provider type | Default capabilities | +|---------------|----------------------| +| `openai` | `openai_chat`, `openai_responses` | +| `anthropic` | `anthropic_messages` | +| `openRouter` | `openai_chat` | +| `vercelAiGateway` | `openai_chat`, `openai_responses` | +| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages` | +| `custom` | whatever was configured | + +### 2. Allow / Block Lists + +For each remaining provider, the gateway resolves the effective allow and +block patterns: + +- **`inherit`**: use the provider's own model lists +- **`select`**: use the resource-selected subset of those lists + +A candidate is kept only if `isAllowedByLists(requestedModel, allows, blocks)` +passes: + +1. At least one allow pattern must match +2. No block pattern may match + +Patterns support `*` and `?` globs (`gpt-*`, `claude-3-5-sonnet-?`). + +### 3. Most Specific Allow Pattern + +Among providers that allow the model, keep those whose matching allow +pattern is most specific: + +1. Exact keys beat patterns +2. Fewer wildcard characters win +3. Longer literal length wins + +Example: `gpt-4o` beats `gpt-*` beats `*`. + +### 4. Catalog Ownership + +When specificity is tied (common with multiple `*` allows), score each +provider against the known model catalog: + +| Score | Meaning | +|------:|---------| +| 2 | Typed provider whose catalog contains the model (`openai` → openai catalog, `anthropic` → anthropic, etc.) | +| 1 | Aggregator or custom (`openRouter`, `vercelAiGateway`, `custom`) and the model exists somewhere in the catalog | +| 0 | No ownership signal (typed catalog miss, or unknown model on aggregator/custom) | + +Model id lookup tries the raw id, then a stripped `vendor/model` form +(e.g. `openai/gpt-4o` → also try `gpt-4o`). + +Typed providers map to catalog providers as: + +| Provider type | Catalog | +|---------------|---------| +| `openai` | `openai` | +| `anthropic` | `anthropic` | +| `googleGemini` | `gemini` | +| `vertexAi` | `vertex` | +| `bedrock` | `bedrock` | +| `microsoftFoundry` | `azure` | +| `openRouter` / `vercelAiGateway` / `custom` | none (aggregator/custom path) | + +### 5. Provider Class Preference + +If catalog ownership is still tied, prefer: + +| Rank | Class | +|-----:|-------| +| 2 | Native typed provider (`openai`, `anthropic`, `googleGemini`, …) | +| 1 | Aggregator (`openRouter`, `vercelAiGateway`) | +| 0 | `custom` | + +### 6. Ambiguous Error + +If more than one distinct provider remains after all steps, the gateway +rejects the request: + +``` +Model "" is ambiguous across multiple AI providers on this resource +``` + +Typical remaining ties: two OpenAI-type providers both with `*`, or two +customs advertising the same capability for an unknown model. + +## Examples + +Assume each provider below is attached and enabled on the same inference +resource. + +### Example A: OpenAI + Anthropic, Both `*` + +| Provider | Allow | Capabilities | +|----------|-------|--------------| +| OpenAI | `*` | `openai_chat`, `openai_responses` | +| Anthropic | `*` | `anthropic_messages` | + +**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"` + +1. Capability → only OpenAI remains +2. Allow → OpenAI matches `*` +3. Result → **OpenAI** + +Anthropic never reaches pattern or catalog scoring. Capability alone decides. + +**Request:** `POST /v1/messages` with `model: "claude-3-5-sonnet-latest"` + +1. Capability → only Anthropic remains +2. Result → **Anthropic** + +### Example B: OpenAI + OpenRouter, Both `*` + +| Provider | Allow | Capabilities | +|----------|-------|--------------| +| OpenAI | `*` | `openai_chat`, … | +| OpenRouter | `*` | `openai_chat` | + +**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"` + +1. Capability → both remain (`openai_chat`) +2. Allow → both match `*` +3. Specificity → tie (`*` vs `*`) +4. Catalog → OpenAI scores `2` (owns `gpt-4o`); OpenRouter scores `1` +5. Result → **OpenAI** + +### Example C: OpenRouter Only Serving a Claude Model Over OpenAI Chat + +| Provider | Allow | Capabilities | +|----------|-------|--------------| +| OpenRouter | `*` | `openai_chat` | + +**Request:** `POST /v1/chat/completions` with `model: "anthropic/claude-3.5-sonnet"` + +1. Capability → OpenRouter remains +2. Only one candidate → **OpenRouter** + +No tie-breaking needed. + +### Example D: OpenAI (`gpt-*`) + OpenRouter (`*`) + +| Provider | Allow | +|----------|-------| +| OpenAI | `gpt-*` | +| OpenRouter | `*` | + +**Request:** `model: "gpt-4o"` on `openai_chat` + +1. Capability → both +2. Allow → both match +3. Specificity → OpenAI's `gpt-*` beats OpenRouter's `*` +4. Result → **OpenAI** + +Catalog scoring is not needed because specificity already unique'd the set. + +### Example E: OpenAI + Anthropic With Overlapping Custom Capabilities + +Someone grants Anthropic `openai_chat` as well (non-default). + +| Provider | Allow | Capabilities | +|----------|-------|--------------| +| OpenAI | `*` | `openai_chat`, … | +| Anthropic | `*` | `anthropic_messages`, `openai_chat` | + +**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"` + +1. Capability → both remain +2. Allow → both match `*` +3. Specificity → tie +4. Catalog → OpenAI `2`, Anthropic `0` (`gpt-4o` is not in the anthropic catalog) +5. Result → **OpenAI** + +### Example F: Two Aggregators, Known Model + +| Provider | Allow | +|----------|-------| +| OpenRouter | `*` | +| Vercel AI Gateway | `*` | + +**Request:** `model: "gpt-4o"` on `openai_chat` + +1. Capability → both +2. Allow / specificity → tie +3. Catalog → both score `1` (known model, no typed owner in the set) +4. Class → both aggregators (rank `1`) → still tied +5. Result → **ambiguous error** + +Attach a native OpenAI provider (or narrow one aggregator's allow list) to +make this determinable. + +### Example G: Two OpenAI Providers, Both `*` + +| Provider | Type | Allow | +|----------|------|-------| +| OpenAI Prod | `openai` | `*` | +| OpenAI Staging | `openai` | `*` | + +**Request:** `model: "gpt-4o"` + +1–5 all leave both candidates (same capability, same specificity, same +catalog ownership, same class). + +Result → **ambiguous error** + +Disambiguate with different allow patterns, disable one attachment, or +split across resources. + +### Example H: Unknown Model Across Native + Aggregator + +| Provider | Allow | +|----------|-------| +| OpenAI | `*` | +| OpenRouter | `*` | + +**Request:** `model: "my-fine-tune-v3"` (not in catalog) + +1. Capability → both +2. Allow / specificity → tie +3. Catalog → both score `0` (typed miss + unknown aggregator model) +4. Class → OpenAI (`2`) beats OpenRouter (`1`) +5. Result → **OpenAI** + +## Practical Guidance + +- Native OpenAI + Anthropic with `*` is safe. Different default APIs never + collide. +- OpenAI + OpenRouter with `*` is usually fine for catalog-known OpenAI + models. Native wins. +- Prefer specific allow patterns (`gpt-4o`, `gpt-*`) when two providers share + a capability. +- Two providers of the same type both using `*` will stay ambiguous. Narrow + at least one allow list. +- Custom providers only win ties when no stronger native/aggregator signal + remains. + +## Related Behavior + +- **Saving providers on a resource does not reject overlapping allows.** + Collisions are resolved (or rejected) per request. +- Budgets, auth, and upstream URL / target routing run after a single + provider has been selected. diff --git a/server/lib/aiInferenceResource.ts b/server/lib/aiInferenceResource.ts index cfd560fce..d38683b59 100644 --- a/server/lib/aiInferenceResource.ts +++ b/server/lib/aiInferenceResource.ts @@ -10,7 +10,6 @@ import { type Transaction } from "@server/db"; import { z } from "zod"; -import { modelKeysConflict } from "@server/lib/aiModelKeyMatch"; type DbOrTrx = Transaction | typeof db; @@ -100,142 +99,6 @@ function normalizeAttachments( ); } -type EffectiveAllowRow = { - providerId: number; - modelKey: string; -}; - -/** - * Ensure effective allow modelKeys do not conflict across attached providers. - * inherit uses provider allows; select uses resource-selected allows (or the - * optional override map). Block patterns are ignored for overlap checks. - */ -export async function assertNoOverlappingModelKeys( - attachments: ResourceAiProviderAttachment[], - options: { - trx?: DbOrTrx; - resourceId?: number; - siteResourceId?: number; - selectedAllowsByProvider?: Map; - } = {} -): Promise { - const trx = options.trx ?? db; - - const activeAttachments = attachments.filter((a) => a.enabled); - - if (activeAttachments.length < 2) { - return null; - } - - 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 effectiveAllows: EffectiveAllowRow[] = []; - - if (inheritProviderIds.length > 0) { - const providerAllows = await trx - .select({ - providerId: aiModels.providerId, - modelKey: aiModels.modelKey - }) - .from(aiModels) - .where( - and( - inArray(aiModels.providerId, inheritProviderIds), - eq(aiModels.enabled, true), - eq(aiModels.listType, "allow") - ) - ); - effectiveAllows.push(...providerAllows); - } - - if (selectProviderIds.length > 0) { - if (options.selectedAllowsByProvider) { - for (const providerId of selectProviderIds) { - const keys = - options.selectedAllowsByProvider.get(providerId) ?? []; - for (const modelKey of keys) { - effectiveAllows.push({ providerId, modelKey }); - } - } - } else if (options.resourceId !== undefined) { - const rows = await trx - .select({ - providerId: aiModels.providerId, - modelKey: aiModels.modelKey - }) - .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) - ) - ); - effectiveAllows.push(...rows); - } else if (options.siteResourceId !== undefined) { - const rows = await trx - .select({ - providerId: aiModels.providerId, - modelKey: aiModels.modelKey - }) - .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) - ) - ); - effectiveAllows.push(...rows); - } - } - - const conflictPairs: string[] = []; - for (let i = 0; i < effectiveAllows.length; i++) { - for (let j = i + 1; j < effectiveAllows.length; j++) { - const left = effectiveAllows[i]; - const right = effectiveAllows[j]; - if (left.providerId === right.providerId) { - continue; - } - if (!modelKeysConflict(left.modelKey, right.modelKey)) { - continue; - } - const pair = [left.modelKey, right.modelKey].sort().join(" vs "); - if (!conflictPairs.includes(pair)) { - conflictPairs.push(pair); - } - } - } - - if (conflictPairs.length === 0) { - return null; - } - - conflictPairs.sort(); - return { - error: `Model keys must be unique across providers on a resource. Overlapping keys: ${conflictPairs.join(", ")}` - }; -} - /** * Validate provider attachments for an org. */ @@ -243,8 +106,6 @@ export async function resolveProviderAttachments(input: { orgId: string; attachments: ResourceAiProviderInput[]; requireAtLeastOne: boolean; - resourceId?: number; - siteResourceId?: number; }): Promise { const attachments = normalizeAttachments(input.attachments); @@ -286,14 +147,6 @@ export async function resolveProviderAttachments(input: { }; } - const overlapError = await assertNoOverlappingModelKeys(attachments, { - resourceId: input.resourceId, - siteResourceId: input.siteResourceId - }); - if (overlapError) { - return overlapError; - } - return attachments; } @@ -830,7 +683,6 @@ async function assertModelEntriesValid(input: { const catalogRows = await db .select({ modelId: aiModels.modelId, - modelKey: aiModels.modelKey, listType: aiModels.listType, providerId: aiModels.providerId, enabled: aiModels.enabled @@ -850,7 +702,6 @@ async function assertModelEntriesValid(input: { } const catalogById = new Map(catalogRows.map((row) => [row.modelId, row])); - const selectedAllowsByProvider = new Map(); for (const entry of input.modelEntries) { const catalog = catalogById.get(entry.modelId); if (!catalog) { @@ -862,18 +713,6 @@ async function assertModelEntriesValid(input: { if (!catalog.enabled) { return `Model ${entry.modelId} is disabled on its provider`; } - if (entry.listType === "allow") { - const keys = selectedAllowsByProvider.get(catalog.providerId) ?? []; - keys.push(catalog.modelKey); - selectedAllowsByProvider.set(catalog.providerId, keys); - } - } - - const overlapError = await assertNoOverlappingModelKeys(input.attachments, { - selectedAllowsByProvider - }); - if (overlapError) { - return overlapError.error; } return null; diff --git a/server/lib/aiModelKeyMatch.ts b/server/lib/aiModelKeyMatch.ts index 38184c3b0..8f54afb8c 100644 --- a/server/lib/aiModelKeyMatch.ts +++ b/server/lib/aiModelKeyMatch.ts @@ -61,29 +61,6 @@ export function compareModelKeySpecificity(a: string, b: string): number { return literalLength(b) - literalLength(a); } -/** - * Attach-time conflict check. Detects identical keys and exact-vs-pattern - * matches. Does not attempt full glob intersection. - */ -export function modelKeysConflict(a: string, b: string): boolean { - if (a === b) { - return true; - } - - const aIsPattern = isModelKeyPattern(a); - const bIsPattern = isModelKeyPattern(b); - - if (aIsPattern === bIsPattern) { - return false; - } - - if (aIsPattern) { - return modelKeyMatches(a, b); - } - - return modelKeyMatches(b, a); -} - /** * Provider-layer policy: empty allowlist denies all. Blocklist only applies * after an allow match. diff --git a/server/lib/aiProviderSelection.ts b/server/lib/aiProviderSelection.ts new file mode 100644 index 000000000..e35d85f1a --- /dev/null +++ b/server/lib/aiProviderSelection.ts @@ -0,0 +1,97 @@ +import { + aiModelCatalog, + getCatalogProviderForType, + type CatalogProvider +} from "@server/lib/aiModelCatalog"; +import type { AiProviderType } from "@server/lib/aiProviderDefaults"; + +function stripVendorPrefix(modelId: string): string | null { + const idx = modelId.indexOf("/"); + if (idx === -1 || idx === modelId.length - 1) { + return null; + } + return modelId.slice(idx + 1); +} + +function modelKeysToTry(modelId: string): string[] { + const keys = [modelId]; + const stripped = stripVendorPrefix(modelId); + if (stripped) { + keys.push(stripped); + } + return keys; +} + +function catalogOwnsModel( + catalogProvider: CatalogProvider, + modelId: string +): boolean { + for (const key of modelKeysToTry(modelId)) { + if (aiModelCatalog.get(catalogProvider, key)) { + return true; + } + } + return false; +} + +function modelKnownInAnyCatalog(modelId: string): boolean { + for (const key of modelKeysToTry(modelId)) { + if (aiModelCatalog.listByKey(key).length > 0) { + return true; + } + } + return false; +} + +/** + * How strongly a provider "owns" a requested model id via the known catalog. + * + * 2 - Typed provider whose catalog contains the model + * 1 - Aggregator/custom that can proxy a catalog-known model + * 0 - No ownership signal (typed miss, or unknown model on aggregator/custom) + */ +export function catalogOwnershipScore( + type: AiProviderType, + modelId: string +): number { + const catalogProvider = getCatalogProviderForType(type); + if (catalogProvider != null) { + return catalogOwnsModel(catalogProvider, modelId) ? 2 : 0; + } + return modelKnownInAnyCatalog(modelId) ? 1 : 0; +} + +/** + * Prefer native vendor providers over aggregators over custom when catalog + * ownership is tied. + * + * 2 - Native typed provider (openai, anthropic, gemini, ...) + * 1 - Aggregator gateway (openRouter, vercelAiGateway) + * 0 - Custom + */ +export function providerClassRank(type: AiProviderType): number { + if (type === "custom") { + return 0; + } + if (type === "openRouter" || type === "vercelAiGateway") { + return 1; + } + return 2; +} + +export function keepBestScored( + items: T[], + scoreFn: (item: T) => number +): T[] { + if (items.length <= 1) { + return items; + } + let best = Number.NEGATIVE_INFINITY; + for (const item of items) { + const score = scoreFn(item); + if (score > best) { + best = score; + } + } + return items.filter((item) => scoreFn(item) === best); +} diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 9e1433832..579b51019 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -50,6 +50,11 @@ import { isAllowedByLists, mostSpecificMatchingAllow } from "@server/lib/aiModelKeyMatch"; +import { + catalogOwnershipScore, + keepBestScored, + providerClassRank +} from "@server/lib/aiProviderSelection"; import { aiGatewayUpstreamFetch } from "@server/lib/aiGatewayUpstreamFetch"; import { getModelPricing, calculateAiCost } from "@server/lib/aiModelPricing"; import { @@ -502,17 +507,28 @@ async function selectProvider( }; } + // 1) Prefer the most specific allow pattern that matched the request. candidates.sort((a, b) => compareModelKeySpecificity(a.modelKey, b.modelKey) ); - const bestSpecificity = candidates[0].modelKey; - const topCandidates = candidates.filter( + let remaining = candidates.filter( (c) => compareModelKeySpecificity(c.modelKey, bestSpecificity) === 0 ); + // 2) Prefer providers whose catalog owns this model id. Aggregators only + // score when the model is known somewhere in the catalog. + remaining = keepBestScored(remaining, (c) => + catalogOwnershipScore(c.provider.type as AiProviderType, requestedModel) + ); + + // 3) Prefer native typed providers over aggregators over custom. + remaining = keepBestScored(remaining, (c) => + providerClassRank(c.provider.type as AiProviderType) + ); + const uniqueProviders = new Map(); - for (const candidate of topCandidates) { + for (const candidate of remaining) { uniqueProviders.set(candidate.provider.providerId, candidate.provider); } @@ -523,7 +539,7 @@ async function selectProvider( return { ok: false, status: HttpCode.FORBIDDEN, - message: `Model "${requestedModel}" is ambiguous across multiple AI providers on this resource` + message: `Model "${requestedModel}" is ambiguous across multiple AI providers on this resource. Ask your administrator to configure a more specific allow pattern for this model.` }; } diff --git a/server/routers/resource/addAiProviderToResource.ts b/server/routers/resource/addAiProviderToResource.ts index f2477d89d..dd0d86227 100644 --- a/server/routers/resource/addAiProviderToResource.ts +++ b/server/routers/resource/addAiProviderToResource.ts @@ -131,8 +131,7 @@ export async function addAiProviderToResource( const attachments = await resolveProviderAttachments({ orgId: resource.orgId, attachments: nextAttachments, - requireAtLeastOne: true, - resourceId + requireAtLeastOne: true }); if (isInferenceFieldsError(attachments)) { return next( diff --git a/server/routers/resource/removeAiProviderFromResource.ts b/server/routers/resource/removeAiProviderFromResource.ts index 5b99ff089..28c877317 100644 --- a/server/routers/resource/removeAiProviderFromResource.ts +++ b/server/routers/resource/removeAiProviderFromResource.ts @@ -134,8 +134,7 @@ export async function removeAiProviderFromResource( const attachments = await resolveProviderAttachments({ orgId: resource.orgId, attachments: remaining, - requireAtLeastOne: false, - resourceId + requireAtLeastOne: false }); if (isInferenceFieldsError(attachments)) { return next( diff --git a/server/routers/resource/setResourceAiProviders.ts b/server/routers/resource/setResourceAiProviders.ts index ef312ac70..b906b47d5 100644 --- a/server/routers/resource/setResourceAiProviders.ts +++ b/server/routers/resource/setResourceAiProviders.ts @@ -113,8 +113,7 @@ export async function setResourceAiProviders( const attachments = await resolveProviderAttachments({ orgId: resource.orgId, attachments: providers, - requireAtLeastOne: false, - resourceId + requireAtLeastOne: false }); if (isInferenceFieldsError(attachments)) { return next( diff --git a/server/routers/siteResource/addAiProviderToSiteResource.ts b/server/routers/siteResource/addAiProviderToSiteResource.ts index 18e8cd74d..0fd93b91c 100644 --- a/server/routers/siteResource/addAiProviderToSiteResource.ts +++ b/server/routers/siteResource/addAiProviderToSiteResource.ts @@ -131,8 +131,7 @@ export async function addAiProviderToSiteResource( const attachments = await resolveProviderAttachments({ orgId: siteResource.orgId, attachments: nextAttachments, - requireAtLeastOne: true, - siteResourceId + requireAtLeastOne: true }); if (isInferenceFieldsError(attachments)) { return next( diff --git a/server/routers/siteResource/removeAiProviderFromSiteResource.ts b/server/routers/siteResource/removeAiProviderFromSiteResource.ts index 29facaece..4e7f4e666 100644 --- a/server/routers/siteResource/removeAiProviderFromSiteResource.ts +++ b/server/routers/siteResource/removeAiProviderFromSiteResource.ts @@ -133,8 +133,7 @@ export async function removeAiProviderFromSiteResource( const attachments = await resolveProviderAttachments({ orgId: siteResource.orgId, attachments: remaining, - requireAtLeastOne: false, - siteResourceId + requireAtLeastOne: false }); if (isInferenceFieldsError(attachments)) { return next( diff --git a/server/routers/siteResource/setSiteResourceAiProviders.ts b/server/routers/siteResource/setSiteResourceAiProviders.ts index a6c8a52c7..c7762adb6 100644 --- a/server/routers/siteResource/setSiteResourceAiProviders.ts +++ b/server/routers/siteResource/setSiteResourceAiProviders.ts @@ -115,8 +115,7 @@ export async function setSiteResourceAiProviders( const attachments = await resolveProviderAttachments({ orgId: siteResource.orgId, attachments: providers, - requireAtLeastOne: false, - siteResourceId + requireAtLeastOne: false }); if (isInferenceFieldsError(attachments)) { return next( From f7aca854171a6b325e40be9de72ee4299e2f44f0 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 11 Aug 2026 16:14:46 -0400 Subject: [PATCH 297/494] link the usage with the session to display together --- messages/en-US.json | 3 ++ server/db/pg/schema/schema.ts | 10 +++- server/db/sqlite/schema/schema.ts | 10 +++- server/lib/aiBudgetEnforcement.ts | 6 +++ server/routers/aiGateway/logAiSession.ts | 4 +- server/routers/aiGateway/pipeline.ts | 11 ++++- server/routers/auditLogs/queryAiSessionLog.ts | 49 ++++++++++++++++++- server/routers/auditLogs/types.ts | 10 ++++ src/app/[orgId]/settings/logs/ai/page.tsx | 34 ++++++++++++- 9 files changed, 129 insertions(+), 8 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index d5ecdf780..44b1bf6a3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3411,6 +3411,9 @@ "aiSessionLogTruncated": "This session was truncated before storage and may be incomplete.", "aiSessionViewRaw": "View Raw JSON", "aiSessionViewChat": "View Chat", + "tokens": "Tokens", + "cost": "Cost", + "estimated": "estimated", "requestAnalyticsDescription": "View detailed request analytics for resources in this organization", "logRetentionRequestLabel": "HTTP Request Log Retention", "logRetentionRequestDescription": "How long to retain request logs", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index cbb1e60cb..afaccc480 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1831,6 +1831,13 @@ export const aiUsageRecords = pgTable( userId: varchar("userId").references(() => users.userId, { onDelete: "set null" }), + // Links this usage record back to the aiSessionLog row for the same + // request (aiSessionLog.sessionId), so token/cost usage can be shown + // alongside the session transcript. Not a DB-level FK - aiSessionLog + // lives in the separate logs database. Nullable because the session + // log may be disabled (retention set to 0) while usage tracking + // stays on. + sessionId: varchar("sessionId"), requestedModel: varchar("requestedModel").notNull(), promptTokens: integer("promptTokens").notNull().default(0), cacheReadTokens: integer("cacheReadTokens").notNull().default(0), @@ -1862,7 +1869,8 @@ export const aiUsageRecords = pgTable( t.orgId, t.userId, t.createdAt - ) + ), + index("idx_ai_usage_records_session").on(t.sessionId) ] ); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 8934638d7..bf3be9f87 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1819,6 +1819,13 @@ export const aiUsageRecords = sqliteTable( userId: text("userId").references(() => users.userId, { onDelete: "set null" }), + // Links this usage record back to the aiSessionLog row for the same + // request (aiSessionLog.sessionId), so token/cost usage can be shown + // alongside the session transcript. Not a DB-level FK - aiSessionLog + // lives in the separate logs database. Nullable because the session + // log may be disabled (retention set to 0) while usage tracking + // stays on. + sessionId: text("sessionId"), requestedModel: text("requestedModel").notNull(), promptTokens: integer("promptTokens").notNull().default(0), cacheReadTokens: integer("cacheReadTokens").notNull().default(0), @@ -1852,7 +1859,8 @@ export const aiUsageRecords = sqliteTable( t.orgId, t.userId, t.createdAt - ) + ), + index("idx_ai_usage_records_session").on(t.sessionId) ] ); diff --git a/server/lib/aiBudgetEnforcement.ts b/server/lib/aiBudgetEnforcement.ts index 4177af9b7..d73f5ba59 100644 --- a/server/lib/aiBudgetEnforcement.ts +++ b/server/lib/aiBudgetEnforcement.ts @@ -428,6 +428,11 @@ export type UsageRecordInput = { usage: AiUsage; costUsd: number | null; createdAt?: number; + // Same id as the aiSessionLog row logged for this request, so the two + // can be joined to show token/cost usage alongside the session + // transcript. Undefined when the session wasn't logged (e.g. session + // log retention disabled for the org). + sessionId?: string; }; type AiUsageRecordInsert = InferInsertModel; @@ -539,6 +544,7 @@ export async function recordUsage(input: UsageRecordInput): Promise { resourceId: input.resourceId, siteResourceId: input.siteResourceId, userId: input.userId, + sessionId: input.sessionId, requestedModel: input.requestedModel, promptTokens: usage.promptTokens, cacheReadTokens: usage.cacheReadTokens, diff --git a/server/routers/aiGateway/logAiSession.ts b/server/routers/aiGateway/logAiSession.ts index 92723bed4..ee05517f5 100644 --- a/server/routers/aiGateway/logAiSession.ts +++ b/server/routers/aiGateway/logAiSession.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "crypto"; import { logsDb, db, orgs, aiSessionLog, type AiProvider } from "@server/db"; import type { InferInsertModel } from "drizzle-orm"; import logger from "@server/logger"; @@ -169,6 +168,7 @@ function truncateBody(value: string): { value: string; truncated: boolean } { } export function logAiSession(data: { + sessionId: string; capability: AiCapability; provider: AiProvider; requestedModel: string | undefined; @@ -230,7 +230,7 @@ export function logAiSession(data: { } sessionLogBuffer.push({ - sessionId: randomUUID(), + sessionId: data.sessionId, orgId: sanitizeString(data.orgId), providerId: data.provider.providerId, capability: data.capability, diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 579b51019..7648e2355 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import { Request, Response } from "express"; import { and, eq, inArray } from "drizzle-orm"; import { @@ -594,6 +595,12 @@ export function recordAiGatewayCompletion(args: { const pricing = getModelPricing(provider.type as AiProviderType, model); const cost = calculateAiCost(pricing, usage); + // Shared by the usage record and the session log so the two can be + // joined later to show token/cost usage alongside the transcript - + // generated up front since neither buffered insert's row id is known + // until its next batch flush. + const sessionId = randomUUID(); + logger.info("AI gateway request usage", { capability, providerId: provider.providerId, @@ -618,7 +625,8 @@ export function recordAiGatewayCompletion(args: { userId: requestUserId, requestedModel: model ?? "unknown", usage, - costUsd: cost?.totalCost ?? null + costUsd: cost?.totalCost ?? null, + sessionId }); if (budgets.length > 0) { @@ -635,6 +643,7 @@ export function recordAiGatewayCompletion(args: { } logAiSession({ + sessionId, capability, provider, requestedModel, diff --git a/server/routers/auditLogs/queryAiSessionLog.ts b/server/routers/auditLogs/queryAiSessionLog.ts index 2412f8980..d61bdf5c8 100644 --- a/server/routers/auditLogs/queryAiSessionLog.ts +++ b/server/routers/auditLogs/queryAiSessionLog.ts @@ -2,6 +2,7 @@ import { logsDb, aiSessionLog, aiProviders, + aiUsageRecords, resources, siteResources, users, @@ -253,6 +254,51 @@ async function enrichWithDetails( } } + const usageMap = new Map< + string, + { + promptTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + completionTokens: number; + reasoningTokens: number; + totalTokens: number; + costUsd: number | null; + estimated: boolean; + } + >(); + const sessionIds = logs.map((log) => log.sessionId); + if (sessionIds.length > 0) { + const usageDetails = await primaryDb + .select({ + sessionId: aiUsageRecords.sessionId, + promptTokens: aiUsageRecords.promptTokens, + cacheReadTokens: aiUsageRecords.cacheReadTokens, + cacheWriteTokens: aiUsageRecords.cacheWriteTokens, + completionTokens: aiUsageRecords.completionTokens, + reasoningTokens: aiUsageRecords.reasoningTokens, + totalTokens: aiUsageRecords.totalTokens, + costUsd: aiUsageRecords.costUsd, + estimated: aiUsageRecords.estimated + }) + .from(aiUsageRecords) + .where(inArray(aiUsageRecords.sessionId, sessionIds)); + + for (const u of usageDetails) { + if (!u.sessionId) continue; + usageMap.set(u.sessionId, { + promptTokens: u.promptTokens, + cacheReadTokens: u.cacheReadTokens, + cacheWriteTokens: u.cacheWriteTokens, + completionTokens: u.completionTokens, + reasoningTokens: u.reasoningTokens, + totalTokens: u.totalTokens, + costUsd: u.costUsd, + estimated: u.estimated + }); + } + } + return logs.map((log) => { const provider = providerMap.get(log.providerId); @@ -281,7 +327,8 @@ async function enrichWithDetails( providerType: provider?.type ?? null, resourceName, resourceNiceId, - userEmail: log.userId ? (userMap.get(log.userId) ?? null) : null + userEmail: log.userId ? (userMap.get(log.userId) ?? null) : null, + usage: usageMap.get(log.sessionId) ?? null }; }); } diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index 42b93904e..b63acbf30 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -119,6 +119,16 @@ export type QueryAiSessionLogResponse = { truncated: boolean; statusCode: number | null; createdAt: number; + usage: { + promptTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + completionTokens: number; + reasoningTokens: number; + totalTokens: number; + costUsd: number | null; + estimated: boolean; + } | null; }[]; pagination: { total: number; diff --git a/src/app/[orgId]/settings/logs/ai/page.tsx b/src/app/[orgId]/settings/logs/ai/page.tsx index 67d18b94c..7330db111 100644 --- a/src/app/[orgId]/settings/logs/ai/page.tsx +++ b/src/app/[orgId]/settings/logs/ai/page.tsx @@ -459,7 +459,7 @@ export default function AiSessionLogsPage() { const renderExpandedRow = (row: any) => { return (
-
+
{t("aiSessionId")}

@@ -472,6 +472,26 @@ export default function AiSessionLogsPage() { {row.statusCode ?? "N/A"}

+
+ {t("tokens")} +

+ {row.usage + ? `${row.usage.totalTokens.toLocaleString()}${ + row.usage.estimated + ? ` (${t("estimated")})` + : "" + }` + : "N/A"} +

+
+
+ {t("cost")} +

+ {row.usage && row.usage.costUsd != null + ? `$${row.usage.costUsd.toFixed(4)}` + : "N/A"} +

+
Date: Tue, 11 Aug 2026 22:25:26 +0200 Subject: [PATCH 298/494] =?UTF-8?q?=E2=9C=A8=20=20test=20alert=20email=20w?= =?UTF-8?q?orks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 + server/emails/templates/AlertNotification.tsx | 33 +++++- .../private/lib/alerts/processTestAlerts.ts | 5 +- server/private/lib/alerts/sendAlertEmail.ts | 49 +++++--- .../routers/alertRule/testAlertRule.ts | 111 +++++++++++++++++- server/private/routers/external.ts | 2 +- server/routers/alertRule/types.ts | 4 +- .../AlertRuleGraphEditor.tsx | 28 +++++ src/lib/getRandomItemInArray.ts | 5 + 9 files changed, 214 insertions(+), 25 deletions(-) create mode 100644 src/lib/getRandomItemInArray.ts diff --git a/messages/en-US.json b/messages/en-US.json index 2dd438a12..05b40d27d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1695,6 +1695,8 @@ "alertingRuleSaved": "Alert rule saved", "alertingRuleSavedCreatedDescription": "Your new alert rule was created. You can keep editing it on this page.", "alertingRuleSavedUpdatedDescription": "Your changes to this alert rule were saved.", + "alertingTestAlertSent": "Test alert sent", + "alertingTestAlertSentDescription": "A test alert was sent to the actions configured on this rule.", "alertingEditRule": "Edit Alert Rule", "alertingCreateRule": "Create Alert Rule", "alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify", diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index ce30753da..c81cf60da 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -31,9 +31,24 @@ export type AlertNotificationProps = { orgId: string; data: Record; dashboardLink: string; + isTestAlert?: boolean; }; -function getEventMeta(eventType: AlertEventType): { +function getEventMeta( + eventType: AlertEventType, + isTestAlert: boolean = false +): { + heading: string; + previewText: string; + summary: string; + statusLabel: string | null; + statusColor: string | null; +} { + const meta = getBaseEventMeta(eventType); + return isTestAlert ? { ...meta, heading: `[TEST] ${meta.heading}` } : meta; +} + +function getBaseEventMeta(eventType: AlertEventType): { heading: string; previewText: string; summary: string; @@ -180,8 +195,14 @@ function formatDataItems( } export const AlertNotification = (props: AlertNotificationProps) => { - const { eventType, orgId, data, dashboardLink } = props; - const meta = getEventMeta(eventType); + const { + eventType, + orgId, + data, + dashboardLink, + isTestAlert = false + } = props; + const meta = getEventMeta(eventType, isTestAlert); const dataItems = formatDataItems(data); const isToggle = @@ -242,6 +263,12 @@ export const AlertNotification = (props: AlertNotificationProps) => { Open your dashboard to view more details and manage your alert rules. + {isTestAlert && ( + + This is a test alert. No action is required, + and no real event has occurred. + + )} diff --git a/server/private/lib/alerts/processTestAlerts.ts b/server/private/lib/alerts/processTestAlerts.ts index 7aa1691f0..1a6c1d7c6 100644 --- a/server/private/lib/alerts/processTestAlerts.ts +++ b/server/private/lib/alerts/processTestAlerts.ts @@ -20,7 +20,10 @@ export async function processTestAlerts(context: TestAlertContext) { try { const recipients = await resolveEmailRecipients(action); if (recipients.length > 0) { - await sendAlertEmail(recipients, context); + await sendAlertEmail(recipients, { + ...context, + isTest: true + }); } } catch (err) { logger.error(`processTestAlerts: failed to send alert email`, err); diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index 6f99b102c..0eef6fb5c 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -15,7 +15,24 @@ import { sendEmail } from "@server/emails"; import AlertNotification from "@server/emails/templates/AlertNotification"; import config from "@server/lib/config"; import logger from "@server/logger"; -import { AlertContext } from "@server/routers/alertRule/types"; +import { + AlertContext, + type AlertEventType +} from "@server/routers/alertRule/types"; + +type EmailAlertContext = { + eventType: AlertEventType; + orgId: string; + /** Set for site_online / site_offline events */ + siteId?: number; + /** Set for health_check_* events */ + healthCheckId?: number; + /** Set for resource_* events */ + resourceId?: number; + /** Human-readable context data included in emails and webhook payloads */ + data: Record; + isTest?: boolean; +}; /** * Sends an alert notification email to every address in `recipients`. @@ -27,7 +44,7 @@ import { AlertContext } from "@server/routers/alertRule/types"; */ export async function sendAlertEmail( recipients: string[], - context: AlertContext + context: EmailAlertContext ): Promise { if (recipients.length === 0) { return; @@ -46,7 +63,8 @@ export async function sendAlertEmail( eventType: context.eventType, orgId: context.orgId, data: context.data, - dashboardLink + dashboardLink, + isTestAlert: context.isTest }), { from, @@ -70,34 +88,35 @@ export async function sendAlertEmail( // Helpers // --------------------------------------------------------------------------- -function buildSubject(context: AlertContext): string { +function buildSubject(context: EmailAlertContext): string { + const prefix = context.isTest ? "[Test Alert]" : "[Alert]"; switch (context.eventType) { case "site_online": - return "[Alert] Site Back Online"; + return `${prefix} Site Back Online`; case "site_offline": - return "[Alert] Site Offline"; + return `${prefix} Site Offline`; case "site_toggle": - return "[Alert] Site Status Changed"; + return `${prefix} Site Status Changed`; case "health_check_healthy": - return "[Alert] Health Check Recovered"; + return `${prefix} Health Check Recovered`; case "health_check_unhealthy": - return "[Alert] Health Check Failing"; + return `${prefix} Health Check Failing`; case "health_check_toggle": - return "[Alert] Health Check Status Changed"; + return `${prefix} Health Check Status Changed`; case "resource_healthy": - return "[Alert] Resource Healthy"; + return `${prefix} Resource Healthy`; case "resource_unhealthy": - return "[Alert] Resource Unhealthy"; + return `${prefix} Resource Unhealthy`; case "resource_degraded": - return "[Alert] Resource Degraded"; + return `${prefix} Resource Degraded`; case "resource_toggle": - return "[Alert] Resource Status Changed"; + return `${prefix} Resource Status Changed`; default: { // Exhaustiveness fallback – should never be reached with a // well-typed caller, but keeps runtime behaviour predictable. const _exhaustive: never = context.eventType; void _exhaustive; - return "[Alert] Event Notification"; + return `${prefix} Event Notification`; } } } diff --git a/server/private/routers/alertRule/testAlertRule.ts b/server/private/routers/alertRule/testAlertRule.ts index 39a5c28a1..6104dbc6b 100644 --- a/server/private/routers/alertRule/testAlertRule.ts +++ b/server/private/routers/alertRule/testAlertRule.ts @@ -27,7 +27,13 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; -import { ListAlertRulesResponse } from "@server/routers/alertRule/types"; +import { + ListAlertRulesResponse, + type AlertAction, + type EmailAlertAction +} from "@server/routers/alertRule/types"; +import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts"; +import { getRandomItemInArray } from "@app/lib/getRandomItemInArray"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -51,12 +57,12 @@ export const RESOURCE_EVENT_TYPES = [ ] as const; const webhookActionSchema = z.strictObject({ - webhookUrl: z.string().url(), + webhookUrl: z.url(), config: z.string().optional(), enabled: z.boolean().optional().default(true) }); -const bodySchema = z.strictObject({ +const bodySchema = z.object({ eventType: z.enum([ ...HC_EVENT_TYPES, ...SITE_EVENT_TYPES, @@ -97,7 +103,106 @@ export async function testAlertRule( ); } + const body = parsedBody.data; + const collectedActions: AlertAction[] = []; + if ( + body.emails.length > 0 || + body.roleIds.length > 0 || + body.userIds.length > 0 + ) { + collectedActions.push({ + type: "email", + emails: body.emails, + roleIds: body.roleIds, + userIds: body.userIds + }); + } + + for (const action of body.webhookActions) { + collectedActions.push({ + type: "webhook", + ...action + }); + } + + let data: Record = {}; + switch (body.eventType) { + case "site_toggle": + data = { + status: getRandomItemInArray(["online", "offline"]), + siteName: "Test Site Alert" + }; + break; + case "site_offline": + data = { + status: "offline", + siteName: "Test Site Alert" + }; + break; + case "site_online": + data = { + status: "online", + siteName: "Test Site Alert" + }; + break; + case "resource_toggle": + data = { + status: getRandomItemInArray([ + "healthy", + "unhealthy", + "degraded" + ]), + siteName: "Test Resource Alert" + }; + break; + case "resource_healthy": + data = { + status: "healthy", + siteName: "Test Resource Alert" + }; + break; + case "resource_unhealthy": + data = { + status: "unhealthy", + siteName: "Test Resource Alert" + }; + break; + case "resource_degraded": + data = { + status: "degraded", + siteName: "Test Resource Alert" + }; + break; + case "health_check_toggle": + data = { + status: getRandomItemInArray(["healthy", "unhealthy"]), + healthCheckName: "Test Health Check Alert" + }; + break; + case "health_check_healthy": + data = { + status: "healthy", + healthCheckName: "Test Health Check Alert" + }; + break; + case "health_check_unhealthy": + data = { + status: "unhealthy", + healthCheckName: "Test Health Check Alert" + }; + break; + + default: + break; + } + // TODO: process alert rule + await processTestAlerts({ + eventType: body.eventType, + orgId, + actions: collectedActions, + data + }); } catch (error) { logger.error(error); return next( diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 0fd4cc023..b880598cc 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -809,7 +809,7 @@ authenticated.get( ); authenticated.post( - "/org/:orgId/alert-rule/test", + "/org/:orgId/test-alert-rule", verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.testAlertRule), diff --git a/server/routers/alertRule/types.ts b/server/routers/alertRule/types.ts index 99057b312..21baa7b2b 100644 --- a/server/routers/alertRule/types.ts +++ b/server/routers/alertRule/types.ts @@ -128,7 +128,7 @@ export interface AlertContext { export type EmailAlertAction = { type: "email"; userIds?: string[]; - roleIds?: string[]; + roleIds?: number[]; emails?: string[]; }; @@ -139,7 +139,7 @@ export type WebhookAlertAction = { config?: string | undefined; }; -type AlertAction = EmailAlertAction | WebhookAlertAction; +export type AlertAction = EmailAlertAction | WebhookAlertAction; export interface TestAlertContext { eventType: AlertEventType; actions: AlertAction[]; diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index a10f8f3b3..243ce82f8 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -189,10 +189,38 @@ export default function AlertRuleGraphEditor({ description: t("alertingNoActionsTestDescription") }); } + return; } const values = form.getValues(); + try { + const payload = formValuesToApiPayload(values); + if (isNew) { + const res = await api.post< + AxiosResponse + >(`/org/${orgId}/test-alert-rule`, payload); + toast({ + title: t("alertingTestAlertSent"), + description: t("alertingTestAlertSentDescription") + }); + } else { + await api.post( + `/org/${orgId}/alert-rule/${alertRuleId}`, + payload + ); + toast({ + title: t("alertingTestAlertSent"), + description: t("alertingTestAlertSentDescription") + }); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } }; const [, formAction, isSaving] = useActionState(saveAlert, null); diff --git a/src/lib/getRandomItemInArray.ts b/src/lib/getRandomItemInArray.ts new file mode 100644 index 000000000..aa5a2e562 --- /dev/null +++ b/src/lib/getRandomItemInArray.ts @@ -0,0 +1,5 @@ +export function getRandomItemInArray(array: T[]) { + // Source - https://stackoverflow.com/a/4550514 + const randomElement = array[Math.floor(Math.random() * array.length)]; + return randomElement; +} From 1782f31075bd20371d08d8d39dbfe882a54d1298 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 11 Aug 2026 16:38:16 -0400 Subject: [PATCH 299/494] show link to provider settings --- messages/en-US.json | 1 + src/components/AiProviderAttachments.tsx | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/messages/en-US.json b/messages/en-US.json index 44b1bf6a3..b0743f631 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1921,6 +1921,7 @@ "aiResourceProvidersUpdated": "Providers updated", "aiResourceProvidersErrorUpdate": "Failed to update providers", "aiResourceProviderEditDescription": "Choose how this provider's models are exposed on this resource.", + "viewProviderSettings": "View Provider Settings", "aiResourceProviderMode": "Access mode", "aiResourceProviderModeInherit": "Inherit", "aiResourceProviderModeSelect": "Select", diff --git a/src/components/AiProviderAttachments.tsx b/src/components/AiProviderAttachments.tsx index 09a4243e1..87d61dd96 100644 --- a/src/components/AiProviderAttachments.tsx +++ b/src/components/AiProviderAttachments.tsx @@ -42,6 +42,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { Plus, XIcon } from "lucide-react"; import { useTranslations } from "next-intl"; +import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -178,6 +179,7 @@ export function AiProviderAttachments({ {editing && ( { @@ -283,11 +285,13 @@ type EditFormValues = { }; function EditAttachmentCredenza({ + orgId, attachment, open, onOpenChange, onSave }: { + orgId: string; attachment: AiProviderAttachmentValue; open: boolean; onOpenChange: (open: boolean) => void; @@ -565,6 +569,18 @@ function EditAttachmentCredenza({ + From f5b10df7cffde575ecca39ded24eebef566bd657 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 11 Aug 2026 16:39:52 -0400 Subject: [PATCH 300/494] fix non-https crypto issue --- src/components/BudgetsEditor.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/BudgetsEditor.tsx b/src/components/BudgetsEditor.tsx index e9dbbaefb..3ccbfad3c 100644 --- a/src/components/BudgetsEditor.tsx +++ b/src/components/BudgetsEditor.tsx @@ -82,10 +82,17 @@ function nextAvailableCombo(rows: BudgetRow[]): { return { unit: "usd", period: "monthly" }; } +function newRowKey(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + export function newBudgetRow(rows: BudgetRow[]): BudgetRow { const combo = nextAvailableCombo(rows); return { - key: crypto.randomUUID(), + key: newRowKey(), amount: "", unit: combo.unit, period: combo.period From 0016b8fce7c9b93c396b7202a417e1951bfccd8e Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 11 Aug 2026 16:40:10 -0400 Subject: [PATCH 301/494] show other tokens on the expanded row --- messages/en-US.json | 10 +++- src/app/[orgId]/settings/logs/ai/page.tsx | 67 +++++++++++++++++++---- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index b0743f631..6444dbdd8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3412,9 +3412,15 @@ "aiSessionLogTruncated": "This session was truncated before storage and may be incomplete.", "aiSessionViewRaw": "View Raw JSON", "aiSessionViewChat": "View Chat", - "tokens": "Tokens", "cost": "Cost", - "estimated": "estimated", + "estimated": "Estimated", + "tokenUsage": "Token Usage", + "promptTokens": "Prompt Tokens", + "cacheReadTokens": "Cache Read Tokens", + "cacheWriteTokens": "Cache Write Tokens", + "completionTokens": "Completion Tokens", + "reasoningTokens": "Reasoning Tokens", + "totalTokens": "Total Tokens", "requestAnalyticsDescription": "View detailed request analytics for resources in this organization", "logRetentionRequestLabel": "HTTP Request Log Retention", "logRetentionRequestDescription": "How long to retain request logs", diff --git a/src/app/[orgId]/settings/logs/ai/page.tsx b/src/app/[orgId]/settings/logs/ai/page.tsx index 7330db111..5d51c118b 100644 --- a/src/app/[orgId]/settings/logs/ai/page.tsx +++ b/src/app/[orgId]/settings/logs/ai/page.tsx @@ -472,18 +472,6 @@ export default function AiSessionLogsPage() { {row.statusCode ?? "N/A"}

-
- {t("tokens")} -

- {row.usage - ? `${row.usage.totalTokens.toLocaleString()}${ - row.usage.estimated - ? ` (${t("estimated")})` - : "" - }` - : "N/A"} -

-
{t("cost")}

@@ -492,7 +480,62 @@ export default function AiSessionLogsPage() { : "N/A"}

+
+ {t("estimated")} +

+ {row.usage + ? row.usage.estimated + ? t("yes") + : t("no") + : "N/A"} +

+
+ {row.usage && ( +
+
+ {t("tokenUsage")} +
+
+
+ {t("promptTokens")} +

+ {row.usage.promptTokens.toLocaleString()} +

+
+
+ {t("cacheReadTokens")} +

+ {row.usage.cacheReadTokens.toLocaleString()} +

+
+
+ {t("cacheWriteTokens")} +

+ {row.usage.cacheWriteTokens.toLocaleString()} +

+
+
+ {t("completionTokens")} +

+ {row.usage.completionTokens.toLocaleString()} +

+
+
+ {t("reasoningTokens")} +

+ {row.usage.reasoningTokens.toLocaleString()} +

+
+
+ {t("totalTokens")} +

+ {row.usage.totalTokens.toLocaleString()} +

+
+
+
+ )} Date: Tue, 11 Aug 2026 17:52:27 -0400 Subject: [PATCH 302/494] basic ai analytics created --- messages/en-US.json | 2 + .../auditLogs/aiUsageAnalyticsShared.ts | 170 ++++++++++ server/routers/auditLogs/index.ts | 5 + .../auditLogs/queryAiUsageFilterOptions.ts | 269 ++++++++++++++++ .../routers/auditLogs/queryAiUsageOverview.ts | 228 ++++++++++++++ .../auditLogs/queryAiUsageProviders.ts | 183 +++++++++++ .../auditLogs/queryAiUsageResources.ts | 222 +++++++++++++ .../auditLogs/queryAiUsageUsersRoles.ts | 271 ++++++++++++++++ server/routers/external.ts | 35 +++ server/routers/integration.ts | 35 +++ .../[orgId]/settings/logs/ai-usage/page.tsx | 30 ++ src/app/navigation.tsx | 11 + src/components/AiUsageAnalyticsData.tsx | 297 ++++++++++++++++++ .../ai-usage-analytics/OverviewTab.tsx | 197 ++++++++++++ .../ai-usage-analytics/ProvidersTab.tsx | 91 ++++++ .../ai-usage-analytics/ResourcesTab.tsx | 99 ++++++ .../ToggleableTrendChart.tsx | 188 +++++++++++ .../ai-usage-analytics/TopEntitiesList.tsx | 92 ++++++ .../ai-usage-analytics/UsersRolesTab.tsx | 166 ++++++++++ src/components/ai-usage-analytics/shared.ts | 64 ++++ src/lib/queries.ts | 166 +++++++++- 21 files changed, 2820 insertions(+), 1 deletion(-) create mode 100644 server/routers/auditLogs/aiUsageAnalyticsShared.ts create mode 100644 server/routers/auditLogs/queryAiUsageFilterOptions.ts create mode 100644 server/routers/auditLogs/queryAiUsageOverview.ts create mode 100644 server/routers/auditLogs/queryAiUsageProviders.ts create mode 100644 server/routers/auditLogs/queryAiUsageResources.ts create mode 100644 server/routers/auditLogs/queryAiUsageUsersRoles.ts create mode 100644 src/app/[orgId]/settings/logs/ai-usage/page.tsx create mode 100644 src/components/AiUsageAnalyticsData.tsx create mode 100644 src/components/ai-usage-analytics/OverviewTab.tsx create mode 100644 src/components/ai-usage-analytics/ProvidersTab.tsx create mode 100644 src/components/ai-usage-analytics/ResourcesTab.tsx create mode 100644 src/components/ai-usage-analytics/ToggleableTrendChart.tsx create mode 100644 src/components/ai-usage-analytics/TopEntitiesList.tsx create mode 100644 src/components/ai-usage-analytics/UsersRolesTab.tsx create mode 100644 src/components/ai-usage-analytics/shared.ts diff --git a/messages/en-US.json b/messages/en-US.json index 6444dbdd8..166ca8a4b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3397,6 +3397,8 @@ "aiSessionLogsDescription": "View prompt and response transcripts for AI gateway requests in this organization", "sidebarLogsAi": "AI Session Logs", "commandLogsAi": "AI Session Logs", + "sidebarLogsAiUsage": "AI Usage Analytics", + "commandLogsAiUsage": "AI Usage Analytics", "provider": "Provider", "capability": "Capability", "model": "Model", diff --git a/server/routers/auditLogs/aiUsageAnalyticsShared.ts b/server/routers/auditLogs/aiUsageAnalyticsShared.ts new file mode 100644 index 000000000..95a6f825b --- /dev/null +++ b/server/routers/auditLogs/aiUsageAnalyticsShared.ts @@ -0,0 +1,170 @@ +import { and, eq, gte, lte, or, inArray, sql } from "drizzle-orm"; +import { aiUsageRecords, userOrgRoles, driver, db } from "@server/db"; +import { z } from "zod"; +import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; + +// Cap on how many distinct series a trend chart will plot before collapsing +// the remainder into an "other" bucket - matches the theme's 5 categorical +// chart colors (--chart-1..--chart-5). +export const TOP_N = 5; + +// Same guard used by queryRequestAnalytics/queryAiSessionLog for distinct +// breakdown lists. +export const DISTINCT_LIMIT = 500; + +export const aiUsageAnalyticsFiltersQuery = z.object({ + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeStart must be a valid ISO date string" + }) + .transform((val) => new Date(val).getTime()) + .prefault(() => getSevenDaysAgo().toISOString()) + .openapi({ + type: "string", + format: "date-time", + description: + "Start time as ISO date string (defaults to 7 days ago)" + }), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeEnd must be a valid ISO date string" + }) + .transform((val) => new Date(val).getTime()) + .prefault(() => new Date().toISOString()) + .openapi({ + type: "string", + format: "date-time", + description: + "End time as ISO date string (defaults to current time)" + }), + providerId: z + .string() + .optional() + .transform(Number) + .pipe(z.int().positive()) + .optional(), + model: z.string().optional(), + resourceId: z + .string() + .optional() + .transform(Number) + .pipe(z.int().positive()) + .optional(), + roleId: z + .string() + .optional() + .transform(Number) + .pipe(z.int().positive()) + .optional(), + userId: z.string().optional() +}); + +export const aiUsageAnalyticsParams = z.object({ + orgId: z.string() +}); + +export const aiUsageAnalyticsCombined = aiUsageAnalyticsFiltersQuery.merge( + aiUsageAnalyticsParams +); + +export type AiUsageAnalyticsQuery = z.infer; + +// A role has no column on aiUsageRecords - it's derived by resolving the +// role's members to userIds first, then filtering on userId. If the role has +// no members we still need the filter to exclude everything rather than be +// silently ignored, hence the sentinel value. +export async function resolveRoleUserIds( + orgId: string, + roleId?: number +): Promise { + if (!roleId) { + return undefined; + } + + const rows = await db + .select({ userId: userOrgRoles.userId }) + .from(userOrgRoles) + .where( + and(eq(userOrgRoles.orgId, orgId), eq(userOrgRoles.roleId, roleId)) + ); + + return rows.length > 0 + ? rows.map((r) => r.userId) + : ["__no_users_in_role__"]; +} + +export function buildAiUsageWhere( + data: AiUsageAnalyticsQuery, + roleUserIds?: string[] +) { + return and( + eq(aiUsageRecords.orgId, data.orgId), + gte(aiUsageRecords.createdAt, data.timeStart), + lte(aiUsageRecords.createdAt, data.timeEnd), + data.providerId + ? eq(aiUsageRecords.providerId, data.providerId) + : undefined, + data.model ? eq(aiUsageRecords.requestedModel, data.model) : undefined, + data.resourceId + ? or( + eq(aiUsageRecords.resourceId, data.resourceId), + eq(aiUsageRecords.siteResourceId, data.resourceId) + ) + : undefined, + data.userId ? eq(aiUsageRecords.userId, data.userId) : undefined, + roleUserIds ? inArray(aiUsageRecords.userId, roleUserIds) : undefined + ); +} + +// Buckets createdAt (epoch ms) down to a per-day string, dialect-aware, same +// approach as the DATE_TRUNC/DATE branch in queryRequestAnalytics.ts. +export function dayBucketExpr() { + return driver === "pg" + ? sql`DATE_TRUNC('day', TO_TIMESTAMP(${aiUsageRecords.createdAt} / 1000.0))` + : sql`DATE(${aiUsageRecords.createdAt} / 1000, 'unixepoch')`; +} + +export type DailyMetricRow = { + day: string; + key: K; + value: number; +}; + +// Ranks dimension keys by total value and returns the top N. +export function pickTopNKeys( + totals: Map, + n: number = TOP_N +): K[] { + return [...totals.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, n) + .map(([key]) => key); +} + +export interface DayValueRow { + day: string; + [seriesKey: string]: number | string; +} + +// Collapses per-day, per-key rows into a per-day series object, folding +// anything outside `topKeys` into a shared "other" series. +export function bucketTopNPerDay( + rows: DailyMetricRow[], + topKeys: K[] +): DayValueRow[] { + const topSet = new Set(topKeys); + const byDay = new Map>(); + + for (const row of rows) { + const seriesKey = topSet.has(row.key) ? row.key : "other"; + const dayEntry = byDay.get(row.day) ?? {}; + dayEntry[seriesKey] = (dayEntry[seriesKey] ?? 0) + row.value; + byDay.set(row.day, dayEntry); + } + + return [...byDay.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, values]) => ({ day, ...values })); +} diff --git a/server/routers/auditLogs/index.ts b/server/routers/auditLogs/index.ts index 51d970360..12735d650 100644 --- a/server/routers/auditLogs/index.ts +++ b/server/routers/auditLogs/index.ts @@ -3,3 +3,8 @@ export * from "./queryRequestAnalytics"; export * from "./exportRequestAuditLog"; export * from "./queryAiSessionLog"; export * from "./exportAiSessionLog"; +export * from "./queryAiUsageFilterOptions"; +export * from "./queryAiUsageOverview"; +export * from "./queryAiUsageProviders"; +export * from "./queryAiUsageResources"; +export * from "./queryAiUsageUsersRoles"; diff --git a/server/routers/auditLogs/queryAiUsageFilterOptions.ts b/server/routers/auditLogs/queryAiUsageFilterOptions.ts new file mode 100644 index 000000000..46cd3199e --- /dev/null +++ b/server/routers/auditLogs/queryAiUsageFilterOptions.ts @@ -0,0 +1,269 @@ +import { + db, + aiUsageRecords, + aiProviders, + resources, + siteResources, + users, + roles, + userOrgRoles +} from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { and, eq, gte, lte, inArray, isNull, not } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; +import { DISTINCT_LIMIT } from "./aiUsageAnalyticsShared"; + +const queryAiUsageFilterOptionsQuery = z.object({ + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeStart must be a valid ISO date string" + }) + .transform((val) => new Date(val).getTime()) + .prefault(() => getSevenDaysAgo().toISOString()), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeEnd must be a valid ISO date string" + }) + .transform((val) => new Date(val).getTime()) + .prefault(() => new Date().toISOString()) +}); + +const queryAiUsageFilterOptionsParams = z.object({ + orgId: z.string() +}); + +const queryAiUsageFilterOptionsCombined = + queryAiUsageFilterOptionsQuery.merge(queryAiUsageFilterOptionsParams); +type Q = z.infer; + +function sortNamedFilterOptions( + items: T[] +): T[] { + return [...items].sort((a, b) => { + const nameA = a.name ?? ""; + const nameB = b.name ?? ""; + + if (nameA < nameB) return -1; + if (nameA > nameB) return 1; + + return a.id - b.id; + }); +} + +async function query(data: Q) { + const baseConditions = and( + eq(aiUsageRecords.orgId, data.orgId), + gte(aiUsageRecords.createdAt, data.timeStart), + lte(aiUsageRecords.createdAt, data.timeEnd) + ); + + const [ + uniqueProviders, + uniqueModels, + uniqueResources, + uniqueSiteResources, + uniqueUsers + ] = await Promise.all([ + db + .selectDistinct({ id: aiUsageRecords.providerId }) + .from(aiUsageRecords) + .where(baseConditions) + .limit(DISTINCT_LIMIT + 1), + db + .selectDistinct({ model: aiUsageRecords.requestedModel }) + .from(aiUsageRecords) + .where(baseConditions) + .limit(DISTINCT_LIMIT + 1), + db + .selectDistinct({ id: aiUsageRecords.resourceId }) + .from(aiUsageRecords) + .where(and(baseConditions, not(isNull(aiUsageRecords.resourceId)))) + .limit(DISTINCT_LIMIT + 1), + db + .selectDistinct({ id: aiUsageRecords.siteResourceId }) + .from(aiUsageRecords) + .where( + and( + baseConditions, + isNull(aiUsageRecords.resourceId), + not(isNull(aiUsageRecords.siteResourceId)) + ) + ) + .limit(DISTINCT_LIMIT + 1), + db + .selectDistinct({ userId: aiUsageRecords.userId }) + .from(aiUsageRecords) + .where(and(baseConditions, not(isNull(aiUsageRecords.userId)))) + .limit(DISTINCT_LIMIT + 1) + ]); + + const models = uniqueModels + .map((row) => row.model) + .filter((model): model is string => model !== null) + .sort(); + + const providerIds = uniqueProviders + .map((row) => row.id) + .filter((id): id is number => id !== null); + + let providers: Array<{ id: number; name: string | null }> = []; + if (providerIds.length > 0) { + const providerDetails = await db + .select({ providerId: aiProviders.providerId, name: aiProviders.name }) + .from(aiProviders) + .where(inArray(aiProviders.providerId, providerIds)); + + providers = providerDetails.map((p) => ({ id: p.providerId, name: p.name })); + } + + const resourceIds = uniqueResources + .map((row) => row.id) + .filter((id): id is number => id !== null); + const siteResourceIds = uniqueSiteResources + .map((row) => row.id) + .filter((id): id is number => id !== null); + + let resourcesWithNames: Array<{ id: number; name: string | null }> = []; + if (resourceIds.length > 0) { + const resourceDetails = await db + .select({ resourceId: resources.resourceId, name: resources.name }) + .from(resources) + .where(inArray(resources.resourceId, resourceIds)); + + resourcesWithNames = resourcesWithNames.concat( + resourceDetails.map((r) => ({ id: r.resourceId, name: r.name })) + ); + } + if (siteResourceIds.length > 0) { + const siteResourceDetails = await db + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + + resourcesWithNames = resourcesWithNames.concat( + siteResourceDetails.map((r) => ({ id: r.siteResourceId, name: r.name })) + ); + } + + const userIds = uniqueUsers + .map((row) => row.userId) + .filter((id): id is string => id !== null); + + let userList: Array<{ id: string; email: string | null }> = []; + let roleList: Array<{ id: number; name: string | null }> = []; + if (userIds.length > 0) { + const userDetails = await db + .select({ userId: users.userId, email: users.email }) + .from(users) + .where(inArray(users.userId, userIds)); + userList = userDetails.map((u) => ({ id: u.userId, email: u.email })); + + const roleRows = await db + .select({ roleId: roles.roleId, name: roles.name }) + .from(userOrgRoles) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.orgId, data.orgId), + inArray(userOrgRoles.userId, userIds) + ) + ); + + const roleMap = new Map(); + for (const r of roleRows) { + roleMap.set(r.roleId, r.name); + } + roleList = [...roleMap.entries()].map(([id, name]) => ({ id, name })); + } + + return { + providers: sortNamedFilterOptions(providers), + resources: sortNamedFilterOptions(resourcesWithNames), + roles: sortNamedFilterOptions(roleList), + users: userList, + models + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/ai/usage/filters", + description: + "Query the distinct filter options (providers, models, resources, roles, users) available for AI usage analytics within a time range", + tags: [OpenAPITags.Logs], + request: { + query: queryAiUsageFilterOptionsQuery, + params: queryAiUsageFilterOptionsParams + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export type QueryAiUsageFilterOptionsResponse = Awaited>; + +export async function queryAiUsageFilterOptions( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAiUsageFilterOptionsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error)) + ); + } + + const parsedParams = queryAiUsageFilterOptionsParams.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error)) + ); + } + + const data = await query({ ...parsedQuery.data, ...parsedParams.data }); + + return response(res, { + data, + success: true, + error: false, + message: "AI usage filter options retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/queryAiUsageOverview.ts b/server/routers/auditLogs/queryAiUsageOverview.ts new file mode 100644 index 000000000..57ab55539 --- /dev/null +++ b/server/routers/auditLogs/queryAiUsageOverview.ts @@ -0,0 +1,228 @@ +import { db, aiUsageRecords } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { and, count, desc, eq, sql } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { + aiUsageAnalyticsFiltersQuery, + aiUsageAnalyticsParams, + aiUsageAnalyticsCombined, + buildAiUsageWhere, + resolveRoleUserIds, + dayBucketExpr, + pickTopNKeys, + bucketTopNPerDay, + DISTINCT_LIMIT, + type AiUsageAnalyticsQuery +} from "./aiUsageAnalyticsShared"; + +type Q = AiUsageAnalyticsQuery; + +async function query(data: Q) { + const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId); + const baseConditions = buildAiUsageWhere(data, roleUserIds); + + const [totalsRow] = await db + .select({ + requests: count(), + promptTokens: sql`COALESCE(SUM(${aiUsageRecords.promptTokens}), 0)`, + cacheReadTokens: sql`COALESCE(SUM(${aiUsageRecords.cacheReadTokens}), 0)`, + cacheWriteTokens: sql`COALESCE(SUM(${aiUsageRecords.cacheWriteTokens}), 0)`, + completionTokens: sql`COALESCE(SUM(${aiUsageRecords.completionTokens}), 0)`, + reasoningTokens: sql`COALESCE(SUM(${aiUsageRecords.reasoningTokens}), 0)`, + totalTokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`, + costUsd: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`, + estimatedRequests: sql`SUM(CASE WHEN ${aiUsageRecords.estimated} THEN 1 ELSE 0 END)` + }) + .from(aiUsageRecords) + .where(baseConditions); + + const dayExpr = dayBucketExpr(); + + const requestsPerDay = await db + .select({ + day: dayExpr.as("day"), + requests: count() + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr) + .orderBy(dayExpr); + + const tokensPerDay = await db + .select({ + day: dayExpr.as("day"), + promptTokens: sql`COALESCE(SUM(${aiUsageRecords.promptTokens}), 0)`, + cacheReadTokens: sql`COALESCE(SUM(${aiUsageRecords.cacheReadTokens}), 0)`, + cacheWriteTokens: sql`COALESCE(SUM(${aiUsageRecords.cacheWriteTokens}), 0)`, + completionTokens: sql`COALESCE(SUM(${aiUsageRecords.completionTokens}), 0)`, + reasoningTokens: sql`COALESCE(SUM(${aiUsageRecords.reasoningTokens}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr) + .orderBy(dayExpr); + + const costPerDay = await db + .select({ + day: dayExpr.as("day"), + cost: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr) + .orderBy(dayExpr); + + const modelByDay = await db + .select({ + day: dayExpr.as("day"), + model: aiUsageRecords.requestedModel, + cost: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`, + tokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr, aiUsageRecords.requestedModel) + .orderBy(dayExpr); + + const modelCostTotals = new Map(); + const modelTokenTotals = new Map(); + for (const row of modelByDay) { + modelCostTotals.set( + row.model, + (modelCostTotals.get(row.model) ?? 0) + row.cost + ); + modelTokenTotals.set( + row.model, + (modelTokenTotals.get(row.model) ?? 0) + row.tokens + ); + } + + const topModelsByCost = pickTopNKeys(modelCostTotals); + const topModelsByTokens = pickTopNKeys(modelTokenTotals); + + const modelCostPerDay = bucketTopNPerDay( + modelByDay.map((r) => ({ day: r.day, key: r.model, value: r.cost })), + topModelsByCost + ); + const modelTokensPerDay = bucketTopNPerDay( + modelByDay.map((r) => ({ day: r.day, key: r.model, value: r.tokens })), + topModelsByTokens + ); + + const topModelsRaw = await db + .select({ + model: aiUsageRecords.requestedModel, + requests: count(), + totalTokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`, + costUsd: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(aiUsageRecords.requestedModel) + .orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`)) + .limit(DISTINCT_LIMIT + 1); + + if (topModelsRaw.length > DISTINCT_LIMIT) { + throw createHttpError( + HttpCode.BAD_REQUEST, + "Too many distinct models. Please narrow your query." + ); + } + + return { + totalRequests: totalsRow.requests, + totalTokens: totalsRow.totalTokens, + totalCost: totalsRow.costUsd, + estimatedPercent: + totalsRow.requests > 0 + ? (totalsRow.estimatedRequests / totalsRow.requests) * 100 + : 0, + tokenBreakdown: { + promptTokens: totalsRow.promptTokens, + cacheReadTokens: totalsRow.cacheReadTokens, + cacheWriteTokens: totalsRow.cacheWriteTokens, + completionTokens: totalsRow.completionTokens, + reasoningTokens: totalsRow.reasoningTokens + }, + requestsPerDay, + tokensPerDay, + costPerDay, + modelCostPerDay, + modelTokensPerDay, + topModels: topModelsRaw + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/ai/usage/overview", + description: "Query the AI usage analytics overview for an organization", + tags: [OpenAPITags.Logs], + request: { + query: aiUsageAnalyticsFiltersQuery, + params: aiUsageAnalyticsParams + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export type QueryAiUsageOverviewResponse = Awaited>; + +export async function queryAiUsageOverview( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error)) + ); + } + + const parsedParams = aiUsageAnalyticsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error)) + ); + } + + const data = await query({ ...parsedQuery.data, ...parsedParams.data }); + + return response(res, { + data, + success: true, + error: false, + message: "AI usage overview retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/queryAiUsageProviders.ts b/server/routers/auditLogs/queryAiUsageProviders.ts new file mode 100644 index 000000000..77df6330d --- /dev/null +++ b/server/routers/auditLogs/queryAiUsageProviders.ts @@ -0,0 +1,183 @@ +import { db, aiUsageRecords, aiProviders } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { count, desc, inArray, sql } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { + aiUsageAnalyticsFiltersQuery, + aiUsageAnalyticsParams, + buildAiUsageWhere, + resolveRoleUserIds, + dayBucketExpr, + pickTopNKeys, + bucketTopNPerDay, + DISTINCT_LIMIT, + type AiUsageAnalyticsQuery +} from "./aiUsageAnalyticsShared"; + +type Q = AiUsageAnalyticsQuery; + +async function query(data: Q) { + const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId); + const baseConditions = buildAiUsageWhere(data, roleUserIds); + const dayExpr = dayBucketExpr(); + + const providerByDay = await db + .select({ + day: dayExpr.as("day"), + providerId: aiUsageRecords.providerId, + cost: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`, + tokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr, aiUsageRecords.providerId) + .orderBy(dayExpr); + + const costTotals = new Map(); + const tokenTotals = new Map(); + for (const row of providerByDay) { + const key = String(row.providerId); + costTotals.set(key, (costTotals.get(key) ?? 0) + row.cost); + tokenTotals.set(key, (tokenTotals.get(key) ?? 0) + row.tokens); + } + + const topByCost = pickTopNKeys(costTotals); + const topByTokens = pickTopNKeys(tokenTotals); + + const providerCostPerDay = bucketTopNPerDay( + providerByDay.map((r) => ({ + day: r.day, + key: String(r.providerId), + value: r.cost + })), + topByCost + ); + const providerTokensPerDay = bucketTopNPerDay( + providerByDay.map((r) => ({ + day: r.day, + key: String(r.providerId), + value: r.tokens + })), + topByTokens + ); + + const topProvidersRaw = await db + .select({ + providerId: aiUsageRecords.providerId, + requests: count(), + totalTokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`, + costUsd: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(aiUsageRecords.providerId) + .orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`)) + .limit(DISTINCT_LIMIT + 1); + + if (topProvidersRaw.length > DISTINCT_LIMIT) { + throw createHttpError( + HttpCode.BAD_REQUEST, + "Too many distinct providers. Please narrow your query." + ); + } + + const providerIds = topProvidersRaw.map((r) => r.providerId); + const nameMap = new Map(); + if (providerIds.length > 0) { + const providerDetails = await db + .select({ providerId: aiProviders.providerId, name: aiProviders.name }) + .from(aiProviders) + .where(inArray(aiProviders.providerId, providerIds)); + for (const p of providerDetails) { + nameMap.set(p.providerId, p.name); + } + } + + const topProviders = topProvidersRaw.map((r) => ({ + providerId: r.providerId, + name: nameMap.get(r.providerId) ?? null, + requests: r.requests, + totalTokens: r.totalTokens, + costUsd: r.costUsd + })); + + return { + providerCostPerDay, + providerTokensPerDay, + topProviders + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/ai/usage/providers", + description: "Query the AI usage analytics provider breakdown for an organization", + tags: [OpenAPITags.Logs], + request: { + query: aiUsageAnalyticsFiltersQuery, + params: aiUsageAnalyticsParams + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export type QueryAiUsageProvidersResponse = Awaited>; + +export async function queryAiUsageProviders( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error)) + ); + } + + const parsedParams = aiUsageAnalyticsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error)) + ); + } + + const data = await query({ ...parsedQuery.data, ...parsedParams.data }); + + return response(res, { + data, + success: true, + error: false, + message: "AI usage provider breakdown retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/queryAiUsageResources.ts b/server/routers/auditLogs/queryAiUsageResources.ts new file mode 100644 index 000000000..fb2fddbc4 --- /dev/null +++ b/server/routers/auditLogs/queryAiUsageResources.ts @@ -0,0 +1,222 @@ +import { db, aiUsageRecords, resources, siteResources } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { count, desc, inArray, sql } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { + aiUsageAnalyticsFiltersQuery, + aiUsageAnalyticsParams, + buildAiUsageWhere, + resolveRoleUserIds, + dayBucketExpr, + pickTopNKeys, + bucketTopNPerDay, + DISTINCT_LIMIT, + type AiUsageAnalyticsQuery +} from "./aiUsageAnalyticsShared"; + +type Q = AiUsageAnalyticsQuery; + +// Composite key namespacing resourceId ("r:") vs siteResourceId ("s:") since +// the two id spaces are independent and can overlap numerically. +function resourceKey(resourceId: number | null, siteResourceId: number | null) { + if (resourceId != null) return `r:${resourceId}`; + if (siteResourceId != null) return `s:${siteResourceId}`; + return "none"; +} + +async function query(data: Q) { + const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId); + const baseConditions = buildAiUsageWhere(data, roleUserIds); + const dayExpr = dayBucketExpr(); + + const resourceByDay = await db + .select({ + day: dayExpr.as("day"), + resourceId: aiUsageRecords.resourceId, + siteResourceId: aiUsageRecords.siteResourceId, + cost: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`, + tokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr, aiUsageRecords.resourceId, aiUsageRecords.siteResourceId) + .orderBy(dayExpr); + + const costTotals = new Map(); + const tokenTotals = new Map(); + for (const row of resourceByDay) { + const key = resourceKey(row.resourceId, row.siteResourceId); + costTotals.set(key, (costTotals.get(key) ?? 0) + row.cost); + tokenTotals.set(key, (tokenTotals.get(key) ?? 0) + row.tokens); + } + + const topByCost = pickTopNKeys(costTotals); + const topByTokens = pickTopNKeys(tokenTotals); + + const resourceCostPerDay = bucketTopNPerDay( + resourceByDay.map((r) => ({ + day: r.day, + key: resourceKey(r.resourceId, r.siteResourceId), + value: r.cost + })), + topByCost + ); + const resourceTokensPerDay = bucketTopNPerDay( + resourceByDay.map((r) => ({ + day: r.day, + key: resourceKey(r.resourceId, r.siteResourceId), + value: r.tokens + })), + topByTokens + ); + + const topResourcesRaw = await db + .select({ + resourceId: aiUsageRecords.resourceId, + siteResourceId: aiUsageRecords.siteResourceId, + requests: count(), + totalTokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`, + costUsd: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(aiUsageRecords.resourceId, aiUsageRecords.siteResourceId) + .orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`)) + .limit(DISTINCT_LIMIT + 1); + + if (topResourcesRaw.length > DISTINCT_LIMIT) { + throw createHttpError( + HttpCode.BAD_REQUEST, + "Too many distinct resources. Please narrow your query." + ); + } + + const resourceIds = topResourcesRaw + .map((r) => r.resourceId) + .filter((id): id is number => id !== null); + const siteResourceIds = topResourcesRaw + .map((r) => r.siteResourceId) + .filter((id): id is number => id !== null); + + const nameMap = new Map(); + if (resourceIds.length > 0) { + const resourceDetails = await db + .select({ resourceId: resources.resourceId, name: resources.name }) + .from(resources) + .where(inArray(resources.resourceId, resourceIds)); + for (const r of resourceDetails) { + nameMap.set(`r:${r.resourceId}`, r.name); + } + } + if (siteResourceIds.length > 0) { + const siteResourceDetails = await db + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + for (const r of siteResourceDetails) { + nameMap.set(`s:${r.siteResourceId}`, r.name); + } + } + + const topResources = topResourcesRaw.map((r) => { + const key = resourceKey(r.resourceId, r.siteResourceId); + return { + key, + resourceId: r.resourceId, + siteResourceId: r.siteResourceId, + type: + r.resourceId != null + ? ("public" as const) + : r.siteResourceId != null + ? ("site" as const) + : null, + name: nameMap.get(key) ?? null, + requests: r.requests, + totalTokens: r.totalTokens, + costUsd: r.costUsd + }; + }); + + return { + resourceCostPerDay, + resourceTokensPerDay, + topResources + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/ai/usage/resources", + description: "Query the AI usage analytics resource breakdown for an organization", + tags: [OpenAPITags.Logs], + request: { + query: aiUsageAnalyticsFiltersQuery, + params: aiUsageAnalyticsParams + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export type QueryAiUsageResourcesResponse = Awaited>; + +export async function queryAiUsageResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error)) + ); + } + + const parsedParams = aiUsageAnalyticsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error)) + ); + } + + const data = await query({ ...parsedQuery.data, ...parsedParams.data }); + + return response(res, { + data, + success: true, + error: false, + message: "AI usage resource breakdown retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/queryAiUsageUsersRoles.ts b/server/routers/auditLogs/queryAiUsageUsersRoles.ts new file mode 100644 index 000000000..b98d1d847 --- /dev/null +++ b/server/routers/auditLogs/queryAiUsageUsersRoles.ts @@ -0,0 +1,271 @@ +import { db, aiUsageRecords, users, roles, userOrgRoles } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { and, count, desc, eq, inArray, sql } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { + aiUsageAnalyticsFiltersQuery, + aiUsageAnalyticsParams, + buildAiUsageWhere, + resolveRoleUserIds, + dayBucketExpr, + pickTopNKeys, + bucketTopNPerDay, + DISTINCT_LIMIT, + type AiUsageAnalyticsQuery +} from "./aiUsageAnalyticsShared"; + +type Q = AiUsageAnalyticsQuery; + +const UNKNOWN_USER_KEY = "unknown"; + +async function query(data: Q) { + const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId); + const baseConditions = buildAiUsageWhere(data, roleUserIds); + const dayExpr = dayBucketExpr(); + + // Per (day, user) is the common granularity both the user trend charts and + // the role trend charts are built from - a usage record only stores + // userId, so role totals are derived by expanding each user's usage into + // every role they hold in the org (per-role double counting for + // multi-role users is expected/accepted). + const userByDay = await db + .select({ + day: dayExpr.as("day"), + userId: aiUsageRecords.userId, + cost: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`, + tokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr, aiUsageRecords.userId) + .orderBy(dayExpr); + + const userTotalsRaw = await db + .select({ + userId: aiUsageRecords.userId, + requests: count(), + totalTokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`, + costUsd: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(aiUsageRecords.userId) + .orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`)) + .limit(DISTINCT_LIMIT + 1); + + if (userTotalsRaw.length > DISTINCT_LIMIT) { + throw createHttpError( + HttpCode.BAD_REQUEST, + "Too many distinct users. Please narrow your query." + ); + } + + const userIds = userTotalsRaw + .map((r) => r.userId) + .filter((id): id is string => id !== null); + + const emailMap = new Map(); + if (userIds.length > 0) { + const userDetails = await db + .select({ userId: users.userId, email: users.email }) + .from(users) + .where(inArray(users.userId, userIds)); + for (const u of userDetails) { + emailMap.set(u.userId, u.email); + } + } + + const topUsers = userTotalsRaw.map((r) => ({ + userId: r.userId, + email: r.userId ? emailMap.get(r.userId) ?? null : null, + requests: r.requests, + totalTokens: r.totalTokens, + costUsd: r.costUsd + })); + + const userCostTotals = new Map(); + const userTokenTotals = new Map(); + for (const row of userByDay) { + const key = row.userId ?? UNKNOWN_USER_KEY; + userCostTotals.set(key, (userCostTotals.get(key) ?? 0) + row.cost); + userTokenTotals.set(key, (userTokenTotals.get(key) ?? 0) + row.tokens); + } + const topUsersByCost = pickTopNKeys(userCostTotals); + const topUsersByTokens = pickTopNKeys(userTokenTotals); + + const userCostPerDay = bucketTopNPerDay( + userByDay.map((r) => ({ + day: r.day, + key: r.userId ?? UNKNOWN_USER_KEY, + value: r.cost + })), + topUsersByCost + ); + const userTokensPerDay = bucketTopNPerDay( + userByDay.map((r) => ({ + day: r.day, + key: r.userId ?? UNKNOWN_USER_KEY, + value: r.tokens + })), + topUsersByTokens + ); + + // Resolve every user's role membership(s) in this org so usage can be + // expanded into per-role totals. + const userToRoles = new Map(); + if (userIds.length > 0) { + const roleRows = await db + .select({ + userId: userOrgRoles.userId, + roleId: roles.roleId, + name: roles.name + }) + .from(userOrgRoles) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.orgId, data.orgId), + inArray(userOrgRoles.userId, userIds) + ) + ); + for (const row of roleRows) { + const existing = userToRoles.get(row.userId) ?? []; + existing.push({ roleId: row.roleId, name: row.name }); + userToRoles.set(row.userId, existing); + } + } + + const roleTotals = new Map< + number, + { name: string | null; requests: number; totalTokens: number; costUsd: number } + >(); + for (const r of userTotalsRaw) { + if (!r.userId) continue; + const userRoles = userToRoles.get(r.userId) ?? []; + for (const role of userRoles) { + const existing = roleTotals.get(role.roleId) ?? { + name: role.name, + requests: 0, + totalTokens: 0, + costUsd: 0 + }; + existing.requests += r.requests; + existing.totalTokens += r.totalTokens; + existing.costUsd += r.costUsd; + roleTotals.set(role.roleId, existing); + } + } + + const topRoles = [...roleTotals.entries()] + .map(([roleId, v]) => ({ roleId, ...v })) + .sort((a, b) => b.costUsd - a.costUsd); + + const roleCostRows: { day: string; key: string; value: number }[] = []; + const roleTokenRows: { day: string; key: string; value: number }[] = []; + for (const row of userByDay) { + if (!row.userId) continue; + const userRoles = userToRoles.get(row.userId) ?? []; + for (const role of userRoles) { + roleCostRows.push({ day: row.day, key: String(role.roleId), value: row.cost }); + roleTokenRows.push({ day: row.day, key: String(role.roleId), value: row.tokens }); + } + } + + const roleCostTotals = new Map(); + const roleTokenTotals = new Map(); + for (const row of roleCostRows) { + roleCostTotals.set(row.key, (roleCostTotals.get(row.key) ?? 0) + row.value); + } + for (const row of roleTokenRows) { + roleTokenTotals.set(row.key, (roleTokenTotals.get(row.key) ?? 0) + row.value); + } + const topRolesByCost = pickTopNKeys(roleCostTotals); + const topRolesByTokens = pickTopNKeys(roleTokenTotals); + + const roleCostPerDay = bucketTopNPerDay(roleCostRows, topRolesByCost); + const roleTokensPerDay = bucketTopNPerDay(roleTokenRows, topRolesByTokens); + + return { + topUsers, + userCostPerDay, + userTokensPerDay, + topRoles, + roleCostPerDay, + roleTokensPerDay + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/ai/usage/users-roles", + description: + "Query the AI usage analytics user and role breakdown for an organization", + tags: [OpenAPITags.Logs], + request: { + query: aiUsageAnalyticsFiltersQuery, + params: aiUsageAnalyticsParams + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export type QueryAiUsageUsersRolesResponse = Awaited>; + +export async function queryAiUsageUsersRoles( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error)) + ); + } + + const parsedParams = aiUsageAnalyticsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error)) + ); + } + + const data = await query({ ...parsedQuery.data, ...parsedParams.data }); + + return response(res, { + data, + success: true, + error: false, + message: "AI usage user/role breakdown retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index dd1167d55..b50de6bea 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1502,6 +1502,41 @@ authenticated.get( logs.exportAiSessionLogs ); +authenticated.get( + "/org/:orgId/logs/ai/usage/filters", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageFilterOptions +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/overview", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageOverview +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/providers", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageProviders +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/resources", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageResources +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/users-roles", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageUsersRoles +); + authenticated.get( "/org/:orgId/blueprints", verifyOrgAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 87ec88243..02e1ab987 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -1545,6 +1545,41 @@ authenticated.get( logs.exportAiSessionLogs ); +authenticated.get( + "/org/:orgId/logs/ai/usage/filters", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageFilterOptions +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/overview", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageOverview +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/providers", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageProviders +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/resources", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageResources +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/users-roles", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageUsersRoles +); + authenticated.get( "/org/:orgId/logs/analytics", verifyApiKeyOrgAccess, diff --git a/src/app/[orgId]/settings/logs/ai-usage/page.tsx b/src/app/[orgId]/settings/logs/ai-usage/page.tsx new file mode 100644 index 000000000..f045aead0 --- /dev/null +++ b/src/app/[orgId]/settings/logs/ai-usage/page.tsx @@ -0,0 +1,30 @@ +import { AiUsageAnalyticsData } from "@app/components/AiUsageAnalyticsData"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "AI Usage Analytics" +}; + +export interface AiUsageAnalyticsPageProps { + params: Promise<{ orgId: string }>; +} + +export default async function AiUsageAnalyticsPage( + props: AiUsageAnalyticsPageProps +) { + const orgId = (await props.params).orgId; + + return ( + <> + + +
+ +
+ + ); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 7181fd1c6..6c5e4a935 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -8,6 +8,7 @@ import { Building2, Cable, ChartLine, + Coins, Combine, CreditCard, Fingerprint, @@ -233,6 +234,11 @@ export const orgNavSections = ( href: "/{orgId}/settings/logs/ai", icon: }, + { + title: "sidebarLogsAiUsage", + href: "/{orgId}/settings/logs/ai-usage", + icon: + }, ...(!env?.flags.disableEnterpriseFeatures ? [ { @@ -532,6 +538,11 @@ export const commandBarNavSections = ( href: "/{orgId}/settings/logs/ai", icon: }, + { + title: "commandLogsAiUsage", + href: "/{orgId}/settings/logs/ai-usage", + icon: + }, ...(!env?.flags.disableEnterpriseFeatures ? [ { diff --git a/src/components/AiUsageAnalyticsData.tsx b/src/components/AiUsageAnalyticsData.tsx new file mode 100644 index 000000000..573dc96b1 --- /dev/null +++ b/src/components/AiUsageAnalyticsData.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import { + aiUsageAnalyticsFiltersSchema, + aiUsageAnalyticsQueries, + type AiUsageAnalyticsFilters +} from "@app/lib/queries"; +import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query"; +import { RefreshCw, XIcon } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { DateRangePicker, type DateTimeValue } from "./DateTimePicker"; +import { Button } from "./ui/button"; +import { Card, CardHeader } from "./ui/card"; +import { Label } from "./ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "./ui/select"; +import { Separator } from "./ui/separator"; +import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; +import { HorizontalTabs, type TabItem } from "./HorizontalTabs"; +import { OverviewTab } from "./ai-usage-analytics/OverviewTab"; +import { ProvidersTab } from "./ai-usage-analytics/ProvidersTab"; +import { ResourcesTab } from "./ai-usage-analytics/ResourcesTab"; +import { UsersRolesTab } from "./ai-usage-analytics/UsersRolesTab"; + +export type AiUsageAnalyticsDataProps = { + orgId: string; +}; + +const AI_USAGE_ANALYTICS_QUERY_PREFIX = ["AI_USAGE_ANALYTICS"]; + +export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) { + const searchParams = useSearchParams(); + const path = usePathname(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const filters = aiUsageAnalyticsFiltersSchema.parse( + Object.fromEntries(searchParams.entries()) + ); + + const isEmptySearchParams = Object.values(filters).every( + (v) => v === undefined + ); + + const dateRange = { + startDate: filters.timeStart + ? new Date(filters.timeStart) + : getSevenDaysAgo(), + endDate: filters.timeEnd ? new Date(filters.timeEnd) : new Date() + }; + + const { data: filterOptions } = useQuery( + aiUsageAnalyticsQueries.filterOptions({ + orgId: props.orgId, + filters: { + timeStart: filters.timeStart, + timeEnd: filters.timeEnd + } + }) + ); + + const isFetching = + useIsFetching({ + queryKey: [...AI_USAGE_ANALYTICS_QUERY_PREFIX, props.orgId] + }) > 0; + + function setFilter(key: keyof AiUsageAnalyticsFilters, value?: string) { + const newSearch = new URLSearchParams(searchParams); + newSearch.delete(key); + if (value !== undefined) { + newSearch.set(key, value); + } + router.replace(`${path}?${newSearch.toString()}`); + } + + function handleTimeRangeUpdate(start: DateTimeValue, end: DateTimeValue) { + const newSearch = new URLSearchParams(searchParams); + const timeRegex = + /^(?\d{1,2})\:(?\d{1,2})(\:(?\d{1,2}))?$/; + + if (start.date) { + const startDate = new Date(start.date); + if (start.time) { + const time = timeRegex.exec(start.time); + const groups = time?.groups ?? {}; + startDate.setHours(Number(groups.hours)); + startDate.setMinutes(Number(groups.minutes)); + if (groups.seconds) { + startDate.setSeconds(Number(groups.seconds)); + } + } + newSearch.set("timeStart", startDate.toISOString()); + } + if (end.date) { + const endDate = new Date(end.date); + if (end.time) { + const time = timeRegex.exec(end.time); + const groups = time?.groups ?? {}; + endDate.setHours(Number(groups.hours)); + endDate.setMinutes(Number(groups.minutes)); + if (groups.seconds) { + endDate.setSeconds(Number(groups.seconds)); + } + } + newSearch.set("timeEnd", endDate.toISOString()); + } + router.replace(`${path}?${newSearch.toString()}`); + } + + function getDateTime(date: Date) { + return `${date.getHours()}:${date.getMinutes()}`; + } + + const providerOptions = (filterOptions?.providers ?? []).map((p) => ({ + value: String(p.id), + label: p.name ?? `Provider #${p.id}` + })); + const modelOptions = (filterOptions?.models ?? []).map((m) => ({ + value: m, + label: m + })); + const resourceOptions = (filterOptions?.resources ?? []).map((r) => ({ + value: String(r.id), + label: r.name ?? `Resource #${r.id}` + })); + const roleOptions = (filterOptions?.roles ?? []).map((r) => ({ + value: String(r.id), + label: r.name ?? `Role #${r.id}` + })); + const userOptions = (filterOptions?.users ?? []).map((u) => ({ + value: u.id, + label: u.email ?? u.id + })); + + const tabs: TabItem[] = [ + { title: "Overview", href: "#" }, + { title: "Provider usage", href: "#" }, + { title: "Resources", href: "#" }, + { title: "Users & roles", href: "#" } + ]; + + return ( +
+ + +
+ + + + +
+ setFilter("providerId", v)} + /> + setFilter("model", v)} + /> + setFilter("resourceId", v)} + /> + setFilter("roleId", v)} + /> + setFilter("userId", v)} + /> + + {!isEmptySearchParams && ( + + )} +
+
+
+ +
+
+
+ + + + + + + +
+ ); +} + +type FilterSelectProps = { + id: string; + label: string; + value?: string; + options: { value: string; label: string }[]; + placeholder: string; + onValueChange: (value?: string) => void; +}; + +function FilterSelect(props: FilterSelectProps) { + return ( +
+ + +
+ ); +} diff --git a/src/components/ai-usage-analytics/OverviewTab.tsx b/src/components/ai-usage-analytics/OverviewTab.tsx new file mode 100644 index 000000000..30d4478b1 --- /dev/null +++ b/src/components/ai-usage-analytics/OverviewTab.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { aiUsageAnalyticsQueries } from "@app/lib/queries"; +import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { ToggleableTrendChart } from "./ToggleableTrendChart"; +import { TopEntitiesList, type TopEntity } from "./TopEntitiesList"; +import { + SERIES_COLORS, + buildSeriesFromData, + compactNumberFormatter, + formatCost +} from "./shared"; + +type OverviewTabProps = { + orgId: string; + filters: AiUsageAnalyticsFilters; +}; + +const TOKEN_TYPE_LABELS: Record = { + promptTokens: "Prompt", + cacheReadTokens: "Cache read", + cacheWriteTokens: "Cache write", + completionTokens: "Completion", + reasoningTokens: "Reasoning" +}; + +export function OverviewTab(props: OverviewTabProps) { + const { data, isLoading } = useQuery( + aiUsageAnalyticsQueries.overview({ + orgId: props.orgId, + filters: props.filters + }) + ); + + const requestsSeries = [ + { key: "requests", label: "Requests", color: SERIES_COLORS[0] } + ]; + const tokensSeries = Object.keys(TOKEN_TYPE_LABELS).map((key, i) => ({ + key, + label: TOKEN_TYPE_LABELS[key], + color: SERIES_COLORS[i % SERIES_COLORS.length] + })); + const costSeries = [ + { key: "cost", label: "Cost", color: SERIES_COLORS[0] } + ]; + + const modelCostSeries = buildSeriesFromData( + data?.modelCostPerDay ?? [], + (key) => key + ); + const modelTokensSeries = buildSeriesFromData( + data?.modelTokensPerDay ?? [], + (key) => key + ); + + const topModels: TopEntity[] = (data?.topModels ?? []).map((m) => ({ + key: m.model, + label: m.model, + requests: m.requests, + totalTokens: m.totalTokens, + costUsd: m.costUsd + })); + + return ( +
+ + + + + Total requests + + {data + ? compactNumberFormatter.format( + data.totalRequests + ) + : "--"} + + + + Total tokens + + {data + ? compactNumberFormatter.format( + data.totalTokens + ) + : "--"} + + + + Total cost + + {data ? formatCost(data.totalCost) : "--"} + + + + Estimated + + {data + ? `${Math.round(data.estimatedPercent)}%` + : "--"} + + + + + + +
+ + +

Request volume

+
+ + + +
+ + +

Token usage

+
+ + + +
+ + +

Cost

+
+ + formatCost(v)} + /> + +
+
+ +
+ + +

Model cost

+
+ + formatCost(v)} + /> + +
+ + +

Model tokens

+
+ + + +
+
+ + + +

Top models

+
+ + + +
+
+ ); +} diff --git a/src/components/ai-usage-analytics/ProvidersTab.tsx b/src/components/ai-usage-analytics/ProvidersTab.tsx new file mode 100644 index 000000000..2187ec53f --- /dev/null +++ b/src/components/ai-usage-analytics/ProvidersTab.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { aiUsageAnalyticsQueries } from "@app/lib/queries"; +import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { ToggleableTrendChart } from "./ToggleableTrendChart"; +import { TopEntitiesList, type TopEntity } from "./TopEntitiesList"; +import { buildSeriesFromData, formatCost } from "./shared"; + +type ProvidersTabProps = { + orgId: string; + filters: AiUsageAnalyticsFilters; +}; + +export function ProvidersTab(props: ProvidersTabProps) { + const { data, isLoading } = useQuery( + aiUsageAnalyticsQueries.providers({ + orgId: props.orgId, + filters: props.filters + }) + ); + + const nameByKey = new Map(); + for (const p of data?.topProviders ?? []) { + nameByKey.set(String(p.providerId), p.name ?? `Provider #${p.providerId}`); + } + const labelFor = (key: string) => nameByKey.get(key) ?? `Provider #${key}`; + + const costSeries = buildSeriesFromData( + data?.providerCostPerDay ?? [], + labelFor + ); + const tokensSeries = buildSeriesFromData( + data?.providerTokensPerDay ?? [], + labelFor + ); + + const topProviders: TopEntity[] = (data?.topProviders ?? []).map((p) => ({ + key: String(p.providerId), + label: p.name ?? `Provider #${p.providerId}`, + requests: p.requests, + totalTokens: p.totalTokens, + costUsd: p.costUsd + })); + + return ( +
+ + +

Top providers

+
+ + + +
+ +
+ + +

Provider cost

+
+ + formatCost(v)} + /> + +
+ + +

Provider token usage

+
+ + + +
+
+
+ ); +} diff --git a/src/components/ai-usage-analytics/ResourcesTab.tsx b/src/components/ai-usage-analytics/ResourcesTab.tsx new file mode 100644 index 000000000..39cba68d7 --- /dev/null +++ b/src/components/ai-usage-analytics/ResourcesTab.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { aiUsageAnalyticsQueries } from "@app/lib/queries"; +import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { ToggleableTrendChart } from "./ToggleableTrendChart"; +import { TopEntitiesList, type TopEntity } from "./TopEntitiesList"; +import { buildSeriesFromData, formatCost } from "./shared"; + +type ResourcesTabProps = { + orgId: string; + filters: AiUsageAnalyticsFilters; +}; + +function resourceTypeLabel(type: "public" | "site" | null) { + if (type === "public") return "Resource"; + if (type === "site") return "Site resource"; + return undefined; +} + +export function ResourcesTab(props: ResourcesTabProps) { + const { data, isLoading } = useQuery( + aiUsageAnalyticsQueries.resources({ + orgId: props.orgId, + filters: props.filters + }) + ); + + const nameByKey = new Map(); + for (const r of data?.topResources ?? []) { + nameByKey.set(r.key, r.name ?? r.key); + } + const labelFor = (key: string) => + key === "none" ? "No resource" : (nameByKey.get(key) ?? key); + + const costSeries = buildSeriesFromData( + data?.resourceCostPerDay ?? [], + labelFor + ); + const tokensSeries = buildSeriesFromData( + data?.resourceTokensPerDay ?? [], + labelFor + ); + + const topResources: TopEntity[] = (data?.topResources ?? []).map((r) => ({ + key: r.key, + label: r.name ?? (r.key === "none" ? "No resource" : r.key), + sublabel: resourceTypeLabel(r.type), + requests: r.requests, + totalTokens: r.totalTokens, + costUsd: r.costUsd + })); + + return ( +
+ + +

Top resources

+
+ + + +
+ +
+ + +

Resource cost

+
+ + formatCost(v)} + /> + +
+ + +

Resource token usage

+
+ + + +
+
+
+ ); +} diff --git a/src/components/ai-usage-analytics/ToggleableTrendChart.tsx b/src/components/ai-usage-analytics/ToggleableTrendChart.tsx new file mode 100644 index 000000000..5b6c78c5e --- /dev/null +++ b/src/components/ai-usage-analytics/ToggleableTrendChart.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@app/lib/cn"; +import { BarChart3, LineChart as LineChartIcon, LoaderIcon } from "lucide-react"; +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + XAxis, + YAxis +} from "recharts"; +import { Button } from "@app/components/ui/button"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig +} from "@app/components/ui/chart"; + +export type TrendSeries = { + key: string; + label: string; + color: string; +}; + +export interface TrendChartRow { + day: string; + [seriesKey: string]: number | string; +} + +type ToggleableTrendChartProps = { + data: TrendChartRow[]; + series: TrendSeries[]; + isLoading?: boolean; + valueFormatter?: (value: number) => string; + className?: string; +}; + +const compactFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 1, + notation: "compact", + compactDisplay: "short" +}); + +export function ToggleableTrendChart(props: ToggleableTrendChartProps) { + const [chartType, setChartType] = useState<"bar" | "line">("bar"); + + const valueFormatter = props.valueFormatter ?? compactFormatter.format; + + const chartConfig = props.series.reduce((acc, s) => { + acc[s.key] = { label: s.label, color: s.color }; + return acc; + }, {} as ChartConfig); + + const hasData = props.data.length > 0; + + return ( +
+
+ + +
+ + {!hasData ? ( +
+ {props.isLoading ? ( + <> + + Loading... + + ) : ( + "No data" + )} +
+ ) : ( + + {chartType === "bar" ? ( + + } /> + + formatDay(payload?.[0]?.payload?.day) + } + /> + } + /> + + + + {props.series.map((s) => ( + + ))} + + ) : ( + + } /> + + formatDay(payload?.[0]?.payload?.day) + } + /> + } + /> + + + + {props.series.map((s) => ( + + ))} + + )} + + )} +
+ ); +} + +function formatDay(value: unknown) { + if (typeof value !== "string") return ""; + const date = new Date(value); + if (isNaN(date.getTime())) return value; + return date.toLocaleDateString(undefined, { dateStyle: "medium" }); +} diff --git a/src/components/ai-usage-analytics/TopEntitiesList.tsx b/src/components/ai-usage-analytics/TopEntitiesList.tsx new file mode 100644 index 000000000..9f712363f --- /dev/null +++ b/src/components/ai-usage-analytics/TopEntitiesList.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { LoaderIcon } from "lucide-react"; +import { compactNumberFormatter, formatCost } from "./shared"; + +export type TopEntity = { + key: string; + label: string; + sublabel?: string | null; + requests: number; + totalTokens: number; + costUsd: number | null; +}; + +type TopEntitiesListProps = { + entities: TopEntity[]; + isLoading: boolean; + nameColumnLabel: string; + emptyLabel?: string; +}; + +export function TopEntitiesList(props: TopEntitiesListProps) { + const totalCost = props.entities.reduce( + (sum, e) => sum + (e.costUsd ?? 0), + 0 + ); + + return ( +
+ {props.entities.length > 0 && ( +
+
{props.nameColumnLabel}
+
Requests
+
Tokens
+
Cost
+
%
+
+ )} +
    + {props.entities.length === 0 && ( +
    + {props.isLoading ? ( + <> + + Loading... + + ) : ( + (props.emptyLabel ?? "No data") + )} +
    + )} + {props.entities.map((entity) => { + const percent = + totalCost > 0 ? (entity.costUsd ?? 0) / totalCost : 0; + return ( +
  1. +
    +
    + {entity.label} + {entity.sublabel && ( + + {entity.sublabel} + + )} +
    +
    + {compactNumberFormatter.format(entity.requests)} +
    +
    + {compactNumberFormatter.format( + entity.totalTokens + )} +
    +
    + {formatCost(entity.costUsd)} +
    +
    + {Math.round(percent * 100)}% +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/src/components/ai-usage-analytics/UsersRolesTab.tsx b/src/components/ai-usage-analytics/UsersRolesTab.tsx new file mode 100644 index 000000000..96a814fd6 --- /dev/null +++ b/src/components/ai-usage-analytics/UsersRolesTab.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { aiUsageAnalyticsQueries } from "@app/lib/queries"; +import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { ToggleableTrendChart } from "./ToggleableTrendChart"; +import { TopEntitiesList, type TopEntity } from "./TopEntitiesList"; +import { buildSeriesFromData, formatCost } from "./shared"; + +type UsersRolesTabProps = { + orgId: string; + filters: AiUsageAnalyticsFilters; +}; + +const UNKNOWN_USER_KEY = "unknown"; + +export function UsersRolesTab(props: UsersRolesTabProps) { + const { data, isLoading } = useQuery( + aiUsageAnalyticsQueries.usersRoles({ + orgId: props.orgId, + filters: props.filters + }) + ); + + const roleNameByKey = new Map(); + for (const r of data?.topRoles ?? []) { + roleNameByKey.set(String(r.roleId), r.name ?? `Role #${r.roleId}`); + } + const roleLabelFor = (key: string) => + roleNameByKey.get(key) ?? `Role #${key}`; + + const userEmailByKey = new Map(); + for (const u of data?.topUsers ?? []) { + if (u.userId) { + userEmailByKey.set(u.userId, u.email ?? u.userId); + } + } + const userLabelFor = (key: string) => + key === UNKNOWN_USER_KEY + ? "Unknown user" + : (userEmailByKey.get(key) ?? key); + + const roleCostSeries = buildSeriesFromData( + data?.roleCostPerDay ?? [], + roleLabelFor + ); + const roleTokensSeries = buildSeriesFromData( + data?.roleTokensPerDay ?? [], + roleLabelFor + ); + const userCostSeries = buildSeriesFromData( + data?.userCostPerDay ?? [], + userLabelFor + ); + const userTokensSeries = buildSeriesFromData( + data?.userTokensPerDay ?? [], + userLabelFor + ); + + const topRoles: TopEntity[] = (data?.topRoles ?? []).map((r) => ({ + key: String(r.roleId), + label: r.name ?? `Role #${r.roleId}`, + requests: r.requests, + totalTokens: r.totalTokens, + costUsd: r.costUsd + })); + + const topUsers: TopEntity[] = (data?.topUsers ?? []).map((u) => ({ + key: u.userId ?? UNKNOWN_USER_KEY, + label: u.email ?? u.userId ?? "Unknown user", + requests: u.requests, + totalTokens: u.totalTokens, + costUsd: u.costUsd + })); + + return ( +
+
+

Roles

+ + +

Top roles

+
+ + + +
+
+ + +

Role cost

+
+ + formatCost(v)} + /> + +
+ + +

Role token usage

+
+ + + +
+
+
+ +
+

Users

+ + +

Top users

+
+ + + +
+
+ + +

User cost

+
+ + formatCost(v)} + /> + +
+ + +

User token usage

+
+ + + +
+
+
+
+ ); +} diff --git a/src/components/ai-usage-analytics/shared.ts b/src/components/ai-usage-analytics/shared.ts new file mode 100644 index 000000000..1f551f8fa --- /dev/null +++ b/src/components/ai-usage-analytics/shared.ts @@ -0,0 +1,64 @@ +import type { TrendSeries } from "./ToggleableTrendChart"; + +// Matches the theme's 5 categorical chart colors (--chart-1..--chart-5 in +// src/app/globals.css) - the same ceiling RequestChart already respects. +export const SERIES_COLORS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)" +]; +export const OTHER_COLOR = "var(--muted-foreground)"; +export const OTHER_KEY = "other"; + +// The server already collapsed each day's row down to the top-N dimension +// keys (already ranked) plus an optional "other" bucket - so the full set of +// series can be derived straight from the data's own keys, no separate +// top-list needed. Assigns one categorical color per key, "other" last. +export function buildSeriesFromData( + data: Array>, + labelFor: (key: string) => string +): TrendSeries[] { + const keys = new Set(); + for (const row of data) { + for (const key of Object.keys(row)) { + if (key !== "day" && key !== OTHER_KEY) { + keys.add(key); + } + } + } + + const series: TrendSeries[] = [...keys].map((key, i) => ({ + key, + label: labelFor(key), + color: SERIES_COLORS[i % SERIES_COLORS.length] + })); + + const hasOther = data.some((row) => OTHER_KEY in row); + if (hasOther) { + series.push({ key: OTHER_KEY, label: "Other", color: OTHER_COLOR }); + } + + return series; +} + +export const currencyFormatter = new Intl.NumberFormat(undefined, { + style: "currency", + currency: "USD", + maximumFractionDigits: 2 +}); + +export const compactNumberFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 1, + notation: "compact", + compactDisplay: "short" +}); + +export const exactNumberFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 0 +}); + +export function formatCost(value: number | null | undefined) { + return currencyFormatter.format(value ?? 0); +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 0275d108b..bf7f69b8f 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -6,7 +6,14 @@ import { type BatchedStatusHistoryResponse } from "@server/lib/statusHistory"; import type { ListAlertRulesResponse } from "@server/routers/alertRule/types"; -import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; +import type { + QueryRequestAnalyticsResponse, + QueryAiUsageFilterOptionsResponse, + QueryAiUsageOverviewResponse, + QueryAiUsageProvidersResponse, + QueryAiUsageResourcesResponse, + QueryAiUsageUsersRolesResponse +} from "@server/routers/auditLogs"; import type { QueryAccessAuditLogResponse, QueryActionAuditLogResponse, @@ -928,6 +935,32 @@ export const logAnalyticsFiltersSchema = z.object({ export type LogAnalyticsFilters = z.output; +export const aiUsageAnalyticsFiltersSchema = z.object({ + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeStart must be a valid ISO date string" + }) + .optional() + .catch(undefined), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeEnd must be a valid ISO date string" + }) + .optional() + .catch(undefined), + providerId: z.coerce.number().optional().catch(undefined), + model: z.string().optional().catch(undefined), + resourceId: z.coerce.number().optional().catch(undefined), + roleId: z.coerce.number().optional().catch(undefined), + userId: z.string().optional().catch(undefined) +}); + +export type AiUsageAnalyticsFilters = z.output< + typeof aiUsageAnalyticsFiltersSchema +>; + export const httpLogsFiltersSchema = z.object({ timeStart: z .string() @@ -1242,6 +1275,137 @@ export const logQueries = { }) }; +export const aiUsageAnalyticsQueries = { + filterOptions: ({ + orgId, + filters + }: { + orgId: string; + filters: Pick; + }) => + queryOptions({ + queryKey: ["AI_USAGE_ANALYTICS", orgId, "FILTERS", filters] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/logs/ai/usage/filters`, { + params: filters, + signal + }); + return res.data.data; + } + }), + + overview: ({ + orgId, + filters + }: { + orgId: string; + filters: AiUsageAnalyticsFilters; + }) => + queryOptions({ + queryKey: ["AI_USAGE_ANALYTICS", orgId, "OVERVIEW", filters] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/logs/ai/usage/overview`, { + params: filters, + signal + }); + return res.data.data; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; + } + }), + + providers: ({ + orgId, + filters + }: { + orgId: string; + filters: AiUsageAnalyticsFilters; + }) => + queryOptions({ + queryKey: ["AI_USAGE_ANALYTICS", orgId, "PROVIDERS", filters] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/logs/ai/usage/providers`, { + params: filters, + signal + }); + return res.data.data; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; + } + }), + + resources: ({ + orgId, + filters + }: { + orgId: string; + filters: AiUsageAnalyticsFilters; + }) => + queryOptions({ + queryKey: ["AI_USAGE_ANALYTICS", orgId, "RESOURCES", filters] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/logs/ai/usage/resources`, { + params: filters, + signal + }); + return res.data.data; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; + } + }), + + usersRoles: ({ + orgId, + filters + }: { + orgId: string; + filters: AiUsageAnalyticsFilters; + }) => + queryOptions({ + queryKey: [ + "AI_USAGE_ANALYTICS", + orgId, + "USERS_ROLES", + filters + ] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/logs/ai/usage/users-roles`, { + params: filters, + signal + }); + return res.data.data; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; + } + }) +}; + export const aiProviderQueries = { providerTargets: ({ providerId }: { providerId: number }) => queryOptions({ From 11595f5f96d62aefbac57a99e089a5c99c47e4c2 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 11 Aug 2026 18:02:17 -0400 Subject: [PATCH 303/494] use title case and fix colors --- .../routers/auditLogs/queryAiUsageResources.ts | 14 ++++++++------ src/components/AiUsageAnalyticsData.tsx | 16 ++++++++-------- .../ai-usage-analytics/OverviewTab.tsx | 16 ++++++++-------- .../ai-usage-analytics/ProvidersTab.tsx | 6 +++--- .../ai-usage-analytics/ResourcesTab.tsx | 6 +++--- .../ai-usage-analytics/ToggleableTrendChart.tsx | 4 ++-- .../ai-usage-analytics/UsersRolesTab.tsx | 12 ++++++------ src/components/ui/chart.tsx | 2 +- 8 files changed, 39 insertions(+), 37 deletions(-) diff --git a/server/routers/auditLogs/queryAiUsageResources.ts b/server/routers/auditLogs/queryAiUsageResources.ts index fb2fddbc4..24bac8644 100644 --- a/server/routers/auditLogs/queryAiUsageResources.ts +++ b/server/routers/auditLogs/queryAiUsageResources.ts @@ -24,11 +24,13 @@ import { type Q = AiUsageAnalyticsQuery; -// Composite key namespacing resourceId ("r:") vs siteResourceId ("s:") since -// the two id spaces are independent and can overlap numerically. +// Composite key namespacing resourceId ("r-") vs siteResourceId ("s-") since +// the two id spaces are independent and can overlap numerically. Uses a dash +// rather than a colon so the key stays safe to use as a CSS custom-property +// name suffix (e.g. --color-r-1) on the client. function resourceKey(resourceId: number | null, siteResourceId: number | null) { - if (resourceId != null) return `r:${resourceId}`; - if (siteResourceId != null) return `s:${siteResourceId}`; + if (resourceId != null) return `r-${resourceId}`; + if (siteResourceId != null) return `s-${siteResourceId}`; return "none"; } @@ -113,7 +115,7 @@ async function query(data: Q) { .from(resources) .where(inArray(resources.resourceId, resourceIds)); for (const r of resourceDetails) { - nameMap.set(`r:${r.resourceId}`, r.name); + nameMap.set(`r-${r.resourceId}`, r.name); } } if (siteResourceIds.length > 0) { @@ -125,7 +127,7 @@ async function query(data: Q) { .from(siteResources) .where(inArray(siteResources.siteResourceId, siteResourceIds)); for (const r of siteResourceDetails) { - nameMap.set(`s:${r.siteResourceId}`, r.name); + nameMap.set(`s-${r.siteResourceId}`, r.name); } } diff --git a/src/components/AiUsageAnalyticsData.tsx b/src/components/AiUsageAnalyticsData.tsx index 573dc96b1..879aff1bc 100644 --- a/src/components/AiUsageAnalyticsData.tsx +++ b/src/components/AiUsageAnalyticsData.tsx @@ -140,9 +140,9 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) { const tabs: TabItem[] = [ { title: "Overview", href: "#" }, - { title: "Provider usage", href: "#" }, + { title: "Provider Usage", href: "#" }, { title: "Resources", href: "#" }, - { title: "Users & roles", href: "#" } + { title: "Users & Roles", href: "#" } ]; return ( @@ -175,7 +175,7 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) { label="Provider" value={filters.providerId?.toString()} options={providerOptions} - placeholder="All providers" + placeholder="All Providers" onValueChange={(v) => setFilter("providerId", v)} /> setFilter("model", v)} /> setFilter("resourceId", v)} /> setFilter("roleId", v)} /> setFilter("userId", v)} /> @@ -218,7 +218,7 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) { className="gap-2" > - Reset filters + Reset Filters )}
diff --git a/src/components/ai-usage-analytics/OverviewTab.tsx b/src/components/ai-usage-analytics/OverviewTab.tsx index 30d4478b1..1bf4dab1d 100644 --- a/src/components/ai-usage-analytics/OverviewTab.tsx +++ b/src/components/ai-usage-analytics/OverviewTab.tsx @@ -75,7 +75,7 @@ export function OverviewTab(props: OverviewTabProps) { - Total requests + Total Requests {data ? compactNumberFormatter.format( @@ -85,7 +85,7 @@ export function OverviewTab(props: OverviewTabProps) { - Total tokens + Total Tokens {data ? compactNumberFormatter.format( @@ -95,7 +95,7 @@ export function OverviewTab(props: OverviewTabProps) { - Total cost + Total Cost {data ? formatCost(data.totalCost) : "--"} @@ -115,7 +115,7 @@ export function OverviewTab(props: OverviewTabProps) {
-

Request volume

+

Request Volume

-

Token usage

+

Token Usage

-

Model cost

+

Model Cost

-

Model tokens

+

Model Tokens

-

Top models

+

Top Models

-

Top providers

+

Top Providers

-

Provider cost

+

Provider Cost

-

Provider token usage

+

Provider Token Usage

-

Top resources

+

Top Resources

-

Resource cost

+

Resource Cost

-

Resource token usage

+

Resource Token Usage

@@ -165,7 +165,7 @@ export function ToggleableTrendChart(props: ToggleableTrendChartProps) { Roles -

Top roles

+

Top Roles

-

Role cost

+

Role Cost

-

Role token usage

+

Role Token Usage

Users -

Top users

+

Top Users

-

User cost

+

User Cost

-

User token usage

+

User Token Usage

- + {children}
From 295e38d2af6314b25daf5e264dc9e594c1a97165 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 12 Aug 2026 09:11:10 -0400 Subject: [PATCH 304/494] Remove arbitrary max 50 limit from email whitelist Fix #3568 --- server/lib/blueprints/types.ts | 1 - server/private/routers/policy/createResourcePolicy.ts | 1 - server/routers/policy/setResourcePolicyWhitelist.ts | 1 - server/routers/resource/setResourceWhitelist.ts | 1 - server/routers/user/listUsers.ts | 2 +- 5 files changed, 1 insertion(+), 5 deletions(-) diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 828b3bd15..299819656 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -632,7 +632,6 @@ export const ResourcePolicySchema = z.object({ }) ) ) - .max(50) .transform((v) => v.map((e) => e.toLowerCase())) .optional() .default([]), diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 5cca3f446..8131603dd 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -107,7 +107,6 @@ const createResourcePolicyBodySchema = z.strictObject({ }) ) ) - .max(50) .transform((v) => v.map((e) => e.toLowerCase())) .optional() .default([]), diff --git a/server/routers/policy/setResourcePolicyWhitelist.ts b/server/routers/policy/setResourcePolicyWhitelist.ts index 2cdd94e98..d7e8fa28d 100644 --- a/server/routers/policy/setResourcePolicyWhitelist.ts +++ b/server/routers/policy/setResourcePolicyWhitelist.ts @@ -19,7 +19,6 @@ const setResourcePolicyWhitelistBodySchema = z.strictObject({ }) ) ) - .max(50) .transform((v) => v.map((e) => e.toLowerCase())) }); diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index e6df96de8..ca23cbd4c 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -24,7 +24,6 @@ const setResourceWhitelistBodySchema = z.strictObject({ }) ) ) - .max(50) .transform((v) => v.map((e) => e.toLowerCase())) }); diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index c74f48468..66438eaf3 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -107,7 +107,7 @@ const listUsersSchema = z.strictObject({ .filter((n) => Number.isInteger(n) && n > 0); const unique = [...new Set(nums)]; return unique.length ? unique : undefined; - }, z.array(z.number().int().positive()).max(50).optional()) + }, z.array(z.number().int().positive()).optional()) .openapi({ description: "Filter users who have any of these role ids in the organization (repeat query param)" From ac3402a8b37c2e7c5366f29e78faebd18c4ad50a Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 12 Aug 2026 09:41:42 -0400 Subject: [PATCH 305/494] Adjust structure delete models --- config/models.json | 7559 ---------------------------------- server/lib/aiModelCatalog.ts | 43 +- server/lib/aiModelPricing.ts | 8 +- 3 files changed, 19 insertions(+), 7591 deletions(-) delete mode 100644 config/models.json diff --git a/config/models.json b/config/models.json deleted file mode 100644 index dccd0a370..000000000 --- a/config/models.json +++ /dev/null @@ -1,7559 +0,0 @@ -{ - "data": [ - { - "id": "ai21.j2-mid-v1", - "name": "ai21.j2-mid-v1", - "provider": "bedrock", - "input_cost_per_token": 0.0000125, - "output_cost_per_token": 0.0000125, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "ai21.j2-ultra-v1", - "name": "ai21.j2-ultra-v1", - "provider": "bedrock", - "input_cost_per_token": 0.0000188, - "output_cost_per_token": 0.0000188, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "ai21.jamba-1-5-large-v1:0", - "name": "ai21.jamba-1-5-large-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000008, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "ai21.jamba-1-5-mini-v1:0", - "name": "ai21.jamba-1-5-mini-v1:0", - "provider": "bedrock", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "ai21.jamba-instruct-v1:0", - "name": "ai21.jamba-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 7e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.writer.palmyra-x4-v1:0", - "name": "us.writer.palmyra-x4-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.writer.palmyra-x5-v1:0", - "name": "us.writer.palmyra-x5-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "writer.palmyra-x4-v1:0", - "name": "writer.palmyra-x4-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "writer.palmyra-x5-v1:0", - "name": "writer.palmyra-x5-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "amazon.nova-lite-v1:0", - "name": "amazon.nova-lite-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 6e-8, - "output_cost_per_token": 2.4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "amazon.nova-2-lite-v1:0", - "name": "amazon.nova-2-lite-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "amazon.nova-2-pro-preview-20251202-v1:0", - "name": "amazon.nova-2-pro-preview-20251202-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000021875, - "output_cost_per_token": 0.0000175, - "cache_read_input_token_cost": 5.46875e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "apac.amazon.nova-2-lite-v1:0", - "name": "apac.amazon.nova-2-lite-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 3.3e-7, - "output_cost_per_token": 0.00000275, - "cache_read_input_token_cost": 8.25e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "apac.amazon.nova-2-pro-preview-20251202-v1:0", - "name": "apac.amazon.nova-2-pro-preview-20251202-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000021875, - "output_cost_per_token": 0.0000175, - "cache_read_input_token_cost": 5.46875e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.amazon.nova-2-lite-v1:0", - "name": "eu.amazon.nova-2-lite-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 3.3e-7, - "output_cost_per_token": 0.00000275, - "cache_read_input_token_cost": 8.25e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.amazon.nova-2-pro-preview-20251202-v1:0", - "name": "eu.amazon.nova-2-pro-preview-20251202-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000021875, - "output_cost_per_token": 0.0000175, - "cache_read_input_token_cost": 5.46875e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.amazon.nova-2-lite-v1:0", - "name": "us.amazon.nova-2-lite-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 3.3e-7, - "output_cost_per_token": 0.00000275, - "cache_read_input_token_cost": 8.25e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.amazon.nova-2-pro-preview-20251202-v1:0", - "name": "us.amazon.nova-2-pro-preview-20251202-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000021875, - "output_cost_per_token": 0.0000175, - "cache_read_input_token_cost": 5.46875e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "amazon.nova-micro-v1:0", - "name": "amazon.nova-micro-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 3.5e-8, - "output_cost_per_token": 1.4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "amazon.nova-pro-v1:0", - "name": "amazon.nova-pro-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 8e-7, - "output_cost_per_token": 0.0000032, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "twelvelabs.pegasus-1-2-v1:0", - "name": "twelvelabs.pegasus-1-2-v1:0", - "provider": "bedrock", - "input_cost_per_token": null, - "output_cost_per_token": 0.0000075, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.twelvelabs.pegasus-1-2-v1:0", - "name": "us.twelvelabs.pegasus-1-2-v1:0", - "provider": "bedrock", - "input_cost_per_token": null, - "output_cost_per_token": 0.0000075, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.twelvelabs.pegasus-1-2-v1:0", - "name": "eu.twelvelabs.pegasus-1-2-v1:0", - "provider": "bedrock", - "input_cost_per_token": null, - "output_cost_per_token": 0.0000075, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "amazon.titan-text-express-v1", - "name": "amazon.titan-text-express-v1", - "provider": "bedrock", - "input_cost_per_token": 0.0000013, - "output_cost_per_token": 0.0000017, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "amazon.titan-text-lite-v1", - "name": "amazon.titan-text-lite-v1", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "amazon.titan-text-premier-v1:0", - "name": "amazon.titan-text-premier-v1:0", - "provider": "bedrock", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-3-5-haiku-20241022-v1:0", - "name": "anthropic.claude-3-5-haiku-20241022-v1:0", - "provider": "bedrock", - "input_cost_per_token": 8e-7, - "output_cost_per_token": 0.000004, - "cache_read_input_token_cost": 8e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-haiku-4-5-20251001-v1:0", - "name": "anthropic.claude-haiku-4-5-20251001-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000005, - "cache_read_input_token_cost": 1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-haiku-4-5@20251001", - "name": "anthropic.claude-haiku-4-5@20251001", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000005, - "cache_read_input_token_cost": 1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-3-5-sonnet-20240620-v1:0", - "name": "anthropic.claude-3-5-sonnet-20240620-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-3-5-sonnet-20241022-v2:0", - "name": "anthropic.claude-3-5-sonnet-20241022-v2:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-3-7-sonnet-20240620-v1:0", - "name": "anthropic.claude-3-7-sonnet-20240620-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.0000036, - "output_cost_per_token": 0.000018, - "cache_read_input_token_cost": 3.6e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-3-7-sonnet-20250219-v1:0", - "name": "anthropic.claude-3-7-sonnet-20250219-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-3-haiku-20240307-v1:0", - "name": "anthropic.claude-3-haiku-20240307-v1:0", - "provider": "bedrock", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.00000125, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-3-opus-20240229-v1:0", - "name": "anthropic.claude-3-opus-20240229-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-3-sonnet-20240229-v1:0", - "name": "anthropic.claude-3-sonnet-20240229-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-instant-v1", - "name": "anthropic.claude-instant-v1", - "provider": "bedrock", - "input_cost_per_token": 8e-7, - "output_cost_per_token": 0.0000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-opus-4-1-20250805-v1:0", - "name": "anthropic.claude-opus-4-1-20250805-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-opus-4-20250514-v1:0", - "name": "anthropic.claude-opus-4-20250514-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-opus-4-5-20251101-v1:0", - "name": "anthropic.claude-opus-4-5-20251101-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-opus-4-6-v1", - "name": "anthropic.claude-opus-4-6-v1", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "global.anthropic.claude-opus-4-6-v1", - "name": "global.anthropic.claude-opus-4-6-v1", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-opus-4-6-v1", - "name": "us.anthropic.claude-opus-4-6-v1", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-opus-4-6-v1", - "name": "eu.anthropic.claude-opus-4-6-v1", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "au.anthropic.claude-opus-4-6-v1", - "name": "au.anthropic.claude-opus-4-6-v1", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-opus-4-7", - "name": "anthropic.claude-opus-4-7", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-mythos-preview", - "name": "anthropic.claude-mythos-preview", - "provider": "bedrock", - "input_cost_per_token": 0, - "output_cost_per_token": 0, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "global.anthropic.claude-opus-4-7", - "name": "global.anthropic.claude-opus-4-7", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-opus-4-7", - "name": "us.anthropic.claude-opus-4-7", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-opus-4-7", - "name": "eu.anthropic.claude-opus-4-7", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "au.anthropic.claude-opus-4-7", - "name": "au.anthropic.claude-opus-4-7", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-fable-5", - "name": "anthropic.claude-fable-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00005, - "cache_read_input_token_cost": 0.000001, - "output_cost_per_reasoning_token": null - }, - { - "id": "global.anthropic.claude-fable-5", - "name": "global.anthropic.claude-fable-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00005, - "cache_read_input_token_cost": 0.000001, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-fable-5", - "name": "us.anthropic.claude-fable-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000011, - "output_cost_per_token": 0.000055, - "cache_read_input_token_cost": 0.0000011, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-fable-5", - "name": "eu.anthropic.claude-fable-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000011, - "output_cost_per_token": 0.000055, - "cache_read_input_token_cost": 0.0000011, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-opus-5", - "name": "anthropic.claude-opus-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "global.anthropic.claude-opus-5", - "name": "global.anthropic.claude-opus-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-opus-5", - "name": "us.anthropic.claude-opus-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-opus-5", - "name": "eu.anthropic.claude-opus-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "au.anthropic.claude-opus-5", - "name": "au.anthropic.claude-opus-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "jp.anthropic.claude-opus-5", - "name": "jp.anthropic.claude-opus-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-opus-4-8", - "name": "anthropic.claude-opus-4-8", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "global.anthropic.claude-opus-4-8", - "name": "global.anthropic.claude-opus-4-8", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-opus-4-8", - "name": "us.anthropic.claude-opus-4-8", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-opus-4-8", - "name": "eu.anthropic.claude-opus-4-8", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "au.anthropic.claude-opus-4-8", - "name": "au.anthropic.claude-opus-4-8", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "jp.anthropic.claude-opus-4-8", - "name": "jp.anthropic.claude-opus-4-8", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "jp.anthropic.claude-opus-4-7", - "name": "jp.anthropic.claude-opus-4-7", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-sonnet-5", - "name": "anthropic.claude-sonnet-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "global.anthropic.claude-sonnet-5", - "name": "global.anthropic.claude-sonnet-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-sonnet-5", - "name": "us.anthropic.claude-sonnet-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000022, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": 2.2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-sonnet-5", - "name": "eu.anthropic.claude-sonnet-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000022, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": 2.2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "au.anthropic.claude-sonnet-5", - "name": "au.anthropic.claude-sonnet-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000022, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": 2.2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "jp.anthropic.claude-sonnet-5", - "name": "jp.anthropic.claude-sonnet-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000022, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": 2.2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-sonnet-4-6", - "name": "anthropic.claude-sonnet-4-6", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "global.anthropic.claude-sonnet-4-6", - "name": "global.anthropic.claude-sonnet-4-6", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-sonnet-4-6", - "name": "us.anthropic.claude-sonnet-4-6", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000033, - "output_cost_per_token": 0.0000165, - "cache_read_input_token_cost": 3.3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-sonnet-4-6", - "name": "eu.anthropic.claude-sonnet-4-6", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000033, - "output_cost_per_token": 0.0000165, - "cache_read_input_token_cost": 3.3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "au.anthropic.claude-sonnet-4-6", - "name": "au.anthropic.claude-sonnet-4-6", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000033, - "output_cost_per_token": 0.0000165, - "cache_read_input_token_cost": 3.3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "jp.anthropic.claude-sonnet-4-6", - "name": "jp.anthropic.claude-sonnet-4-6", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000033, - "output_cost_per_token": 0.0000165, - "cache_read_input_token_cost": 3.3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-sonnet-4-20250514-v1:0", - "name": "anthropic.claude-sonnet-4-20250514-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-sonnet-4-5-20250929-v1:0", - "name": "anthropic.claude-sonnet-4-5-20250929-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-v1", - "name": "anthropic.claude-v1", - "provider": "bedrock", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "anthropic.claude-v2:1", - "name": "anthropic.claude-v2:1", - "provider": "bedrock", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "apac.amazon.nova-lite-v1:0", - "name": "apac.amazon.nova-lite-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 6.3e-8, - "output_cost_per_token": 2.52e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "apac.amazon.nova-micro-v1:0", - "name": "apac.amazon.nova-micro-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 3.7e-8, - "output_cost_per_token": 1.48e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "apac.amazon.nova-pro-v1:0", - "name": "apac.amazon.nova-pro-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 8.4e-7, - "output_cost_per_token": 0.00000336, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "apac.anthropic.claude-3-5-sonnet-20240620-v1:0", - "name": "apac.anthropic.claude-3-5-sonnet-20240620-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "apac.anthropic.claude-3-5-sonnet-20241022-v2:0", - "name": "apac.anthropic.claude-3-5-sonnet-20241022-v2:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "apac.anthropic.claude-3-haiku-20240307-v1:0", - "name": "apac.anthropic.claude-3-haiku-20240307-v1:0", - "provider": "bedrock", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.00000125, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "apac.anthropic.claude-haiku-4-5-20251001-v1:0", - "name": "apac.anthropic.claude-haiku-4-5-20251001-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 0.0000055, - "cache_read_input_token_cost": 1.1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "apac.anthropic.claude-3-sonnet-20240229-v1:0", - "name": "apac.anthropic.claude-3-sonnet-20240229-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "apac.anthropic.claude-sonnet-4-20250514-v1:0", - "name": "apac.anthropic.claude-sonnet-4-20250514-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "au.anthropic.claude-sonnet-4-5-20250929-v1:0", - "name": "au.anthropic.claude-sonnet-4-5-20250929-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000033, - "output_cost_per_token": 0.0000165, - "cache_read_input_token_cost": 3.3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/command-r-plus", - "name": "command-r-plus", - "provider": "azure", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/claude-haiku-4-5", - "name": "claude-haiku-4-5", - "provider": "azure_ai", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000005, - "cache_read_input_token_cost": 1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/claude-opus-4-5", - "name": "claude-opus-4-5", - "provider": "azure_ai", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/claude-opus-4-6", - "name": "claude-opus-4-6", - "provider": "azure_ai", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/claude-opus-4-7", - "name": "claude-opus-4-7", - "provider": "azure_ai", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/claude-fable-5", - "name": "claude-fable-5", - "provider": "azure_ai", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00005, - "cache_read_input_token_cost": 0.000001, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/claude-opus-5", - "name": "claude-opus-5", - "provider": "azure_ai", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/claude-opus-4-8", - "name": "claude-opus-4-8", - "provider": "azure_ai", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/claude-opus-4-1", - "name": "claude-opus-4-1", - "provider": "azure_ai", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/claude-sonnet-4-5", - "name": "claude-sonnet-4-5", - "provider": "azure_ai", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/claude-sonnet-5", - "name": "claude-sonnet-5", - "provider": "azure_ai", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/claude-sonnet-4-6", - "name": "claude-sonnet-4-6", - "provider": "azure_ai", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/computer-use-preview", - "name": "computer-use-preview", - "provider": "azure", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/gpt-oss-120b", - "name": "gpt-oss-120b", - "provider": "azure_ai", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/gpt-5.5", - "name": "gpt-5.5", - "provider": "azure_ai", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/gpt-5.5-2026-04-23", - "name": "gpt-5.5-2026-04-23", - "provider": "azure_ai", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/gpt-5.4", - "name": "gpt-5.4", - "provider": "azure_ai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 2.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/gpt-5.4-2026-03-05", - "name": "gpt-5.4-2026-03-05", - "provider": "azure_ai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 2.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/gpt-5.4-mini", - "name": "gpt-5.4-mini", - "provider": "azure_ai", - "input_cost_per_token": 7.5e-7, - "output_cost_per_token": 0.0000045, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/gpt-5.4-mini-2026-03-17", - "name": "gpt-5.4-mini-2026-03-17", - "provider": "azure_ai", - "input_cost_per_token": 7.5e-7, - "output_cost_per_token": 0.0000045, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/gpt-5.4-nano", - "name": "gpt-5.4-nano", - "provider": "azure_ai", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 0.00000125, - "cache_read_input_token_cost": 2e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/gpt-5.4-nano-2026-03-17", - "name": "gpt-5.4-nano-2026-03-17", - "provider": "azure_ai", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 0.00000125, - "cache_read_input_token_cost": 2e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/model_router", - "name": "model_router", - "provider": "azure_ai", - "input_cost_per_token": 1.4e-7, - "output_cost_per_token": 0, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-4o-2024-08-06", - "name": "eu/gpt-4o-2024-08-06", - "provider": "azure", - "input_cost_per_token": 0.00000275, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": 0.000001375, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-4o-2024-11-20", - "name": "eu/gpt-4o-2024-11-20", - "provider": "azure", - "input_cost_per_token": 0.00000275, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-4o-mini-2024-07-18", - "name": "eu/gpt-4o-mini-2024-07-18", - "provider": "azure", - "input_cost_per_token": 1.65e-7, - "output_cost_per_token": 6.6e-7, - "cache_read_input_token_cost": 8.3e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-5-2025-08-07", - "name": "eu/gpt-5-2025-08-07", - "provider": "azure", - "input_cost_per_token": 0.000001375, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": 1.375e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-5-mini-2025-08-07", - "name": "eu/gpt-5-mini-2025-08-07", - "provider": "azure", - "input_cost_per_token": 2.75e-7, - "output_cost_per_token": 0.0000022, - "cache_read_input_token_cost": 2.75e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-5.1", - "name": "eu/gpt-5.1", - "provider": "azure", - "input_cost_per_token": 0.00000138, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": 1.4e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-5.1-chat", - "name": "eu/gpt-5.1-chat", - "provider": "azure", - "input_cost_per_token": 0.00000138, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": 1.4e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-5-nano-2025-08-07", - "name": "eu/gpt-5-nano-2025-08-07", - "provider": "azure", - "input_cost_per_token": 5.5e-8, - "output_cost_per_token": 4.4e-7, - "cache_read_input_token_cost": 5.5e-9, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/o1-2024-12-17", - "name": "eu/o1-2024-12-17", - "provider": "azure", - "input_cost_per_token": 0.0000165, - "output_cost_per_token": 0.000066, - "cache_read_input_token_cost": 0.00000825, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/o1-mini-2024-09-12", - "name": "eu/o1-mini-2024-09-12", - "provider": "azure", - "input_cost_per_token": 0.00000121, - "output_cost_per_token": 0.00000484, - "cache_read_input_token_cost": 6.05e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/o1-preview-2024-09-12", - "name": "eu/o1-preview-2024-09-12", - "provider": "azure", - "input_cost_per_token": 0.0000165, - "output_cost_per_token": 0.000066, - "cache_read_input_token_cost": 0.00000825, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/o3-mini-2025-01-31", - "name": "eu/o3-mini-2025-01-31", - "provider": "azure", - "input_cost_per_token": 0.00000121, - "output_cost_per_token": 0.00000484, - "cache_read_input_token_cost": 6.05e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/global-standard/gpt-4o-2024-08-06", - "name": "global-standard/gpt-4o-2024-08-06", - "provider": "azure", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 0.00000125, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/global-standard/gpt-4o-2024-11-20", - "name": "global-standard/gpt-4o-2024-11-20", - "provider": "azure", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 0.00000125, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/global-standard/gpt-4o-mini", - "name": "global-standard/gpt-4o-mini", - "provider": "azure", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/global/gpt-4o-2024-08-06", - "name": "global/gpt-4o-2024-08-06", - "provider": "azure", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 0.00000125, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/global/gpt-4o-2024-11-20", - "name": "global/gpt-4o-2024-11-20", - "provider": "azure", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 0.00000125, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/global/gpt-5.1", - "name": "global/gpt-5.1", - "provider": "azure", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/global/gpt-5.1-chat", - "name": "global/gpt-5.1-chat", - "provider": "azure", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-3.5-turbo", - "name": "gpt-3.5-turbo", - "provider": "azure", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-3.5-turbo-0125", - "name": "gpt-3.5-turbo-0125", - "provider": "azure", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-35-turbo", - "name": "gpt-35-turbo", - "provider": "azure", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-35-turbo-0125", - "name": "gpt-35-turbo-0125", - "provider": "azure", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-35-turbo-1106", - "name": "gpt-35-turbo-1106", - "provider": "azure", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-35-turbo-16k", - "name": "gpt-35-turbo-16k", - "provider": "azure", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000004, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-35-turbo-16k-0613", - "name": "gpt-35-turbo-16k-0613", - "provider": "azure", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000004, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4", - "name": "gpt-4", - "provider": "azure", - "input_cost_per_token": 0.00003, - "output_cost_per_token": 0.00006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4-0125-preview", - "name": "gpt-4-0125-preview", - "provider": "azure", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4-0613", - "name": "gpt-4-0613", - "provider": "azure", - "input_cost_per_token": 0.00003, - "output_cost_per_token": 0.00006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4-1106-preview", - "name": "gpt-4-1106-preview", - "provider": "azure", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4-32k", - "name": "gpt-4-32k", - "provider": "azure", - "input_cost_per_token": 0.00006, - "output_cost_per_token": 0.00012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4-32k-0613", - "name": "gpt-4-32k-0613", - "provider": "azure", - "input_cost_per_token": 0.00006, - "output_cost_per_token": 0.00012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4-turbo", - "name": "gpt-4-turbo", - "provider": "azure", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4-turbo-2024-04-09", - "name": "gpt-4-turbo-2024-04-09", - "provider": "azure", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4-turbo-vision-preview", - "name": "gpt-4-turbo-vision-preview", - "provider": "azure", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4.1", - "name": "gpt-4.1", - "provider": "azure", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000008, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4.1-2025-04-14", - "name": "gpt-4.1-2025-04-14", - "provider": "azure", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000008, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4.1-mini", - "name": "gpt-4.1-mini", - "provider": "azure", - "input_cost_per_token": 4e-7, - "output_cost_per_token": 0.0000016, - "cache_read_input_token_cost": 1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4.1-mini-2025-04-14", - "name": "gpt-4.1-mini-2025-04-14", - "provider": "azure", - "input_cost_per_token": 4e-7, - "output_cost_per_token": 0.0000016, - "cache_read_input_token_cost": 1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4.1-nano", - "name": "gpt-4.1-nano", - "provider": "azure", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4.1-nano-2025-04-14", - "name": "gpt-4.1-nano-2025-04-14", - "provider": "azure", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4.5-preview", - "name": "gpt-4.5-preview", - "provider": "azure", - "input_cost_per_token": 0.000075, - "output_cost_per_token": 0.00015, - "cache_read_input_token_cost": 0.0000375, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4o", - "name": "gpt-4o", - "provider": "azure", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 0.00000125, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4o-2024-05-13", - "name": "gpt-4o-2024-05-13", - "provider": "azure", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4o-2024-08-06", - "name": "gpt-4o-2024-08-06", - "provider": "azure", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 0.00000125, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4o-2024-11-20", - "name": "gpt-4o-2024-11-20", - "provider": "azure", - "input_cost_per_token": 0.00000275, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": 0.00000125, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-audio-2025-08-28", - "name": "gpt-audio-2025-08-28", - "provider": "azure", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-audio-1.5-2026-02-23", - "name": "gpt-audio-1.5-2026-02-23", - "provider": "azure", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-audio-mini-2025-10-06", - "name": "gpt-audio-mini-2025-10-06", - "provider": "azure", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.0000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4o-audio-preview-2024-12-17", - "name": "gpt-4o-audio-preview-2024-12-17", - "provider": "azure", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4o-mini", - "name": "gpt-4o-mini", - "provider": "azure", - "input_cost_per_token": 1.65e-7, - "output_cost_per_token": 6.6e-7, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4o-mini-2024-07-18", - "name": "gpt-4o-mini-2024-07-18", - "provider": "azure", - "input_cost_per_token": 1.65e-7, - "output_cost_per_token": 6.6e-7, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-4o-mini-audio-preview-2024-12-17", - "name": "gpt-4o-mini-audio-preview-2024-12-17", - "provider": "azure", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.1-2025-11-13", - "name": "gpt-5.1-2025-11-13", - "provider": "azure", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.1-chat-2025-11-13", - "name": "gpt-5.1-chat-2025-11-13", - "provider": "azure", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5", - "name": "gpt-5", - "provider": "azure", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5-2025-08-07", - "name": "gpt-5-2025-08-07", - "provider": "azure", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5-chat", - "name": "gpt-5-chat", - "provider": "azure", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5-chat-latest", - "name": "gpt-5-chat-latest", - "provider": "azure", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5-mini", - "name": "gpt-5-mini", - "provider": "azure", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5-mini-2025-08-07", - "name": "gpt-5-mini-2025-08-07", - "provider": "azure", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5-nano", - "name": "gpt-5-nano", - "provider": "azure", - "input_cost_per_token": 5e-8, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 5e-9, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5-nano-2025-08-07", - "name": "gpt-5-nano-2025-08-07", - "provider": "azure", - "input_cost_per_token": 5e-8, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 5e-9, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.1", - "name": "gpt-5.1", - "provider": "azure", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.1-chat", - "name": "gpt-5.1-chat", - "provider": "azure", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.2", - "name": "gpt-5.2", - "provider": "azure", - "input_cost_per_token": 0.00000175, - "output_cost_per_token": 0.000014, - "cache_read_input_token_cost": 1.75e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.2-2025-12-11", - "name": "gpt-5.2-2025-12-11", - "provider": "azure", - "input_cost_per_token": 0.00000175, - "output_cost_per_token": 0.000014, - "cache_read_input_token_cost": 1.75e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.2-chat", - "name": "gpt-5.2-chat", - "provider": "azure", - "input_cost_per_token": 0.00000175, - "output_cost_per_token": 0.000014, - "cache_read_input_token_cost": 1.75e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.2-chat-2025-12-11", - "name": "gpt-5.2-chat-2025-12-11", - "provider": "azure", - "input_cost_per_token": 0.00000175, - "output_cost_per_token": 0.000014, - "cache_read_input_token_cost": 1.75e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.3-chat", - "name": "gpt-5.3-chat", - "provider": "azure", - "input_cost_per_token": 0.00000175, - "output_cost_per_token": 0.000014, - "cache_read_input_token_cost": 1.75e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.4", - "name": "gpt-5.4", - "provider": "azure", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 2.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-5.4", - "name": "us/gpt-5.4", - "provider": "azure", - "input_cost_per_token": 0.00000275, - "output_cost_per_token": 0.0000165, - "cache_read_input_token_cost": 2.8e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-5.4", - "name": "eu/gpt-5.4", - "provider": "azure", - "input_cost_per_token": 0.00000275, - "output_cost_per_token": 0.0000165, - "cache_read_input_token_cost": 2.8e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.4-2026-03-05", - "name": "gpt-5.4-2026-03-05", - "provider": "azure", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 2.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-5.4-2026-03-05", - "name": "us/gpt-5.4-2026-03-05", - "provider": "azure", - "input_cost_per_token": 0.00000275, - "output_cost_per_token": 0.0000165, - "cache_read_input_token_cost": 2.8e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-5.4-2026-03-05", - "name": "eu/gpt-5.4-2026-03-05", - "provider": "azure", - "input_cost_per_token": 0.00000275, - "output_cost_per_token": 0.0000165, - "cache_read_input_token_cost": 2.8e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.6", - "name": "gpt-5.6", - "provider": "azure", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.6-sol", - "name": "gpt-5.6-sol", - "provider": "azure", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.6-terra", - "name": "gpt-5.6-terra", - "provider": "azure", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.6-luna", - "name": "gpt-5.6-luna", - "provider": "azure", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": 2e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-5.6", - "name": "us/gpt-5.6", - "provider": "azure", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.000033, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-5.6-sol", - "name": "us/gpt-5.6-sol", - "provider": "azure", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.000033, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-5.6-terra", - "name": "us/gpt-5.6-terra", - "provider": "azure", - "input_cost_per_token": 0.0000022, - "output_cost_per_token": 0.0000132, - "cache_read_input_token_cost": 2.2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-5.6-luna", - "name": "us/gpt-5.6-luna", - "provider": "azure", - "input_cost_per_token": 2.2e-7, - "output_cost_per_token": 0.00000132, - "cache_read_input_token_cost": 2.2e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-5.6", - "name": "eu/gpt-5.6", - "provider": "azure", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.000033, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-5.6-sol", - "name": "eu/gpt-5.6-sol", - "provider": "azure", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.000033, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-5.6-terra", - "name": "eu/gpt-5.6-terra", - "provider": "azure", - "input_cost_per_token": 0.0000022, - "output_cost_per_token": 0.0000132, - "cache_read_input_token_cost": 2.2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-5.6-luna", - "name": "eu/gpt-5.6-luna", - "provider": "azure", - "input_cost_per_token": 2.2e-7, - "output_cost_per_token": 0.00000132, - "cache_read_input_token_cost": 2.2e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.5", - "name": "gpt-5.5", - "provider": "azure", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-5.5", - "name": "us/gpt-5.5", - "provider": "azure", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.000033, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-5.5", - "name": "eu/gpt-5.5", - "provider": "azure", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.000033, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.5-2026-04-23", - "name": "gpt-5.5-2026-04-23", - "provider": "azure", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-5.5-2026-04-23", - "name": "us/gpt-5.5-2026-04-23", - "provider": "azure", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.000033, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/eu/gpt-5.5-2026-04-23", - "name": "eu/gpt-5.5-2026-04-23", - "provider": "azure", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.000033, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.4-mini", - "name": "gpt-5.4-mini", - "provider": "azure", - "input_cost_per_token": 7.5e-7, - "output_cost_per_token": 0.0000045, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.4-mini-2026-03-17", - "name": "gpt-5.4-mini-2026-03-17", - "provider": "azure", - "input_cost_per_token": 7.5e-7, - "output_cost_per_token": 0.0000045, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.4-nano", - "name": "gpt-5.4-nano", - "provider": "azure", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 0.00000125, - "cache_read_input_token_cost": 2e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-5.4-nano-2026-03-17", - "name": "gpt-5.4-nano-2026-03-17", - "provider": "azure", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 0.00000125, - "cache_read_input_token_cost": 2e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/mistral-large-2402", - "name": "mistral-large-2402", - "provider": "azure", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/mistral-large-latest", - "name": "mistral-large-latest", - "provider": "azure", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/o1", - "name": "o1", - "provider": "azure", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.00006, - "cache_read_input_token_cost": 0.0000075, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/o1-2024-12-17", - "name": "o1-2024-12-17", - "provider": "azure", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.00006, - "cache_read_input_token_cost": 0.0000075, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/o1-mini", - "name": "o1-mini", - "provider": "azure", - "input_cost_per_token": 0.00000121, - "output_cost_per_token": 0.00000484, - "cache_read_input_token_cost": 6.05e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/o1-mini-2024-09-12", - "name": "o1-mini-2024-09-12", - "provider": "azure", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 0.0000044, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/o1-preview", - "name": "o1-preview", - "provider": "azure", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.00006, - "cache_read_input_token_cost": 0.0000075, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/o1-preview-2024-09-12", - "name": "o1-preview-2024-09-12", - "provider": "azure", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.00006, - "cache_read_input_token_cost": 0.0000075, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/o3", - "name": "o3", - "provider": "azure", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000008, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/o3-2025-04-16", - "name": "o3-2025-04-16", - "provider": "azure", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000008, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/o3-mini", - "name": "o3-mini", - "provider": "azure", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 0.0000044, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/o3-mini-2025-01-31", - "name": "o3-mini-2025-01-31", - "provider": "azure", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 0.0000044, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/o4-mini", - "name": "o4-mini", - "provider": "azure", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 0.0000044, - "cache_read_input_token_cost": 2.75e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/o4-mini-2025-04-16", - "name": "o4-mini-2025-04-16", - "provider": "azure", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 0.0000044, - "cache_read_input_token_cost": 2.75e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-4.1-2025-04-14", - "name": "us/gpt-4.1-2025-04-14", - "provider": "azure", - "input_cost_per_token": 0.0000022, - "output_cost_per_token": 0.0000088, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-4.1-mini-2025-04-14", - "name": "us/gpt-4.1-mini-2025-04-14", - "provider": "azure", - "input_cost_per_token": 4.4e-7, - "output_cost_per_token": 0.00000176, - "cache_read_input_token_cost": 1.1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-4.1-nano-2025-04-14", - "name": "us/gpt-4.1-nano-2025-04-14", - "provider": "azure", - "input_cost_per_token": 1.1e-7, - "output_cost_per_token": 4.4e-7, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-4o-2024-08-06", - "name": "us/gpt-4o-2024-08-06", - "provider": "azure", - "input_cost_per_token": 0.00000275, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": 0.000001375, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-4o-2024-11-20", - "name": "us/gpt-4o-2024-11-20", - "provider": "azure", - "input_cost_per_token": 0.00000275, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-4o-mini-2024-07-18", - "name": "us/gpt-4o-mini-2024-07-18", - "provider": "azure", - "input_cost_per_token": 1.65e-7, - "output_cost_per_token": 6.6e-7, - "cache_read_input_token_cost": 8.3e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-5-2025-08-07", - "name": "us/gpt-5-2025-08-07", - "provider": "azure", - "input_cost_per_token": 0.000001375, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": 1.375e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-5-mini-2025-08-07", - "name": "us/gpt-5-mini-2025-08-07", - "provider": "azure", - "input_cost_per_token": 2.75e-7, - "output_cost_per_token": 0.0000022, - "cache_read_input_token_cost": 2.75e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-5-nano-2025-08-07", - "name": "us/gpt-5-nano-2025-08-07", - "provider": "azure", - "input_cost_per_token": 5.5e-8, - "output_cost_per_token": 4.4e-7, - "cache_read_input_token_cost": 5.5e-9, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-5.1", - "name": "us/gpt-5.1", - "provider": "azure", - "input_cost_per_token": 0.00000138, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": 1.4e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/gpt-5.1-chat", - "name": "us/gpt-5.1-chat", - "provider": "azure", - "input_cost_per_token": 0.00000138, - "output_cost_per_token": 0.000011, - "cache_read_input_token_cost": 1.4e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/o1-2024-12-17", - "name": "us/o1-2024-12-17", - "provider": "azure", - "input_cost_per_token": 0.0000165, - "output_cost_per_token": 0.000066, - "cache_read_input_token_cost": 0.00000825, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/o1-mini-2024-09-12", - "name": "us/o1-mini-2024-09-12", - "provider": "azure", - "input_cost_per_token": 0.00000121, - "output_cost_per_token": 0.00000484, - "cache_read_input_token_cost": 6.05e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/o1-preview-2024-09-12", - "name": "us/o1-preview-2024-09-12", - "provider": "azure", - "input_cost_per_token": 0.0000165, - "output_cost_per_token": 0.000066, - "cache_read_input_token_cost": 0.00000825, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/o3-2025-04-16", - "name": "us/o3-2025-04-16", - "provider": "azure", - "input_cost_per_token": 0.0000022, - "output_cost_per_token": 0.0000088, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/o3-mini-2025-01-31", - "name": "us/o3-mini-2025-01-31", - "provider": "azure", - "input_cost_per_token": 0.00000121, - "output_cost_per_token": 0.00000484, - "cache_read_input_token_cost": 6.05e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/us/o4-mini-2025-04-16", - "name": "us/o4-mini-2025-04-16", - "provider": "azure", - "input_cost_per_token": 0.00000121, - "output_cost_per_token": 0.00000484, - "cache_read_input_token_cost": 3.1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Llama-3.2-11B-Vision-Instruct", - "name": "Llama-3.2-11B-Vision-Instruct", - "provider": "azure_ai", - "input_cost_per_token": 3.7e-7, - "output_cost_per_token": 3.7e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Llama-3.2-90B-Vision-Instruct", - "name": "Llama-3.2-90B-Vision-Instruct", - "provider": "azure_ai", - "input_cost_per_token": 0.00000204, - "output_cost_per_token": 0.00000204, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Llama-3.3-70B-Instruct", - "name": "Llama-3.3-70B-Instruct", - "provider": "azure_ai", - "input_cost_per_token": 7.1e-7, - "output_cost_per_token": 7.1e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8", - "name": "Llama-4-Maverick-17B-128E-Instruct-FP8", - "provider": "azure_ai", - "input_cost_per_token": 0.00000141, - "output_cost_per_token": 3.5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Llama-4-Scout-17B-16E-Instruct", - "name": "Llama-4-Scout-17B-16E-Instruct", - "provider": "azure_ai", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 7.8e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Meta-Llama-3-70B-Instruct", - "name": "Meta-Llama-3-70B-Instruct", - "provider": "azure_ai", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 3.7e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Meta-Llama-3.1-405B-Instruct", - "name": "Meta-Llama-3.1-405B-Instruct", - "provider": "azure_ai", - "input_cost_per_token": 0.00000533, - "output_cost_per_token": 0.000016, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Meta-Llama-3.1-70B-Instruct", - "name": "Meta-Llama-3.1-70B-Instruct", - "provider": "azure_ai", - "input_cost_per_token": 0.00000268, - "output_cost_per_token": 0.00000354, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Meta-Llama-3.1-8B-Instruct", - "name": "Meta-Llama-3.1-8B-Instruct", - "provider": "azure_ai", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 6.1e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Phi-3-medium-128k-instruct", - "name": "Phi-3-medium-128k-instruct", - "provider": "azure_ai", - "input_cost_per_token": 1.7e-7, - "output_cost_per_token": 6.8e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Phi-3-medium-4k-instruct", - "name": "Phi-3-medium-4k-instruct", - "provider": "azure_ai", - "input_cost_per_token": 1.7e-7, - "output_cost_per_token": 6.8e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Phi-3-mini-128k-instruct", - "name": "Phi-3-mini-128k-instruct", - "provider": "azure_ai", - "input_cost_per_token": 1.3e-7, - "output_cost_per_token": 5.2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Phi-3-mini-4k-instruct", - "name": "Phi-3-mini-4k-instruct", - "provider": "azure_ai", - "input_cost_per_token": 1.3e-7, - "output_cost_per_token": 5.2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Phi-3-small-128k-instruct", - "name": "Phi-3-small-128k-instruct", - "provider": "azure_ai", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Phi-3-small-8k-instruct", - "name": "Phi-3-small-8k-instruct", - "provider": "azure_ai", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Phi-3.5-MoE-instruct", - "name": "Phi-3.5-MoE-instruct", - "provider": "azure_ai", - "input_cost_per_token": 1.6e-7, - "output_cost_per_token": 6.4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Phi-3.5-mini-instruct", - "name": "Phi-3.5-mini-instruct", - "provider": "azure_ai", - "input_cost_per_token": 1.3e-7, - "output_cost_per_token": 5.2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Phi-3.5-vision-instruct", - "name": "Phi-3.5-vision-instruct", - "provider": "azure_ai", - "input_cost_per_token": 1.3e-7, - "output_cost_per_token": 5.2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Phi-4", - "name": "Phi-4", - "provider": "azure_ai", - "input_cost_per_token": 1.25e-7, - "output_cost_per_token": 5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Phi-4-mini-instruct", - "name": "Phi-4-mini-instruct", - "provider": "azure_ai", - "input_cost_per_token": 7.5e-8, - "output_cost_per_token": 3e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Phi-4-multimodal-instruct", - "name": "Phi-4-multimodal-instruct", - "provider": "azure_ai", - "input_cost_per_token": 8e-8, - "output_cost_per_token": 3.2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Phi-4-mini-reasoning", - "name": "Phi-4-mini-reasoning", - "provider": "azure_ai", - "input_cost_per_token": 8e-8, - "output_cost_per_token": 3.2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/Phi-4-reasoning", - "name": "Phi-4-reasoning", - "provider": "azure_ai", - "input_cost_per_token": 1.25e-7, - "output_cost_per_token": 5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/MAI-DS-R1", - "name": "MAI-DS-R1", - "provider": "azure_ai", - "input_cost_per_token": 0.00000135, - "output_cost_per_token": 0.0000054, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/deepseek-v3.2", - "name": "deepseek-v3.2", - "provider": "azure_ai", - "input_cost_per_token": 5.8e-7, - "output_cost_per_token": 0.00000168, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/deepseek-v3.2-speciale", - "name": "deepseek-v3.2-speciale", - "provider": "azure_ai", - "input_cost_per_token": 5.8e-7, - "output_cost_per_token": 0.00000168, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/deepseek-r1", - "name": "deepseek-r1", - "provider": "azure_ai", - "input_cost_per_token": 0.00000135, - "output_cost_per_token": 0.0000054, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/deepseek-v3", - "name": "deepseek-v3", - "provider": "azure_ai", - "input_cost_per_token": 0.00000114, - "output_cost_per_token": 0.00000456, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/deepseek-v3-0324", - "name": "deepseek-v3-0324", - "provider": "azure_ai", - "input_cost_per_token": 0.00000114, - "output_cost_per_token": 0.00000456, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/deepseek-v3.1", - "name": "deepseek-v3.1", - "provider": "azure_ai", - "input_cost_per_token": 0.00000123, - "output_cost_per_token": 0.00000494, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/deepseek-v4-pro", - "name": "deepseek-v4-pro", - "provider": "azure_ai", - "input_cost_per_token": 0.00000174, - "output_cost_per_token": 0.00000348, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/deepseek-v4-flash", - "name": "deepseek-v4-flash", - "provider": "azure_ai", - "input_cost_per_token": 1.9e-7, - "output_cost_per_token": 5.1e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/global/grok-3", - "name": "global/grok-3", - "provider": "azure_ai", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/global/grok-3-mini", - "name": "global/grok-3-mini", - "provider": "azure_ai", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.00000127, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/grok-3", - "name": "grok-3", - "provider": "azure_ai", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/grok-3-mini", - "name": "grok-3-mini", - "provider": "azure_ai", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.00000127, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/grok-4", - "name": "grok-4", - "provider": "azure_ai", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/grok-4-fast-non-reasoning", - "name": "grok-4-fast-non-reasoning", - "provider": "azure_ai", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/grok-4-fast-reasoning", - "name": "grok-4-fast-reasoning", - "provider": "azure_ai", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/grok-4-1-fast-non-reasoning", - "name": "grok-4-1-fast-non-reasoning", - "provider": "azure_ai", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/grok-4-1-fast-reasoning", - "name": "grok-4-1-fast-reasoning", - "provider": "azure_ai", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/grok-code-fast-1", - "name": "grok-code-fast-1", - "provider": "azure_ai", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/jais-30b-chat", - "name": "jais-30b-chat", - "provider": "azure_ai", - "input_cost_per_token": 0.0032, - "output_cost_per_token": 0.00971, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/jamba-instruct", - "name": "jamba-instruct", - "provider": "azure_ai", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 7e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/kimi-k2.5", - "name": "kimi-k2.5", - "provider": "azure_ai", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.000003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/kimi-k2.6", - "name": "kimi-k2.6", - "provider": "azure_ai", - "input_cost_per_token": 9.5e-7, - "output_cost_per_token": 0.000004, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/ministral-3b", - "name": "ministral-3b", - "provider": "azure_ai", - "input_cost_per_token": 4e-8, - "output_cost_per_token": 4e-8, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/mistral-large", - "name": "mistral-large", - "provider": "azure_ai", - "input_cost_per_token": 0.000004, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/mistral-large-2407", - "name": "mistral-large-2407", - "provider": "azure_ai", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/mistral-large-latest", - "name": "mistral-large-latest", - "provider": "azure_ai", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/mistral-large-3", - "name": "mistral-large-3", - "provider": "azure_ai", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/mistral-medium-2505", - "name": "mistral-medium-2505", - "provider": "azure_ai", - "input_cost_per_token": 4e-7, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/mistral-nemo", - "name": "mistral-nemo", - "provider": "azure_ai", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 1.5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/mistral-small", - "name": "mistral-small", - "provider": "azure_ai", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure_ai/mistral-small-2503", - "name": "mistral-small-2503", - "provider": "azure_ai", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 3e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-northeast-1/anthropic.claude-instant-v1", - "name": "ap-northeast-1/anthropic.claude-instant-v1", - "provider": "bedrock", - "input_cost_per_token": 0.00000223, - "output_cost_per_token": 0.00000755, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-northeast-1/anthropic.claude-v1", - "name": "ap-northeast-1/anthropic.claude-v1", - "provider": "bedrock", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-northeast-1/anthropic.claude-v2:1", - "name": "ap-northeast-1/anthropic.claude-v2:1", - "provider": "bedrock", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-northeast-1/deepseek.v3.2", - "name": "ap-northeast-1/deepseek.v3.2", - "provider": "bedrock", - "input_cost_per_token": 7.4e-7, - "output_cost_per_token": 0.00000222, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-northeast-1/minimax.minimax-m2.1", - "name": "ap-northeast-1/minimax.minimax-m2.1", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-northeast-1/minimax.minimax-m2.5", - "name": "ap-northeast-1/minimax.minimax-m2.5", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-northeast-1/moonshotai.kimi-k2-thinking", - "name": "ap-northeast-1/moonshotai.kimi-k2-thinking", - "provider": "bedrock", - "input_cost_per_token": 7.3e-7, - "output_cost_per_token": 0.00000303, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-northeast-1/moonshotai.kimi-k2.5", - "name": "ap-northeast-1/moonshotai.kimi-k2.5", - "provider": "bedrock", - "input_cost_per_token": 7.2e-7, - "output_cost_per_token": 0.0000036, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-northeast-1/qwen.qwen3-coder-next", - "name": "ap-northeast-1/qwen.qwen3-coder-next", - "provider": "bedrock", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/moonshotai.kimi-k2-thinking", - "name": "moonshotai.kimi-k2-thinking", - "provider": "bedrock", - "input_cost_per_token": 7.3e-7, - "output_cost_per_token": 0.00000303, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/moonshotai.kimi-k2.5", - "name": "moonshotai.kimi-k2.5", - "provider": "bedrock", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.00000303, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-south-1/meta.llama3-70b-instruct-v1:0", - "name": "ap-south-1/meta.llama3-70b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.00000318, - "output_cost_per_token": 0.0000042, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-south-1/meta.llama3-8b-instruct-v1:0", - "name": "ap-south-1/meta.llama3-8b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 7.2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-south-1/deepseek.v3.2", - "name": "ap-south-1/deepseek.v3.2", - "provider": "bedrock", - "input_cost_per_token": 7.4e-7, - "output_cost_per_token": 0.00000222, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-south-1/minimax.minimax-m2.1", - "name": "ap-south-1/minimax.minimax-m2.1", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-south-1/minimax.minimax-m2.5", - "name": "ap-south-1/minimax.minimax-m2.5", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-south-1/moonshotai.kimi-k2-thinking", - "name": "ap-south-1/moonshotai.kimi-k2-thinking", - "provider": "bedrock", - "input_cost_per_token": 7.1e-7, - "output_cost_per_token": 0.00000294, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-south-1/moonshotai.kimi-k2.5", - "name": "ap-south-1/moonshotai.kimi-k2.5", - "provider": "bedrock", - "input_cost_per_token": 7.2e-7, - "output_cost_per_token": 0.0000036, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-south-1/qwen.qwen3-coder-next", - "name": "ap-south-1/qwen.qwen3-coder-next", - "provider": "bedrock", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-southeast-2/minimax.minimax-m2.5", - "name": "ap-southeast-2/minimax.minimax-m2.5", - "provider": "bedrock", - "input_cost_per_token": 3.09e-7, - "output_cost_per_token": 0.000001236, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-southeast-3/deepseek.v3.2", - "name": "ap-southeast-3/deepseek.v3.2", - "provider": "bedrock", - "input_cost_per_token": 7.4e-7, - "output_cost_per_token": 0.00000222, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-southeast-3/minimax.minimax-m2.1", - "name": "ap-southeast-3/minimax.minimax-m2.1", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-southeast-3/minimax.minimax-m2.5", - "name": "ap-southeast-3/minimax.minimax-m2.5", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-southeast-3/moonshotai.kimi-k2.5", - "name": "ap-southeast-3/moonshotai.kimi-k2.5", - "provider": "bedrock", - "input_cost_per_token": 7.2e-7, - "output_cost_per_token": 0.0000036, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ap-southeast-3/qwen.qwen3-coder-next", - "name": "ap-southeast-3/qwen.qwen3-coder-next", - "provider": "bedrock", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ca-central-1/meta.llama3-70b-instruct-v1:0", - "name": "ca-central-1/meta.llama3-70b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.00000305, - "output_cost_per_token": 0.00000403, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/ca-central-1/meta.llama3-8b-instruct-v1:0", - "name": "ca-central-1/meta.llama3-8b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 3.5e-7, - "output_cost_per_token": 6.9e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-north-1/deepseek.v3.2", - "name": "eu-north-1/deepseek.v3.2", - "provider": "bedrock", - "input_cost_per_token": 7.4e-7, - "output_cost_per_token": 0.00000222, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-north-1/minimax.minimax-m2.1", - "name": "eu-north-1/minimax.minimax-m2.1", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-north-1/minimax.minimax-m2.5", - "name": "eu-north-1/minimax.minimax-m2.5", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-north-1/moonshotai.kimi-k2.5", - "name": "eu-north-1/moonshotai.kimi-k2.5", - "provider": "bedrock", - "input_cost_per_token": 7.2e-7, - "output_cost_per_token": 0.0000036, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-central-1/anthropic.claude-instant-v1", - "name": "eu-central-1/anthropic.claude-instant-v1", - "provider": "bedrock", - "input_cost_per_token": 0.00000248, - "output_cost_per_token": 0.00000838, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-central-1/anthropic.claude-v1", - "name": "eu-central-1/anthropic.claude-v1", - "provider": "bedrock", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-central-1/anthropic.claude-v2:1", - "name": "eu-central-1/anthropic.claude-v2:1", - "provider": "bedrock", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-central-1/minimax.minimax-m2.1", - "name": "eu-central-1/minimax.minimax-m2.1", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-central-1/minimax.minimax-m2.5", - "name": "eu-central-1/minimax.minimax-m2.5", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-central-1/qwen.qwen3-coder-next", - "name": "eu-central-1/qwen.qwen3-coder-next", - "provider": "bedrock", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-west-1/meta.llama3-70b-instruct-v1:0", - "name": "eu-west-1/meta.llama3-70b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.00000286, - "output_cost_per_token": 0.00000378, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-west-1/meta.llama3-8b-instruct-v1:0", - "name": "eu-west-1/meta.llama3-8b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 3.2e-7, - "output_cost_per_token": 6.5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-west-1/minimax.minimax-m2.1", - "name": "eu-west-1/minimax.minimax-m2.1", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-west-1/minimax.minimax-m2.5", - "name": "eu-west-1/minimax.minimax-m2.5", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-west-1/qwen.qwen3-coder-next", - "name": "eu-west-1/qwen.qwen3-coder-next", - "provider": "bedrock", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-west-2/meta.llama3-70b-instruct-v1:0", - "name": "eu-west-2/meta.llama3-70b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.00000345, - "output_cost_per_token": 0.00000455, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-west-2/meta.llama3-8b-instruct-v1:0", - "name": "eu-west-2/meta.llama3-8b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 3.9e-7, - "output_cost_per_token": 7.8e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-west-2/minimax.minimax-m2.1", - "name": "eu-west-2/minimax.minimax-m2.1", - "provider": "bedrock", - "input_cost_per_token": 4.7e-7, - "output_cost_per_token": 0.00000186, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-west-2/minimax.minimax-m2.5", - "name": "eu-west-2/minimax.minimax-m2.5", - "provider": "bedrock", - "input_cost_per_token": 4.7e-7, - "output_cost_per_token": 0.00000186, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-west-2/qwen.qwen3-coder-next", - "name": "eu-west-2/qwen.qwen3-coder-next", - "provider": "bedrock", - "input_cost_per_token": 7.8e-7, - "output_cost_per_token": 0.00000186, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-west-3/mistral.mistral-7b-instruct-v0:2", - "name": "eu-west-3/mistral.mistral-7b-instruct-v0:2", - "provider": "bedrock", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 2.6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-west-3/mistral.mistral-large-2402-v1:0", - "name": "eu-west-3/mistral.mistral-large-2402-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.0000104, - "output_cost_per_token": 0.0000312, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-west-3/mistral.mixtral-8x7b-instruct-v0:1", - "name": "eu-west-3/mistral.mixtral-8x7b-instruct-v0:1", - "provider": "bedrock", - "input_cost_per_token": 5.9e-7, - "output_cost_per_token": 9.1e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-south-1/minimax.minimax-m2.1", - "name": "eu-south-1/minimax.minimax-m2.1", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-south-1/minimax.minimax-m2.5", - "name": "eu-south-1/minimax.minimax-m2.5", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/eu-south-1/qwen.qwen3-coder-next", - "name": "eu-south-1/qwen.qwen3-coder-next", - "provider": "bedrock", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/invoke/anthropic.claude-3-5-sonnet-20240620-v1:0", - "name": "invoke/anthropic.claude-3-5-sonnet-20240620-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/sa-east-1/meta.llama3-70b-instruct-v1:0", - "name": "sa-east-1/meta.llama3-70b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.00000445, - "output_cost_per_token": 0.00000588, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/sa-east-1/meta.llama3-8b-instruct-v1:0", - "name": "sa-east-1/meta.llama3-8b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.00000101, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/sa-east-1/deepseek.v3.2", - "name": "sa-east-1/deepseek.v3.2", - "provider": "bedrock", - "input_cost_per_token": 7.4e-7, - "output_cost_per_token": 0.00000222, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/sa-east-1/minimax.minimax-m2.1", - "name": "sa-east-1/minimax.minimax-m2.1", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/sa-east-1/minimax.minimax-m2.5", - "name": "sa-east-1/minimax.minimax-m2.5", - "provider": "bedrock", - "input_cost_per_token": 3.6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/sa-east-1/moonshotai.kimi-k2-thinking", - "name": "sa-east-1/moonshotai.kimi-k2-thinking", - "provider": "bedrock", - "input_cost_per_token": 7.3e-7, - "output_cost_per_token": 0.00000303, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/sa-east-1/moonshotai.kimi-k2.5", - "name": "sa-east-1/moonshotai.kimi-k2.5", - "provider": "bedrock", - "input_cost_per_token": 7.2e-7, - "output_cost_per_token": 0.0000036, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/sa-east-1/qwen.qwen3-coder-next", - "name": "sa-east-1/qwen.qwen3-coder-next", - "provider": "bedrock", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.00000144, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/anthropic.claude-instant-v1", - "name": "us-east-1/anthropic.claude-instant-v1", - "provider": "bedrock", - "input_cost_per_token": 8e-7, - "output_cost_per_token": 0.0000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/anthropic.claude-v1", - "name": "us-east-1/anthropic.claude-v1", - "provider": "bedrock", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/anthropic.claude-v2:1", - "name": "us-east-1/anthropic.claude-v2:1", - "provider": "bedrock", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/meta.llama3-70b-instruct-v1:0", - "name": "us-east-1/meta.llama3-70b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.00000265, - "output_cost_per_token": 0.0000035, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/meta.llama3-8b-instruct-v1:0", - "name": "us-east-1/meta.llama3-8b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/mistral.mistral-7b-instruct-v0:2", - "name": "us-east-1/mistral.mistral-7b-instruct-v0:2", - "provider": "bedrock", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/mistral.mistral-large-2402-v1:0", - "name": "us-east-1/mistral.mistral-large-2402-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/mistral.mixtral-8x7b-instruct-v0:1", - "name": "us-east-1/mistral.mixtral-8x7b-instruct-v0:1", - "provider": "bedrock", - "input_cost_per_token": 4.5e-7, - "output_cost_per_token": 7e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/deepseek.v3.2", - "name": "us-east-1/deepseek.v3.2", - "provider": "bedrock", - "input_cost_per_token": 6.2e-7, - "output_cost_per_token": 0.00000185, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/minimax.minimax-m2.1", - "name": "us-east-1/minimax.minimax-m2.1", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/minimax.minimax-m2.5", - "name": "us-east-1/minimax.minimax-m2.5", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/moonshotai.kimi-k2-thinking", - "name": "us-east-1/moonshotai.kimi-k2-thinking", - "provider": "bedrock", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/moonshotai.kimi-k2.5", - "name": "us-east-1/moonshotai.kimi-k2.5", - "provider": "bedrock", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.000003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/qwen.qwen3-coder-next", - "name": "us-east-1/qwen.qwen3-coder-next", - "provider": "bedrock", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-2/deepseek.v3.2", - "name": "us-east-2/deepseek.v3.2", - "provider": "bedrock", - "input_cost_per_token": 6.2e-7, - "output_cost_per_token": 0.00000185, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-2/minimax.minimax-m2.1", - "name": "us-east-2/minimax.minimax-m2.1", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-2/minimax.minimax-m2.5", - "name": "us-east-2/minimax.minimax-m2.5", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-2/moonshotai.kimi-k2-thinking", - "name": "us-east-2/moonshotai.kimi-k2-thinking", - "provider": "bedrock", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-2/moonshotai.kimi-k2.5", - "name": "us-east-2/moonshotai.kimi-k2.5", - "provider": "bedrock", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.000003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-2/qwen.qwen3-coder-next", - "name": "us-east-2/qwen.qwen3-coder-next", - "provider": "bedrock", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-east-1/amazon.nova-pro-v1:0", - "name": "us-gov-east-1/amazon.nova-pro-v1:0", - "provider": "bedrock", - "input_cost_per_token": 9.6e-7, - "output_cost_per_token": 0.00000384, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-east-1/amazon.titan-text-express-v1", - "name": "us-gov-east-1/amazon.titan-text-express-v1", - "provider": "bedrock", - "input_cost_per_token": 0.0000013, - "output_cost_per_token": 0.0000017, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-east-1/amazon.titan-text-lite-v1", - "name": "us-gov-east-1/amazon.titan-text-lite-v1", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-east-1/amazon.titan-text-premier-v1:0", - "name": "us-gov-east-1/amazon.titan-text-premier-v1:0", - "provider": "bedrock", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0", - "name": "us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.0000036, - "output_cost_per_token": 0.000018, - "cache_read_input_token_cost": 3.6e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0", - "name": "us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": 3e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0", - "name": "us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.0000036, - "output_cost_per_token": 0.000018, - "cache_read_input_token_cost": 3.6e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0", - "name": "us-gov-east-1/claude-sonnet-4-5-20250929-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.0000036, - "output_cost_per_token": 0.000018, - "cache_read_input_token_cost": 3.6e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0", - "name": "us-gov-east-1/meta.llama3-70b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.00000265, - "output_cost_per_token": 0.0000035, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-east-1/meta.llama3-8b-instruct-v1:0", - "name": "us-gov-east-1/meta.llama3-8b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.00000265, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-west-1/amazon.nova-pro-v1:0", - "name": "us-gov-west-1/amazon.nova-pro-v1:0", - "provider": "bedrock", - "input_cost_per_token": 9.6e-7, - "output_cost_per_token": 0.00000384, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-west-1/amazon.titan-text-express-v1", - "name": "us-gov-west-1/amazon.titan-text-express-v1", - "provider": "bedrock", - "input_cost_per_token": 0.0000013, - "output_cost_per_token": 0.0000017, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-west-1/amazon.titan-text-lite-v1", - "name": "us-gov-west-1/amazon.titan-text-lite-v1", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-west-1/amazon.titan-text-premier-v1:0", - "name": "us-gov-west-1/amazon.titan-text-premier-v1:0", - "provider": "bedrock", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0", - "name": "us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.0000036, - "output_cost_per_token": 0.000018, - "cache_read_input_token_cost": 3.6e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0", - "name": "us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.0000036, - "output_cost_per_token": 0.000018, - "cache_read_input_token_cost": 3.6e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0", - "name": "us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": 3e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0", - "name": "us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.0000036, - "output_cost_per_token": 0.000018, - "cache_read_input_token_cost": 3.6e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0", - "name": "us-gov-west-1/claude-sonnet-4-5-20250929-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.0000036, - "output_cost_per_token": 0.000018, - "cache_read_input_token_cost": 3.6e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0", - "name": "us-gov-west-1/meta.llama3-70b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.00000265, - "output_cost_per_token": 0.0000035, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-west-1/meta.llama3-8b-instruct-v1:0", - "name": "us-gov-west-1/meta.llama3-8b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.00000265, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-1/meta.llama3-70b-instruct-v1:0", - "name": "us-west-1/meta.llama3-70b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.00000265, - "output_cost_per_token": 0.0000035, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-1/meta.llama3-8b-instruct-v1:0", - "name": "us-west-1/meta.llama3-8b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-2/anthropic.claude-instant-v1", - "name": "us-west-2/anthropic.claude-instant-v1", - "provider": "bedrock", - "input_cost_per_token": 8e-7, - "output_cost_per_token": 0.0000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-2/anthropic.claude-v1", - "name": "us-west-2/anthropic.claude-v1", - "provider": "bedrock", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-2/anthropic.claude-v2:1", - "name": "us-west-2/anthropic.claude-v2:1", - "provider": "bedrock", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-2/mistral.mistral-7b-instruct-v0:2", - "name": "us-west-2/mistral.mistral-7b-instruct-v0:2", - "provider": "bedrock", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-2/mistral.mistral-large-2402-v1:0", - "name": "us-west-2/mistral.mistral-large-2402-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-2/mistral.mixtral-8x7b-instruct-v0:1", - "name": "us-west-2/mistral.mixtral-8x7b-instruct-v0:1", - "provider": "bedrock", - "input_cost_per_token": 4.5e-7, - "output_cost_per_token": 7e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-2/deepseek.v3.2", - "name": "us-west-2/deepseek.v3.2", - "provider": "bedrock", - "input_cost_per_token": 6.2e-7, - "output_cost_per_token": 0.00000185, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-2/minimax.minimax-m2.1", - "name": "us-west-2/minimax.minimax-m2.1", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-2/minimax.minimax-m2.5", - "name": "us-west-2/minimax.minimax-m2.5", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-2/moonshotai.kimi-k2-thinking", - "name": "us-west-2/moonshotai.kimi-k2-thinking", - "provider": "bedrock", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-2/moonshotai.kimi-k2.5", - "name": "us-west-2/moonshotai.kimi-k2.5", - "provider": "bedrock", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.000003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-2/qwen.qwen3-coder-next", - "name": "us-west-2/qwen.qwen3-coder-next", - "provider": "bedrock", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0", - "name": "us.anthropic.claude-3-5-haiku-20241022-v1:0", - "provider": "bedrock", - "input_cost_per_token": 8e-7, - "output_cost_per_token": 0.000004, - "cache_read_input_token_cost": 8e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "chatgpt-4o-latest", - "name": "chatgpt-4o-latest", - "provider": "openai", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-haiku-4-5-20251001", - "name": "claude-haiku-4-5-20251001", - "provider": "anthropic", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000005, - "cache_read_input_token_cost": 1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-haiku-4-5", - "name": "claude-haiku-4-5", - "provider": "anthropic", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000005, - "cache_read_input_token_cost": 1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-3-7-sonnet-20250219", - "name": "claude-3-7-sonnet-20250219", - "provider": "anthropic", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-3-haiku-20240307", - "name": "claude-3-haiku-20240307", - "provider": "anthropic", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.00000125, - "cache_read_input_token_cost": 3e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-3-opus-20240229", - "name": "claude-3-opus-20240229", - "provider": "anthropic", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-4-opus-20250514", - "name": "claude-4-opus-20250514", - "provider": "anthropic", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-4-sonnet-20250514", - "name": "claude-4-sonnet-20250514", - "provider": "anthropic", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-sonnet-4-5", - "name": "claude-sonnet-4-5", - "provider": "anthropic", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-sonnet-4-5-20250929", - "name": "claude-sonnet-4-5-20250929", - "provider": "anthropic", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-sonnet-5", - "name": "claude-sonnet-5", - "provider": "anthropic", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-sonnet-4-6", - "name": "claude-sonnet-4-6", - "provider": "anthropic", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-sonnet-4-5-20250929-v1:0", - "name": "claude-sonnet-4-5-20250929-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-opus-4-1", - "name": "claude-opus-4-1", - "provider": "anthropic", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-opus-4-1-20250805", - "name": "claude-opus-4-1-20250805", - "provider": "anthropic", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-opus-4-20250514", - "name": "claude-opus-4-20250514", - "provider": "anthropic", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-opus-4-5-20251101", - "name": "claude-opus-4-5-20251101", - "provider": "anthropic", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-opus-4-5", - "name": "claude-opus-4-5", - "provider": "anthropic", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-opus-4-6", - "name": "claude-opus-4-6", - "provider": "anthropic", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-opus-4-6-20260205", - "name": "claude-opus-4-6-20260205", - "provider": "anthropic", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-opus-4-7", - "name": "claude-opus-4-7", - "provider": "anthropic", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-opus-4-7-20260416", - "name": "claude-opus-4-7-20260416", - "provider": "anthropic", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-fable-5", - "name": "claude-fable-5", - "provider": "anthropic", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00005, - "cache_read_input_token_cost": 0.000001, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-opus-5", - "name": "claude-opus-5", - "provider": "anthropic", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-opus-4-8", - "name": "claude-opus-4-8", - "provider": "anthropic", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "claude-sonnet-4-20250514", - "name": "claude-sonnet-4-20250514", - "provider": "anthropic", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "cohere.command-light-text-v14", - "name": "cohere.command-light-text-v14", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "cohere.command-r-plus-v1:0", - "name": "cohere.command-r-plus-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "cohere.command-r-v1:0", - "name": "cohere.command-r-v1:0", - "provider": "bedrock", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "cohere.command-text-v14", - "name": "cohere.command-text-v14", - "provider": "bedrock", - "input_cost_per_token": 0.0000015, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "computer-use-preview", - "name": "computer-use-preview", - "provider": "azure", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "deepseek.v3-v1:0", - "name": "deepseek.v3-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 5.8e-7, - "output_cost_per_token": 0.00000168, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "deepseek.v3.2", - "name": "deepseek.v3.2", - "provider": "bedrock_converse", - "input_cost_per_token": 6.2e-7, - "output_cost_per_token": 0.00000185, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.amazon.nova-lite-v1:0", - "name": "eu.amazon.nova-lite-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 7.8e-8, - "output_cost_per_token": 3.12e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.amazon.nova-micro-v1:0", - "name": "eu.amazon.nova-micro-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 4.6e-8, - "output_cost_per_token": 1.84e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.amazon.nova-pro-v1:0", - "name": "eu.amazon.nova-pro-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.00000105, - "output_cost_per_token": 0.0000042, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-3-5-haiku-20241022-v1:0", - "name": "eu.anthropic.claude-3-5-haiku-20241022-v1:0", - "provider": "bedrock", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.00000125, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", - "name": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 0.0000055, - "cache_read_input_token_cost": 1.1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-3-5-sonnet-20240620-v1:0", - "name": "eu.anthropic.claude-3-5-sonnet-20240620-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-3-5-sonnet-20241022-v2:0", - "name": "eu.anthropic.claude-3-5-sonnet-20241022-v2:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-3-7-sonnet-20250219-v1:0", - "name": "eu.anthropic.claude-3-7-sonnet-20250219-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-3-haiku-20240307-v1:0", - "name": "eu.anthropic.claude-3-haiku-20240307-v1:0", - "provider": "bedrock", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.00000125, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-3-opus-20240229-v1:0", - "name": "eu.anthropic.claude-3-opus-20240229-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-3-sonnet-20240229-v1:0", - "name": "eu.anthropic.claude-3-sonnet-20240229-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-opus-4-1-20250805-v1:0", - "name": "eu.anthropic.claude-opus-4-1-20250805-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-opus-4-20250514-v1:0", - "name": "eu.anthropic.claude-opus-4-20250514-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-sonnet-4-20250514-v1:0", - "name": "eu.anthropic.claude-sonnet-4-20250514-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", - "name": "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000033, - "output_cost_per_token": 0.0000165, - "cache_read_input_token_cost": 3.3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.meta.llama3-2-1b-instruct-v1:0", - "name": "eu.meta.llama3-2-1b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 1.3e-7, - "output_cost_per_token": 1.3e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.meta.llama3-2-3b-instruct-v1:0", - "name": "eu.meta.llama3-2-3b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 1.9e-7, - "output_cost_per_token": 1.9e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.mistral.pixtral-large-2502-v1:0", - "name": "eu.mistral.pixtral-large-2502-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "ft:gpt-3.5-turbo", - "name": "ft:gpt-3.5-turbo", - "provider": "openai", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "ft:gpt-3.5-turbo-0125", - "name": "ft:gpt-3.5-turbo-0125", - "provider": "openai", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "ft:gpt-3.5-turbo-0613", - "name": "ft:gpt-3.5-turbo-0613", - "provider": "openai", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "ft:gpt-3.5-turbo-1106", - "name": "ft:gpt-3.5-turbo-1106", - "provider": "openai", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "ft:gpt-4-0613", - "name": "ft:gpt-4-0613", - "provider": "openai", - "input_cost_per_token": 0.00003, - "output_cost_per_token": 0.00006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "ft:gpt-4o-2024-08-06", - "name": "ft:gpt-4o-2024-08-06", - "provider": "openai", - "input_cost_per_token": 0.00000375, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 0.000001875, - "output_cost_per_reasoning_token": null - }, - { - "id": "ft:gpt-4o-2024-11-20", - "name": "ft:gpt-4o-2024-11-20", - "provider": "openai", - "input_cost_per_token": 0.00000375, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "ft:gpt-4o-mini-2024-07-18", - "name": "ft:gpt-4o-mini-2024-07-18", - "provider": "openai", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": 1.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "ft:gpt-4.1-2025-04-14", - "name": "ft:gpt-4.1-2025-04-14", - "provider": "openai", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": 7.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "ft:gpt-4.1-mini-2025-04-14", - "name": "ft:gpt-4.1-mini-2025-04-14", - "provider": "openai", - "input_cost_per_token": 8e-7, - "output_cost_per_token": 0.0000032, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "ft:gpt-4.1-nano-2025-04-14", - "name": "ft:gpt-4.1-nano-2025-04-14", - "provider": "openai", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 8e-7, - "cache_read_input_token_cost": 5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "ft:o4-mini-2025-04-16", - "name": "ft:o4-mini-2025-04-16", - "provider": "openai", - "input_cost_per_token": 0.000004, - "output_cost_per_token": 0.000016, - "cache_read_input_token_cost": 0.000001, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-2.0-flash", - "name": "gemini-2.0-flash", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-2.0-flash-001", - "name": "gemini-2.0-flash-001", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": 3.75e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-2.0-flash-lite", - "name": "gemini-2.0-flash-lite", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 7.5e-8, - "output_cost_per_token": 3e-7, - "cache_read_input_token_cost": 1.875e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-2.0-flash-lite-001", - "name": "gemini-2.0-flash-lite-001", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 7.5e-8, - "output_cost_per_token": 3e-7, - "cache_read_input_token_cost": 1.875e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-2.5-flash", - "name": "gemini-2.5-flash", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 3e-8, - "output_cost_per_reasoning_token": 0.0000025 - }, - { - "id": "gemini-3.1-flash-lite-preview", - "name": "gemini-3.1-flash-lite-preview", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": 0.0000015 - }, - { - "id": "gemini-3.1-flash-lite", - "name": "gemini-3.1-flash-lite", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": 0.0000015 - }, - { - "id": "gemini-3.5-flash-lite", - "name": "gemini-3.5-flash-lite", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 3e-8, - "output_cost_per_reasoning_token": 0.0000025 - }, - { - "id": "gemini-2.5-flash-lite", - "name": "gemini-2.5-flash-lite", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 1e-8, - "output_cost_per_reasoning_token": 4e-7 - }, - { - "id": "gemini-2.5-flash-lite-preview-09-2025", - "name": "gemini-2.5-flash-lite-preview-09-2025", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 1e-8, - "output_cost_per_reasoning_token": 4e-7 - }, - { - "id": "gemini-2.5-flash-preview-09-2025", - "name": "gemini-2.5-flash-preview-09-2025", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": 0.0000025 - }, - { - "id": "gemini-2.5-flash-lite-preview-06-17", - "name": "gemini-2.5-flash-lite-preview-06-17", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": 4e-7 - }, - { - "id": "gemini-2.5-pro", - "name": "gemini-2.5-pro", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-3-pro-preview", - "name": "gemini-3-pro-preview", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-3.1-pro-preview", - "name": "gemini-3.1-pro-preview", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-3.1-pro-preview-customtools", - "name": "gemini-3.1-pro-preview-customtools", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/gemini-3-pro-preview", - "name": "gemini-3-pro-preview", - "provider": "vertex_ai", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/gemini-3-flash-preview", - "name": "gemini-3-flash-preview", - "provider": "vertex_ai", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.000003, - "cache_read_input_token_cost": 5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/gemini-3.5-flash", - "name": "gemini-3.5-flash", - "provider": "vertex_ai", - "input_cost_per_token": 0.0000015, - "output_cost_per_token": 0.000009, - "cache_read_input_token_cost": 1.5e-7, - "output_cost_per_reasoning_token": 0.000009 - }, - { - "id": "vertex_ai/gemini-3.6-flash", - "name": "gemini-3.6-flash", - "provider": "vertex_ai", - "input_cost_per_token": 0.0000015, - "output_cost_per_token": 0.0000075, - "cache_read_input_token_cost": 1.5e-7, - "output_cost_per_reasoning_token": 0.0000075 - }, - { - "id": "vertex_ai/gemini-3.1-pro-preview", - "name": "gemini-3.1-pro-preview", - "provider": "vertex_ai", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/gemini-3.1-pro-preview-customtools", - "name": "gemini-3.1-pro-preview-customtools", - "provider": "vertex_ai", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-2.5-pro-preview-tts", - "name": "gemini-2.5-pro-preview-tts", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-robotics-er-1.5-preview", - "name": "gemini-robotics-er-1.5-preview", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 0, - "output_cost_per_reasoning_token": 0.0000025 - }, - { - "id": "gemini/gemini-robotics-er-1.5-preview", - "name": "gemini-robotics-er-1.5-preview", - "provider": "gemini", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 0, - "output_cost_per_reasoning_token": 0.0000025 - }, - { - "id": "gemini/gemini-robotics-er-2-preview", - "name": "gemini-robotics-er-2-preview", - "provider": "gemini", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": 0.00001 - }, - { - "id": "gemini/gemini-robotics-er-1.6-preview", - "name": "gemini-robotics-er-1.6-preview", - "provider": "gemini", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000005, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": 0.000005 - }, - { - "id": "gemini-2.5-computer-use-preview-10-2025", - "name": "gemini-2.5-computer-use-preview-10-2025", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-2.0-flash", - "name": "gemini-2.0-flash", - "provider": "gemini", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-2.0-flash-001", - "name": "gemini-2.0-flash-001", - "provider": "gemini", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-2.0-flash-lite", - "name": "gemini-2.0-flash-lite", - "provider": "gemini", - "input_cost_per_token": 7.5e-8, - "output_cost_per_token": 3e-7, - "cache_read_input_token_cost": 1.875e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-2.5-flash", - "name": "gemini-2.5-flash", - "provider": "gemini", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 3e-8, - "output_cost_per_reasoning_token": 0.0000025 - }, - { - "id": "gemini/gemini-2.5-flash-lite", - "name": "gemini-2.5-flash-lite", - "provider": "gemini", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 1e-8, - "output_cost_per_reasoning_token": 4e-7 - }, - { - "id": "gemini/gemini-2.5-flash-lite-preview-09-2025", - "name": "gemini-2.5-flash-lite-preview-09-2025", - "provider": "gemini", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 1e-8, - "output_cost_per_reasoning_token": 4e-7 - }, - { - "id": "gemini/gemini-2.5-flash-preview-09-2025", - "name": "gemini-2.5-flash-preview-09-2025", - "provider": "gemini", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": 0.0000025 - }, - { - "id": "gemini/gemini-flash-latest", - "name": "gemini-flash-latest", - "provider": "gemini", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": 0.0000025 - }, - { - "id": "gemini/gemini-flash-lite-latest", - "name": "gemini-flash-lite-latest", - "provider": "gemini", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": 4e-7 - }, - { - "id": "gemini/gemini-2.5-flash-lite-preview-06-17", - "name": "gemini-2.5-flash-lite-preview-06-17", - "provider": "gemini", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": 4e-7 - }, - { - "id": "gemini/gemini-2.5-pro", - "name": "gemini-2.5-pro", - "provider": "gemini", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-2.5-computer-use-preview-10-2025", - "name": "gemini-2.5-computer-use-preview-10-2025", - "provider": "gemini", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-3-pro-preview", - "name": "gemini-3-pro-preview", - "provider": "gemini", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-3.1-flash-lite-preview", - "name": "gemini-3.1-flash-lite-preview", - "provider": "gemini", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": 0.0000015 - }, - { - "id": "gemini/gemini-3.1-flash-lite", - "name": "gemini-3.1-flash-lite", - "provider": "gemini", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": 0.0000015 - }, - { - "id": "gemini/gemini-3.5-flash-lite", - "name": "gemini-3.5-flash-lite", - "provider": "gemini", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 3e-8, - "output_cost_per_reasoning_token": 0.0000025 - }, - { - "id": "gemini/gemini-3-flash-preview", - "name": "gemini-3-flash-preview", - "provider": "gemini", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.000003, - "cache_read_input_token_cost": 5e-8, - "output_cost_per_reasoning_token": 0.000003 - }, - { - "id": "gemini/gemini-3.5-flash", - "name": "gemini-3.5-flash", - "provider": "gemini", - "input_cost_per_token": 0.0000015, - "output_cost_per_token": 0.000009, - "cache_read_input_token_cost": 1.5e-7, - "output_cost_per_reasoning_token": 0.000009 - }, - { - "id": "gemini/gemini-3.6-flash", - "name": "gemini-3.6-flash", - "provider": "gemini", - "input_cost_per_token": 0.0000015, - "output_cost_per_token": 0.0000075, - "cache_read_input_token_cost": 1.5e-7, - "output_cost_per_reasoning_token": 0.0000075 - }, - { - "id": "gemini/gemini-omni-flash-preview", - "name": "gemini-omni-flash-preview", - "provider": "gemini", - "input_cost_per_token": 0.0000015, - "output_cost_per_token": 0.000009, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": 0.000009 - }, - { - "id": "gemini/gemini-3.1-pro-preview", - "name": "gemini-3.1-pro-preview", - "provider": "gemini", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-3.1-pro-preview-customtools", - "name": "gemini-3.1-pro-preview-customtools", - "provider": "gemini", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-3-flash-preview", - "name": "gemini-3-flash-preview", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.000003, - "cache_read_input_token_cost": 5e-8, - "output_cost_per_reasoning_token": 0.000003 - }, - { - "id": "gemini-omni-flash-preview", - "name": "gemini-omni-flash-preview", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 0.0000015, - "output_cost_per_token": 0.000009, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": 0.000009 - }, - { - "id": "gemini-3.5-flash", - "name": "gemini-3.5-flash", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 0.0000015, - "output_cost_per_token": 0.000009, - "cache_read_input_token_cost": 1.5e-7, - "output_cost_per_reasoning_token": 0.000009 - }, - { - "id": "gemini-3.6-flash", - "name": "gemini-3.6-flash", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 0.0000015, - "output_cost_per_token": 0.0000075, - "cache_read_input_token_cost": 1.5e-7, - "output_cost_per_reasoning_token": 0.0000075 - }, - { - "id": "gemini/gemini-2.5-pro-preview-tts", - "name": "gemini-2.5-pro-preview-tts", - "provider": "gemini", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-exp-1114", - "name": "gemini-exp-1114", - "provider": "gemini", - "input_cost_per_token": 0, - "output_cost_per_token": 0, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-exp-1206", - "name": "gemini-exp-1206", - "provider": "gemini", - "input_cost_per_token": 0, - "output_cost_per_token": 0, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-gemma-2-27b-it", - "name": "gemini-gemma-2-27b-it", - "provider": "gemini", - "input_cost_per_token": 3.5e-7, - "output_cost_per_token": 0.00000105, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-gemma-2-9b-it", - "name": "gemini-gemma-2-9b-it", - "provider": "gemini", - "input_cost_per_token": 3.5e-7, - "output_cost_per_token": 0.00000105, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemma-3-27b-it", - "name": "gemma-3-27b-it", - "provider": "gemini", - "input_cost_per_token": 0, - "output_cost_per_token": 0, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/learnlm-1.5-pro-experimental", - "name": "learnlm-1.5-pro-experimental", - "provider": "gemini", - "input_cost_per_token": 0, - "output_cost_per_token": 0, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/lyria-3-clip-preview", - "name": "lyria-3-clip-preview", - "provider": "gemini", - "input_cost_per_token": 0, - "output_cost_per_token": 0, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/lyria-3-pro-preview", - "name": "lyria-3-pro-preview", - "provider": "gemini", - "input_cost_per_token": 0, - "output_cost_per_token": 0, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "google.gemma-3-12b-it", - "name": "google.gemma-3-12b-it", - "provider": "bedrock_converse", - "input_cost_per_token": 9e-8, - "output_cost_per_token": 2.9e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "google.gemma-3-27b-it", - "name": "google.gemma-3-27b-it", - "provider": "bedrock_converse", - "input_cost_per_token": 2.3e-7, - "output_cost_per_token": 3.8e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "google.gemma-3-4b-it", - "name": "google.gemma-3-4b-it", - "provider": "bedrock_converse", - "input_cost_per_token": 4e-8, - "output_cost_per_token": 8e-8, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "global.anthropic.claude-sonnet-4-5-20250929-v1:0", - "name": "global.anthropic.claude-sonnet-4-5-20250929-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "global.anthropic.claude-sonnet-4-20250514-v1:0", - "name": "global.anthropic.claude-sonnet-4-20250514-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "global.anthropic.claude-haiku-4-5-20251001-v1:0", - "name": "global.anthropic.claude-haiku-4-5-20251001-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000005, - "cache_read_input_token_cost": 1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "global.amazon.nova-2-lite-v1:0", - "name": "global.amazon.nova-2-lite-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-3.5-turbo", - "name": "gpt-3.5-turbo", - "provider": "openai", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-3.5-turbo-0125", - "name": "gpt-3.5-turbo-0125", - "provider": "openai", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-3.5-turbo-1106", - "name": "gpt-3.5-turbo-1106", - "provider": "openai", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-3.5-turbo-16k", - "name": "gpt-3.5-turbo-16k", - "provider": "openai", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000004, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4", - "name": "gpt-4", - "provider": "openai", - "input_cost_per_token": 0.00003, - "output_cost_per_token": 0.00006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4-0125-preview", - "name": "gpt-4-0125-preview", - "provider": "openai", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4-0314", - "name": "gpt-4-0314", - "provider": "openai", - "input_cost_per_token": 0.00003, - "output_cost_per_token": 0.00006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4-0613", - "name": "gpt-4-0613", - "provider": "openai", - "input_cost_per_token": 0.00003, - "output_cost_per_token": 0.00006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4-1106-preview", - "name": "gpt-4-1106-preview", - "provider": "openai", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4-turbo", - "name": "gpt-4-turbo", - "provider": "openai", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4-turbo-2024-04-09", - "name": "gpt-4-turbo-2024-04-09", - "provider": "openai", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4-turbo-preview", - "name": "gpt-4-turbo-preview", - "provider": "openai", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4.1", - "name": "gpt-4.1", - "provider": "openai", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000008, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4.1-2025-04-14", - "name": "gpt-4.1-2025-04-14", - "provider": "openai", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000008, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4.1-mini", - "name": "gpt-4.1-mini", - "provider": "openai", - "input_cost_per_token": 4e-7, - "output_cost_per_token": 0.0000016, - "cache_read_input_token_cost": 1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4.1-mini-2025-04-14", - "name": "gpt-4.1-mini-2025-04-14", - "provider": "openai", - "input_cost_per_token": 4e-7, - "output_cost_per_token": 0.0000016, - "cache_read_input_token_cost": 1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4.1-nano", - "name": "gpt-4.1-nano", - "provider": "openai", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4.1-nano-2025-04-14", - "name": "gpt-4.1-nano-2025-04-14", - "provider": "openai", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o", - "name": "gpt-4o", - "provider": "openai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 0.00000125, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o-2024-05-13", - "name": "gpt-4o-2024-05-13", - "provider": "openai", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o-2024-08-06", - "name": "gpt-4o-2024-08-06", - "provider": "openai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 0.00000125, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o-2024-11-20", - "name": "gpt-4o-2024-11-20", - "provider": "openai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 0.00000125, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o-audio-preview", - "name": "gpt-4o-audio-preview", - "provider": "openai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o-audio-preview-2024-12-17", - "name": "gpt-4o-audio-preview-2024-12-17", - "provider": "openai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o-audio-preview-2025-06-03", - "name": "gpt-4o-audio-preview-2025-06-03", - "provider": "openai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-audio", - "name": "gpt-audio", - "provider": "openai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-audio-1.5", - "name": "gpt-audio-1.5", - "provider": "openai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-audio-2025-08-28", - "name": "gpt-audio-2025-08-28", - "provider": "openai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-audio-mini", - "name": "gpt-audio-mini", - "provider": "openai", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.0000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-audio-mini-2025-10-06", - "name": "gpt-audio-mini-2025-10-06", - "provider": "openai", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.0000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-audio-mini-2025-12-15", - "name": "gpt-audio-mini-2025-12-15", - "provider": "openai", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.0000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o-mini", - "name": "gpt-4o-mini", - "provider": "openai", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o-mini-2024-07-18", - "name": "gpt-4o-mini-2024-07-18", - "provider": "openai", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o-mini-audio-preview", - "name": "gpt-4o-mini-audio-preview", - "provider": "openai", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o-mini-audio-preview-2024-12-17", - "name": "gpt-4o-mini-audio-preview-2024-12-17", - "provider": "openai", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o-mini-search-preview", - "name": "gpt-4o-mini-search-preview", - "provider": "openai", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o-mini-search-preview-2025-03-11", - "name": "gpt-4o-mini-search-preview-2025-03-11", - "provider": "openai", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o-search-preview", - "name": "gpt-4o-search-preview", - "provider": "openai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 0.00000125, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-4o-search-preview-2025-03-11", - "name": "gpt-4o-search-preview-2025-03-11", - "provider": "openai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 0.00000125, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5", - "name": "gpt-5", - "provider": "openai", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.1", - "name": "gpt-5.1", - "provider": "openai", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.1-2025-11-13", - "name": "gpt-5.1-2025-11-13", - "provider": "openai", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.1-chat-latest", - "name": "gpt-5.1-chat-latest", - "provider": "openai", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.2", - "name": "gpt-5.2", - "provider": "openai", - "input_cost_per_token": 0.00000175, - "output_cost_per_token": 0.000014, - "cache_read_input_token_cost": 1.75e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.2-2025-12-11", - "name": "gpt-5.2-2025-12-11", - "provider": "openai", - "input_cost_per_token": 0.00000175, - "output_cost_per_token": 0.000014, - "cache_read_input_token_cost": 1.75e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.2-chat-latest", - "name": "gpt-5.2-chat-latest", - "provider": "openai", - "input_cost_per_token": 0.00000175, - "output_cost_per_token": 0.000014, - "cache_read_input_token_cost": 1.75e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.3-chat-latest", - "name": "gpt-5.3-chat-latest", - "provider": "openai", - "input_cost_per_token": 0.00000175, - "output_cost_per_token": 0.000014, - "cache_read_input_token_cost": 1.75e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.6", - "name": "gpt-5.6", - "provider": "openai", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.6-sol", - "name": "gpt-5.6-sol", - "provider": "openai", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.6-terra", - "name": "gpt-5.6-terra", - "provider": "openai", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000012, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.6-luna", - "name": "gpt-5.6-luna", - "provider": "openai", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": 2e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.5", - "name": "gpt-5.5", - "provider": "openai", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.5-2026-04-23", - "name": "gpt-5.5-2026-04-23", - "provider": "openai", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.00003, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.4", - "name": "gpt-5.4", - "provider": "openai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 2.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.4-2026-03-05", - "name": "gpt-5.4-2026-03-05", - "provider": "openai", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 2.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.4-mini", - "name": "gpt-5.4-mini", - "provider": "openai", - "input_cost_per_token": 7.5e-7, - "output_cost_per_token": 0.0000045, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.4-mini-2026-03-17", - "name": "gpt-5.4-mini-2026-03-17", - "provider": "openai", - "input_cost_per_token": 7.5e-7, - "output_cost_per_token": 0.0000045, - "cache_read_input_token_cost": 7.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.4-nano", - "name": "gpt-5.4-nano", - "provider": "openai", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 0.00000125, - "cache_read_input_token_cost": 2e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5.4-nano-2026-03-17", - "name": "gpt-5.4-nano-2026-03-17", - "provider": "openai", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 0.00000125, - "cache_read_input_token_cost": 2e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5-2025-08-07", - "name": "gpt-5-2025-08-07", - "provider": "openai", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5-chat", - "name": "gpt-5-chat", - "provider": "openai", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5-chat-latest", - "name": "gpt-5-chat-latest", - "provider": "openai", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5-mini", - "name": "gpt-5-mini", - "provider": "openai", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5-mini-2025-08-07", - "name": "gpt-5-mini-2025-08-07", - "provider": "openai", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5-nano", - "name": "gpt-5-nano", - "provider": "openai", - "input_cost_per_token": 5e-8, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 5e-9, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5-nano-2025-08-07", - "name": "gpt-5-nano-2025-08-07", - "provider": "openai", - "input_cost_per_token": 5e-8, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 5e-9, - "output_cost_per_reasoning_token": null - }, - { - "id": "jp.anthropic.claude-sonnet-4-5-20250929-v1:0", - "name": "jp.anthropic.claude-sonnet-4-5-20250929-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000033, - "output_cost_per_token": 0.0000165, - "cache_read_input_token_cost": 3.3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "jp.anthropic.claude-haiku-4-5-20251001-v1:0", - "name": "jp.anthropic.claude-haiku-4-5-20251001-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 0.0000055, - "cache_read_input_token_cost": 1.1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "meta.llama2-13b-chat-v1", - "name": "meta.llama2-13b-chat-v1", - "provider": "bedrock", - "input_cost_per_token": 7.5e-7, - "output_cost_per_token": 0.000001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "meta.llama2-70b-chat-v1", - "name": "meta.llama2-70b-chat-v1", - "provider": "bedrock", - "input_cost_per_token": 0.00000195, - "output_cost_per_token": 0.00000256, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "meta.llama3-1-405b-instruct-v1:0", - "name": "meta.llama3-1-405b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.00000532, - "output_cost_per_token": 0.000016, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "meta.llama3-1-70b-instruct-v1:0", - "name": "meta.llama3-1-70b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 9.9e-7, - "output_cost_per_token": 9.9e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "meta.llama3-1-8b-instruct-v1:0", - "name": "meta.llama3-1-8b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 2.2e-7, - "output_cost_per_token": 2.2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "meta.llama3-2-11b-instruct-v1:0", - "name": "meta.llama3-2-11b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 3.5e-7, - "output_cost_per_token": 3.5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "meta.llama3-2-1b-instruct-v1:0", - "name": "meta.llama3-2-1b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 1e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "meta.llama3-2-3b-instruct-v1:0", - "name": "meta.llama3-2-3b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 1.5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "meta.llama3-2-90b-instruct-v1:0", - "name": "meta.llama3-2-90b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "meta.llama3-3-70b-instruct-v1:0", - "name": "meta.llama3-3-70b-instruct-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 7.2e-7, - "output_cost_per_token": 7.2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "meta.llama3-70b-instruct-v1:0", - "name": "meta.llama3-70b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.00000265, - "output_cost_per_token": 0.0000035, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "meta.llama3-8b-instruct-v1:0", - "name": "meta.llama3-8b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "meta.llama4-maverick-17b-instruct-v1:0", - "name": "meta.llama4-maverick-17b-instruct-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 2.4e-7, - "output_cost_per_token": 9.7e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "meta.llama4-scout-17b-instruct-v1:0", - "name": "meta.llama4-scout-17b-instruct-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 1.7e-7, - "output_cost_per_token": 6.6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "minimax.minimax-m2", - "name": "minimax.minimax-m2", - "provider": "bedrock_converse", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "minimax.minimax-m2.1", - "name": "minimax.minimax-m2.1", - "provider": "bedrock_converse", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "minimax.minimax-m2.5", - "name": "minimax.minimax-m2.5", - "provider": "bedrock_converse", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "mistral.devstral-2-123b", - "name": "mistral.devstral-2-123b", - "provider": "bedrock_converse", - "input_cost_per_token": 4e-7, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "mistral.magistral-small-2509", - "name": "mistral.magistral-small-2509", - "provider": "bedrock_converse", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "mistral.ministral-3-14b-instruct", - "name": "mistral.ministral-3-14b-instruct", - "provider": "bedrock_converse", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "mistral.ministral-3-3b-instruct", - "name": "mistral.ministral-3-3b-instruct", - "provider": "bedrock_converse", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 1e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "mistral.ministral-3-8b-instruct", - "name": "mistral.ministral-3-8b-instruct", - "provider": "bedrock_converse", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 1.5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "mistral.mistral-7b-instruct-v0:2", - "name": "mistral.mistral-7b-instruct-v0:2", - "provider": "bedrock", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "mistral.mistral-large-2402-v1:0", - "name": "mistral.mistral-large-2402-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000008, - "output_cost_per_token": 0.000024, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "mistral.mistral-large-2407-v1:0", - "name": "mistral.mistral-large-2407-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000009, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "mistral.mistral-large-3-675b-instruct", - "name": "mistral.mistral-large-3-675b-instruct", - "provider": "bedrock_converse", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "mistral.mistral-small-2402-v1:0", - "name": "mistral.mistral-small-2402-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "mistral.mixtral-8x7b-instruct-v0:1", - "name": "mistral.mixtral-8x7b-instruct-v0:1", - "provider": "bedrock", - "input_cost_per_token": 4.5e-7, - "output_cost_per_token": 7e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "mistral.voxtral-mini-3b-2507", - "name": "mistral.voxtral-mini-3b-2507", - "provider": "bedrock_converse", - "input_cost_per_token": 4e-8, - "output_cost_per_token": 4e-8, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "mistral.voxtral-small-24b-2507", - "name": "mistral.voxtral-small-24b-2507", - "provider": "bedrock_converse", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 3e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "moonshot.kimi-k2-thinking", - "name": "moonshot.kimi-k2-thinking", - "provider": "bedrock_converse", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "moonshotai.kimi-k2.5", - "name": "moonshotai.kimi-k2.5", - "provider": "bedrock_converse", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.000003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "nvidia.nemotron-nano-12b-v2", - "name": "nvidia.nemotron-nano-12b-v2", - "provider": "bedrock_converse", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "nvidia.nemotron-nano-9b-v2", - "name": "nvidia.nemotron-nano-9b-v2", - "provider": "bedrock_converse", - "input_cost_per_token": 6e-8, - "output_cost_per_token": 2.3e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "nvidia.nemotron-nano-3-30b", - "name": "nvidia.nemotron-nano-3-30b", - "provider": "bedrock_converse", - "input_cost_per_token": 6e-8, - "output_cost_per_token": 2.4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "nvidia.nemotron-super-3-120b", - "name": "nvidia.nemotron-super-3-120b", - "provider": "bedrock_converse", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6.5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "o1", - "name": "o1", - "provider": "openai", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.00006, - "cache_read_input_token_cost": 0.0000075, - "output_cost_per_reasoning_token": null - }, - { - "id": "o1-2024-12-17", - "name": "o1-2024-12-17", - "provider": "openai", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.00006, - "cache_read_input_token_cost": 0.0000075, - "output_cost_per_reasoning_token": null - }, - { - "id": "o3", - "name": "o3", - "provider": "openai", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000008, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "o3-2025-04-16", - "name": "o3-2025-04-16", - "provider": "openai", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000008, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "o3-mini", - "name": "o3-mini", - "provider": "openai", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 0.0000044, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "o3-mini-2025-01-31", - "name": "o3-mini-2025-01-31", - "provider": "openai", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 0.0000044, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "o4-mini", - "name": "o4-mini", - "provider": "openai", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 0.0000044, - "cache_read_input_token_cost": 2.75e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "o4-mini-2025-04-16", - "name": "o4-mini-2025-04-16", - "provider": "openai", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 0.0000044, - "cache_read_input_token_cost": 2.75e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "openai.gpt-oss-120b-1:0", - "name": "openai.gpt-oss-120b-1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "openai.gpt-oss-20b-1:0", - "name": "openai.gpt-oss-20b-1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 7e-8, - "output_cost_per_token": 3e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "openai.gpt-oss-safeguard-120b", - "name": "openai.gpt-oss-safeguard-120b", - "provider": "bedrock_converse", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "openai.gpt-oss-safeguard-20b", - "name": "openai.gpt-oss-safeguard-20b", - "provider": "bedrock_converse", - "input_cost_per_token": 7e-8, - "output_cost_per_token": 2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "qwen.qwen3-coder-480b-a35b-v1:0", - "name": "qwen.qwen3-coder-480b-a35b-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 2.2e-7, - "output_cost_per_token": 0.0000018, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "qwen.qwen3-235b-a22b-2507-v1:0", - "name": "qwen.qwen3-235b-a22b-2507-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 2.2e-7, - "output_cost_per_token": 8.8e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "qwen.qwen3-coder-30b-a3b-v1:0", - "name": "qwen.qwen3-coder-30b-a3b-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "qwen.qwen3-32b-v1:0", - "name": "qwen.qwen3-32b-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "qwen.qwen3-next-80b-a3b", - "name": "qwen.qwen3-next-80b-a3b", - "provider": "bedrock_converse", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "qwen.qwen3-vl-235b-a22b", - "name": "qwen.qwen3-vl-235b-a22b", - "provider": "bedrock_converse", - "input_cost_per_token": 5.3e-7, - "output_cost_per_token": 0.00000266, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "qwen.qwen3-coder-next", - "name": "qwen.qwen3-coder-next", - "provider": "bedrock_converse", - "input_cost_per_token": 5e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.amazon.nova-lite-v1:0", - "name": "us.amazon.nova-lite-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 6e-8, - "output_cost_per_token": 2.4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.amazon.nova-micro-v1:0", - "name": "us.amazon.nova-micro-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 3.5e-8, - "output_cost_per_token": 1.4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.amazon.nova-premier-v1:0", - "name": "us.amazon.nova-premier-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000025, - "output_cost_per_token": 0.0000125, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.amazon.nova-pro-v1:0", - "name": "us.amazon.nova-pro-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 8e-7, - "output_cost_per_token": 0.0000032, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-3-5-haiku-20241022-v1:0", - "name": "us.anthropic.claude-3-5-haiku-20241022-v1:0", - "provider": "bedrock", - "input_cost_per_token": 8e-7, - "output_cost_per_token": 0.000004, - "cache_read_input_token_cost": 8e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-haiku-4-5-20251001-v1:0", - "name": "us.anthropic.claude-haiku-4-5-20251001-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 0.0000055, - "cache_read_input_token_cost": 1.1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-3-5-sonnet-20240620-v1:0", - "name": "us.anthropic.claude-3-5-sonnet-20240620-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-3-5-sonnet-20241022-v2:0", - "name": "us.anthropic.claude-3-5-sonnet-20241022-v2:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-3-7-sonnet-20250219-v1:0", - "name": "us.anthropic.claude-3-7-sonnet-20250219-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-3-haiku-20240307-v1:0", - "name": "us.anthropic.claude-3-haiku-20240307-v1:0", - "provider": "bedrock", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.00000125, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-3-opus-20240229-v1:0", - "name": "us.anthropic.claude-3-opus-20240229-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-3-sonnet-20240229-v1:0", - "name": "us.anthropic.claude-3-sonnet-20240229-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-opus-4-1-20250805-v1:0", - "name": "us.anthropic.claude-opus-4-1-20250805-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", - "name": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000033, - "output_cost_per_token": 0.0000165, - "cache_read_input_token_cost": 3.3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0", - "name": "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000036, - "output_cost_per_token": 0.000018, - "cache_read_input_token_cost": 3.6e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "au.anthropic.claude-haiku-4-5-20251001-v1:0", - "name": "au.anthropic.claude-haiku-4-5-20251001-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000011, - "output_cost_per_token": 0.0000055, - "cache_read_input_token_cost": 1.1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-opus-4-20250514-v1:0", - "name": "us.anthropic.claude-opus-4-20250514-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-opus-4-5-20251101-v1:0", - "name": "us.anthropic.claude-opus-4-5-20251101-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.0000055, - "output_cost_per_token": 0.0000275, - "cache_read_input_token_cost": 5.5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "global.anthropic.claude-opus-4-5-20251101-v1:0", - "name": "global.anthropic.claude-opus-4-5-20251101-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.anthropic.claude-opus-4-5-20251101-v1:0", - "name": "eu.anthropic.claude-opus-4-5-20251101-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.anthropic.claude-sonnet-4-20250514-v1:0", - "name": "us.anthropic.claude-sonnet-4-20250514-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.deepseek.r1-v1:0", - "name": "us.deepseek.r1-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.00000135, - "output_cost_per_token": 0.0000054, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.deepseek.v3.2", - "name": "us.deepseek.v3.2", - "provider": "bedrock_converse", - "input_cost_per_token": 6.2e-7, - "output_cost_per_token": 0.00000185, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "eu.deepseek.v3.2", - "name": "eu.deepseek.v3.2", - "provider": "bedrock_converse", - "input_cost_per_token": 7.4e-7, - "output_cost_per_token": 0.00000222, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.meta.llama3-1-405b-instruct-v1:0", - "name": "us.meta.llama3-1-405b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.00000532, - "output_cost_per_token": 0.000016, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.meta.llama3-1-70b-instruct-v1:0", - "name": "us.meta.llama3-1-70b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 9.9e-7, - "output_cost_per_token": 9.9e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.meta.llama3-1-8b-instruct-v1:0", - "name": "us.meta.llama3-1-8b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 2.2e-7, - "output_cost_per_token": 2.2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.meta.llama3-2-11b-instruct-v1:0", - "name": "us.meta.llama3-2-11b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 3.5e-7, - "output_cost_per_token": 3.5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.meta.llama3-2-1b-instruct-v1:0", - "name": "us.meta.llama3-2-1b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 1e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.meta.llama3-2-3b-instruct-v1:0", - "name": "us.meta.llama3-2-3b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 1.5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.meta.llama3-2-90b-instruct-v1:0", - "name": "us.meta.llama3-2-90b-instruct-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.meta.llama3-3-70b-instruct-v1:0", - "name": "us.meta.llama3-3-70b-instruct-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 7.2e-7, - "output_cost_per_token": 7.2e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.meta.llama4-maverick-17b-instruct-v1:0", - "name": "us.meta.llama4-maverick-17b-instruct-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 2.4e-7, - "output_cost_per_token": 9.7e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.meta.llama4-scout-17b-instruct-v1:0", - "name": "us.meta.llama4-scout-17b-instruct-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 1.7e-7, - "output_cost_per_token": 6.6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "us.mistral.pixtral-large-2502-v1:0", - "name": "us.mistral.pixtral-large-2502-v1:0", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-3-5-haiku", - "name": "claude-3-5-haiku", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000005, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-3-5-haiku@20241022", - "name": "claude-3-5-haiku@20241022", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000005, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-haiku-4-5", - "name": "claude-haiku-4-5", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000005, - "cache_read_input_token_cost": 1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-haiku-4-5@20251001", - "name": "claude-haiku-4-5@20251001", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000005, - "cache_read_input_token_cost": 1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-3-5-sonnet", - "name": "claude-3-5-sonnet", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-3-5-sonnet@20240620", - "name": "claude-3-5-sonnet@20240620", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-3-7-sonnet@20250219", - "name": "claude-3-7-sonnet@20250219", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-3-haiku", - "name": "claude-3-haiku", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.00000125, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-3-haiku@20240307", - "name": "claude-3-haiku@20240307", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.00000125, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-3-opus", - "name": "claude-3-opus", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-3-opus@20240229", - "name": "claude-3-opus@20240229", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-3-sonnet", - "name": "claude-3-sonnet", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-3-sonnet@20240229", - "name": "claude-3-sonnet@20240229", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-opus-4", - "name": "claude-opus-4", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-opus-4-1", - "name": "claude-opus-4-1", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-opus-4-1@20250805", - "name": "claude-opus-4-1@20250805", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-opus-4-5", - "name": "claude-opus-4-5", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-opus-4-5@20251101", - "name": "claude-opus-4-5@20251101", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-opus-4-6", - "name": "claude-opus-4-6", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-opus-4-6@default", - "name": "claude-opus-4-6@default", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-opus-4-7", - "name": "claude-opus-4-7", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-opus-4-7@default", - "name": "claude-opus-4-7@default", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-fable-5", - "name": "claude-fable-5", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00005, - "cache_read_input_token_cost": 0.000001, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-fable-5@default", - "name": "claude-fable-5@default", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.00005, - "cache_read_input_token_cost": 0.000001, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-opus-5", - "name": "claude-opus-5", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-opus-5@default", - "name": "claude-opus-5@default", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-opus-4-8", - "name": "claude-opus-4-8", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-opus-4-8@default", - "name": "claude-opus-4-8@default", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 5e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-sonnet-4-5", - "name": "claude-sonnet-4-5", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-sonnet-5", - "name": "claude-sonnet-5", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-sonnet-4-6", - "name": "claude-sonnet-4-6", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-sonnet-4-5@20250929", - "name": "claude-sonnet-4-5@20250929", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-opus-4@20250514", - "name": "claude-opus-4@20250514", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000015, - "output_cost_per_token": 0.000075, - "cache_read_input_token_cost": 0.0000015, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-sonnet-4", - "name": "claude-sonnet-4", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-sonnet-4@20250514", - "name": "claude-sonnet-4@20250514", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/mistralai/codestral-2@001", - "name": "mistralai/codestral-2@001", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 9e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/codestral-2", - "name": "codestral-2", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 9e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/codestral-2@001", - "name": "codestral-2@001", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 9e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/mistralai/codestral-2", - "name": "mistralai/codestral-2", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 9e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/codestral-2501", - "name": "codestral-2501", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/codestral@2405", - "name": "codestral@2405", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/codestral@latest", - "name": "codestral@latest", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/deepseek-ai/deepseek-v3.1-maas", - "name": "deepseek-ai/deepseek-v3.1-maas", - "provider": "vertex_ai-deepseek_models", - "input_cost_per_token": 0.00000135, - "output_cost_per_token": 0.0000054, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/deepseek-ai/deepseek-v3.2-maas", - "name": "deepseek-ai/deepseek-v3.2-maas", - "provider": "vertex_ai-deepseek_models", - "input_cost_per_token": 5.6e-7, - "output_cost_per_token": 0.00000168, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/deepseek-ai/deepseek-r1-0528-maas", - "name": "deepseek-ai/deepseek-r1-0528-maas", - "provider": "vertex_ai-deepseek_models", - "input_cost_per_token": 0.00000135, - "output_cost_per_token": 0.0000054, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/gemini-3.1-flash-lite-preview", - "name": "gemini-3.1-flash-lite-preview", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": 0.0000015 - }, - { - "id": "vertex_ai/gemini-3.1-flash-lite", - "name": "gemini-3.1-flash-lite", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.0000015, - "cache_read_input_token_cost": 2.5e-8, - "output_cost_per_reasoning_token": 0.0000015 - }, - { - "id": "vertex_ai/gemini-3.5-flash-lite", - "name": "gemini-3.5-flash-lite", - "provider": "vertex_ai-language-models", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 3e-8, - "output_cost_per_reasoning_token": 0.0000025 - }, - { - "id": "vertex_ai/jamba-1.5", - "name": "jamba-1.5", - "provider": "vertex_ai-ai21_models", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/jamba-1.5-large", - "name": "jamba-1.5-large", - "provider": "vertex_ai-ai21_models", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000008, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/jamba-1.5-large@001", - "name": "jamba-1.5-large@001", - "provider": "vertex_ai-ai21_models", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000008, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/jamba-1.5-mini", - "name": "jamba-1.5-mini", - "provider": "vertex_ai-ai21_models", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/jamba-1.5-mini@001", - "name": "jamba-1.5-mini@001", - "provider": "vertex_ai-ai21_models", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/meta/llama-3.1-405b-instruct-maas", - "name": "meta/llama-3.1-405b-instruct-maas", - "provider": "vertex_ai-llama_models", - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000016, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/meta/llama-3.1-70b-instruct-maas", - "name": "meta/llama-3.1-70b-instruct-maas", - "provider": "vertex_ai-llama_models", - "input_cost_per_token": 0, - "output_cost_per_token": 0, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/meta/llama-3.1-8b-instruct-maas", - "name": "meta/llama-3.1-8b-instruct-maas", - "provider": "vertex_ai-llama_models", - "input_cost_per_token": 0, - "output_cost_per_token": 0, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/meta/llama-3.2-90b-vision-instruct-maas", - "name": "meta/llama-3.2-90b-vision-instruct-maas", - "provider": "vertex_ai-llama_models", - "input_cost_per_token": 0, - "output_cost_per_token": 0, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/meta/llama-4-maverick-17b-128e-instruct-maas", - "name": "meta/llama-4-maverick-17b-128e-instruct-maas", - "provider": "vertex_ai-llama_models", - "input_cost_per_token": 3.5e-7, - "output_cost_per_token": 0.00000115, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/meta/llama-4-maverick-17b-16e-instruct-maas", - "name": "meta/llama-4-maverick-17b-16e-instruct-maas", - "provider": "vertex_ai-llama_models", - "input_cost_per_token": 3.5e-7, - "output_cost_per_token": 0.00000115, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/meta/llama-4-scout-17b-128e-instruct-maas", - "name": "meta/llama-4-scout-17b-128e-instruct-maas", - "provider": "vertex_ai-llama_models", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 7e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/meta/llama-4-scout-17b-16e-instruct-maas", - "name": "meta/llama-4-scout-17b-16e-instruct-maas", - "provider": "vertex_ai-llama_models", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 7e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/meta/llama3-405b-instruct-maas", - "name": "meta/llama3-405b-instruct-maas", - "provider": "vertex_ai-llama_models", - "input_cost_per_token": 0, - "output_cost_per_token": 0, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/meta/llama3-70b-instruct-maas", - "name": "meta/llama3-70b-instruct-maas", - "provider": "vertex_ai-llama_models", - "input_cost_per_token": 0, - "output_cost_per_token": 0, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/meta/llama3-8b-instruct-maas", - "name": "meta/llama3-8b-instruct-maas", - "provider": "vertex_ai-llama_models", - "input_cost_per_token": 0, - "output_cost_per_token": 0, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/minimaxai/minimax-m2-maas", - "name": "minimaxai/minimax-m2-maas", - "provider": "vertex_ai-minimax_models", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/moonshotai/kimi-k2-thinking-maas", - "name": "moonshotai/kimi-k2-thinking-maas", - "provider": "vertex_ai-moonshot_models", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/zai-org/glm-4.7-maas", - "name": "zai-org/glm-4.7-maas", - "provider": "vertex_ai-zai_models", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.0000022, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/zai-org/glm-5-maas", - "name": "zai-org/glm-5-maas", - "provider": "vertex_ai-zai_models", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.0000032, - "cache_read_input_token_cost": 1e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/mistral-medium-3", - "name": "mistral-medium-3", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 4e-7, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/mistral-medium-3@001", - "name": "mistral-medium-3@001", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 4e-7, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/mistralai/mistral-medium-3", - "name": "mistralai/mistral-medium-3", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 4e-7, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/mistralai/mistral-medium-3@001", - "name": "mistralai/mistral-medium-3@001", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 4e-7, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/mistral-large-2411", - "name": "mistral-large-2411", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/mistral-large@2407", - "name": "mistral-large@2407", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/mistral-large@2411-001", - "name": "mistral-large@2411-001", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/mistral-large@latest", - "name": "mistral-large@latest", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/mistral-nemo@2407", - "name": "mistral-nemo@2407", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/mistral-nemo@latest", - "name": "mistral-nemo@latest", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 1.5e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/mistral-small-2503", - "name": "mistral-small-2503", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/mistral-small-2503@001", - "name": "mistral-small-2503@001", - "provider": "vertex_ai-mistral_models", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000003, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/google/gemma-4-26b-a4b-it-maas", - "name": "google/gemma-4-26b-a4b-it-maas", - "provider": "vertex_ai-openai_models", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/openai/gpt-oss-120b-maas", - "name": "openai/gpt-oss-120b-maas", - "provider": "vertex_ai-openai_models", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/openai/gpt-oss-20b-maas", - "name": "openai/gpt-oss-20b-maas", - "provider": "vertex_ai-openai_models", - "input_cost_per_token": 7.5e-8, - "output_cost_per_token": 3e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/xai/grok-4.1-fast-non-reasoning", - "name": "xai/grok-4.1-fast-non-reasoning", - "provider": "vertex_ai", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 5e-7, - "cache_read_input_token_cost": 5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/xai/grok-4.1-fast-reasoning", - "name": "xai/grok-4.1-fast-reasoning", - "provider": "vertex_ai", - "input_cost_per_token": 2e-7, - "output_cost_per_token": 5e-7, - "cache_read_input_token_cost": 5e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/xai/grok-4.20-non-reasoning", - "name": "xai/grok-4.20-non-reasoning", - "provider": "vertex_ai", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/xai/grok-4.20-reasoning", - "name": "xai/grok-4.20-reasoning", - "provider": "vertex_ai", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/qwen/qwen3-235b-a22b-instruct-2507-maas", - "name": "qwen/qwen3-235b-a22b-instruct-2507-maas", - "provider": "vertex_ai-qwen_models", - "input_cost_per_token": 2.5e-7, - "output_cost_per_token": 0.000001, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas", - "name": "qwen/qwen3-coder-480b-a35b-instruct-maas", - "provider": "vertex_ai-qwen_models", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000004, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas", - "name": "qwen/qwen3-next-80b-a3b-instruct-maas", - "provider": "vertex_ai-qwen_models", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas", - "name": "qwen/qwen3-next-80b-a3b-thinking-maas", - "provider": "vertex_ai-qwen_models", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 0.0000012, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "zai.glm-4.7", - "name": "zai.glm-4.7", - "provider": "bedrock_converse", - "input_cost_per_token": 6e-7, - "output_cost_per_token": 0.0000022, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "zai.glm-5", - "name": "zai.glm-5", - "provider": "bedrock_converse", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.0000032, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "zai.glm-4.7-flash", - "name": "zai.glm-4.7-flash", - "provider": "bedrock_converse", - "input_cost_per_token": 7e-8, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5-search-api", - "name": "gpt-5-search-api", - "provider": "openai", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gpt-5-search-api-2025-10-14", - "name": "gpt-5-search-api-2025-10-14", - "provider": "openai", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-2.0-flash-lite-001", - "name": "gemini-2.0-flash-lite-001", - "provider": "gemini", - "input_cost_per_token": 7.5e-8, - "output_cost_per_token": 3e-7, - "cache_read_input_token_cost": 1.875e-8, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-2.5-flash-native-audio-latest", - "name": "gemini-2.5-flash-native-audio-latest", - "provider": "gemini", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-2.5-flash-native-audio-preview-09-2025", - "name": "gemini-2.5-flash-native-audio-preview-09-2025", - "provider": "gemini", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-2.5-flash-native-audio-preview-12-2025", - "name": "gemini-2.5-flash-native-audio-preview-12-2025", - "provider": "gemini", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-3.1-flash-live-preview", - "name": "gemini-3.1-flash-live-preview", - "provider": "gemini", - "input_cost_per_token": 7.5e-7, - "output_cost_per_token": 0.0000045, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-2.5-flash-native-audio-latest", - "name": "gemini-2.5-flash-native-audio-latest", - "provider": "gemini", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-2.5-flash-native-audio-preview-09-2025", - "name": "gemini-2.5-flash-native-audio-preview-09-2025", - "provider": "gemini", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-2.5-flash-native-audio-preview-12-2025", - "name": "gemini-2.5-flash-native-audio-preview-12-2025", - "provider": "gemini", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-3.1-flash-live-preview", - "name": "gemini-3.1-flash-live-preview", - "provider": "gemini", - "input_cost_per_token": 7.5e-7, - "output_cost_per_token": 0.0000045, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-flash-latest", - "name": "gemini-flash-latest", - "provider": "gemini", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 3e-8, - "output_cost_per_reasoning_token": 0.0000025 - }, - { - "id": "gemini-flash-lite-latest", - "name": "gemini-flash-lite-latest", - "provider": "gemini", - "input_cost_per_token": 1e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": 1e-8, - "output_cost_per_reasoning_token": 4e-7 - }, - { - "id": "gemini-pro-latest", - "name": "gemini-pro-latest", - "provider": "gemini", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini/gemini-pro-latest", - "name": "gemini-pro-latest", - "provider": "gemini", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 1.25e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "gemini-exp-1206", - "name": "gemini-exp-1206", - "provider": "gemini", - "input_cost_per_token": 3e-7, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 3e-8, - "output_cost_per_reasoning_token": 0.0000025 - }, - { - "id": "vertex_ai/claude-sonnet-5@default", - "name": "claude-sonnet-5@default", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "vertex_ai/claude-sonnet-4-6@default", - "name": "claude-sonnet-4-6@default", - "provider": "vertex_ai-anthropic_models", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 3e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock_mantle/openai.gpt-oss-120b", - "name": "openai.gpt-oss-120b", - "provider": "bedrock_mantle", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock_mantle/openai.gpt-oss-20b", - "name": "openai.gpt-oss-20b", - "provider": "bedrock_mantle", - "input_cost_per_token": 7.5e-8, - "output_cost_per_token": 3e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock_mantle/openai.gpt-oss-safeguard-120b", - "name": "openai.gpt-oss-safeguard-120b", - "provider": "bedrock_mantle", - "input_cost_per_token": 1.5e-7, - "output_cost_per_token": 6e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock_mantle/openai.gpt-oss-safeguard-20b", - "name": "openai.gpt-oss-safeguard-20b", - "provider": "bedrock_mantle", - "input_cost_per_token": 7.5e-8, - "output_cost_per_token": 3e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock_mantle/google.gemma-4-31b", - "name": "google.gemma-4-31b", - "provider": "bedrock_mantle", - "input_cost_per_token": 1.4e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock_mantle/google.gemma-4-26b-a4b", - "name": "google.gemma-4-26b-a4b", - "provider": "bedrock_mantle", - "input_cost_per_token": 1.3e-7, - "output_cost_per_token": 4e-7, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock_mantle/google.gemma-4-e2b", - "name": "google.gemma-4-e2b", - "provider": "bedrock_mantle", - "input_cost_per_token": 4e-8, - "output_cost_per_token": 8e-8, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock_mantle/xai.grok-4.3", - "name": "xai.grok-4.3", - "provider": "bedrock_mantle", - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.0000025, - "cache_read_input_token_cost": 2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-east-1/zai.glm-5", - "name": "us-east-1/zai.glm-5", - "provider": "bedrock", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.0000032, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-west-2/zai.glm-5", - "name": "us-west-2/zai.glm-5", - "provider": "bedrock", - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.0000032, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0", - "name": "us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.0000012, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": 1.2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "bedrock/us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0", - "name": "us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0", - "provider": "bedrock", - "input_cost_per_token": 0.0000012, - "output_cost_per_token": 0.000006, - "cache_read_input_token_cost": 1.2e-7, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-3.5-turbo-instruct-0914", - "name": "gpt-3.5-turbo-instruct-0914", - "provider": "azure_text", - "input_cost_per_token": 0.0000015, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-35-turbo-instruct", - "name": "gpt-35-turbo-instruct", - "provider": "azure_text", - "input_cost_per_token": 0.0000015, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "azure/gpt-35-turbo-instruct-0914", - "name": "gpt-35-turbo-instruct-0914", - "provider": "azure_text", - "input_cost_per_token": 0.0000015, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "text-unicorn", - "name": "text-unicorn", - "provider": "vertex_ai-text-models", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.000028, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - }, - { - "id": "text-unicorn@001", - "name": "text-unicorn@001", - "provider": "vertex_ai-text-models", - "input_cost_per_token": 0.00001, - "output_cost_per_token": 0.000028, - "cache_read_input_token_cost": null, - "output_cost_per_reasoning_token": null - } - ], - "success": true, - "error": false, - "message": "Model catalog retrieved successfully", - "status": 200 -} \ No newline at end of file diff --git a/server/lib/aiModelCatalog.ts b/server/lib/aiModelCatalog.ts index 9986366fb..a6eab149f 100644 --- a/server/lib/aiModelCatalog.ts +++ b/server/lib/aiModelCatalog.ts @@ -47,27 +47,21 @@ export type AiModelCatalogEntry = { provider: CatalogProvider; model: string; pricing: { - input: number | null; - output: number | null; - cacheRead: number | null; - reasoningOutput: number | null; + in: number | null; + out: number | null; + cache: number | null; + reasoning: number | null; }; }; type RawCatalogEntry = { - id?: string; - name?: string; - model?: string; + model: string; provider: string; - input_cost_per_token?: number | null; - output_cost_per_token?: number | null; - cache_read_input_token_cost?: number | null; - output_cost_per_reasoning_token?: number | null; pricing?: { - input?: number | null; - output?: number | null; - cacheRead?: number | null; - reasoningOutput?: number | null; + in?: number | null; + out?: number | null; + cache?: number | null; + reasoning?: number | null; }; }; @@ -93,25 +87,18 @@ function normalizeEntry(raw: RawCatalogEntry): AiModelCatalogEntry | null { return null; } - const model = raw.model ?? raw.name ?? raw.id; - if (!model) { + if (!raw.model) { return null; } return { provider, - model, + model: raw.model, pricing: { - input: raw.pricing?.input ?? raw.input_cost_per_token ?? null, - output: raw.pricing?.output ?? raw.output_cost_per_token ?? null, - cacheRead: - raw.pricing?.cacheRead ?? - raw.cache_read_input_token_cost ?? - null, - reasoningOutput: - raw.pricing?.reasoningOutput ?? - raw.output_cost_per_reasoning_token ?? - null + in: raw.pricing?.in ?? null, + out: raw.pricing?.out ?? null, + cache: raw.pricing?.cache ?? null, + reasoning: raw.pricing?.reasoning ?? null } }; } diff --git a/server/lib/aiModelPricing.ts b/server/lib/aiModelPricing.ts index 569bfa42e..cb84d6d47 100644 --- a/server/lib/aiModelPricing.ts +++ b/server/lib/aiModelPricing.ts @@ -33,10 +33,10 @@ function toPricing( approximate: boolean ): AiModelPricing { return { - inputCostPerToken: entry.pricing.input, - outputCostPerToken: entry.pricing.output, - cacheReadInputTokenCost: entry.pricing.cacheRead, - outputCostPerReasoningToken: entry.pricing.reasoningOutput, + inputCostPerToken: entry.pricing.in, + outputCostPerToken: entry.pricing.out, + cacheReadInputTokenCost: entry.pricing.cache, + outputCostPerReasoningToken: entry.pricing.reasoning, approximate }; } From 49020fa6ea8601beb8e0a12a25c89b09ea7c002d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 12 Aug 2026 10:38:08 -0400 Subject: [PATCH 306/494] add virtual api key validation in verifySession --- server/auth/verifyVirtualApiKey.ts | 316 ++++++++++++++++++ server/lib/aiCapabilities.ts | 14 +- server/lib/aiGatewayTrust.ts | 38 +++ server/lib/aiProviderDefaults.ts | 132 ++------ server/lib/traefik/getTraefikConfig.ts | 10 +- server/lib/virtualApiKey.ts | 8 + .../private/lib/traefik/getTraefikConfig.ts | 50 +-- server/routers/aiGateway/pipeline.ts | 65 +++- server/routers/aiGateway/targetRouting.ts | 4 +- server/routers/badger/logRequestAudit.ts | 5 +- server/routers/badger/verifySession.ts | 142 ++++++-- .../[providerId]/authentication/page.tsx | 2 +- .../[providerId]/general/page.tsx | 2 +- .../[providerId]/network/page.tsx | 2 +- .../settings/ai-providers/create/page.tsx | 2 +- src/components/AiProviderAuthTypeSelect.tsx | 2 +- .../AiProviderCapabilitiesSelect.tsx | 2 +- src/components/AiProviderTypeSelect.tsx | 2 +- src/components/CreateVirtualApiKeyForm.tsx | 8 +- src/components/EditVirtualApiKeyForm.tsx | 6 +- src/components/UserVirtualApiKeys.tsx | 16 +- src/components/ViewVirtualApiKeySecret.tsx | 8 +- src/components/VirtualApiKeysTable.tsx | 10 +- .../LauncherInferenceApiKeysSection.tsx | 14 +- src/components/user-selector.tsx | 2 +- src/lib/aiCapabilities.ts | 12 + src/lib/aiProviderDefaults.ts | 107 ++++++ src/lib/aiProviderFormSchema.ts | 5 +- src/lib/virtualApiKeyFormat.ts | 63 ++++ 29 files changed, 852 insertions(+), 197 deletions(-) create mode 100644 server/auth/verifyVirtualApiKey.ts create mode 100644 server/lib/aiGatewayTrust.ts create mode 100644 src/lib/aiCapabilities.ts create mode 100644 src/lib/aiProviderDefaults.ts create mode 100644 src/lib/virtualApiKeyFormat.ts diff --git a/server/auth/verifyVirtualApiKey.ts b/server/auth/verifyVirtualApiKey.ts new file mode 100644 index 000000000..354a27e74 --- /dev/null +++ b/server/auth/verifyVirtualApiKey.ts @@ -0,0 +1,316 @@ +import { canUserAccessResource } from "@server/auth/canUserAccessResource"; +import { + db, + users, + virtualApiKeyResources, + virtualApiKeys, + type VirtualApiKey +} from "@server/db"; +import config from "@server/lib/config"; +import { + decryptVirtualApiKeyToken, + VIRTUAL_API_KEY_PREFIX, + looksLikeVirtualApiKeyCredential +} from "@server/lib/virtualApiKey"; +import { getUserOrgRoles } from "@server/lib/userOrgRoles"; +import { and, eq } from "drizzle-orm"; +import { isWithinExpirationDate } from "oslo"; + +export type VirtualApiKeyCredential = { + virtualApiKeyId: string; + secret: string; +}; + +export type VirtualApiKeyUserData = { + userId: string; + username: string; + email: string | null; + name: string | null; + role: string | null; +}; + +function getHeader( + headers: Record | undefined, + name: string +): string | undefined { + if (!headers) { + return undefined; + } + if (headers[name] !== undefined) { + return headers[name]; + } + const lower = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === lower) { + return value; + } + } + return undefined; +} + +function parseVkCredential( + raw: string | undefined +): VirtualApiKeyCredential | null { + if (!raw || !looksLikeVirtualApiKeyCredential(raw)) { + return null; + } + const withoutPrefix = raw.trim().slice(VIRTUAL_API_KEY_PREFIX.length); + const dot = withoutPrefix.indexOf("."); + return { + virtualApiKeyId: withoutPrefix.slice(0, dot), + secret: withoutPrefix.slice(dot + 1) + }; +} + +/** + * Extract a virtual API key credential from provider-style auth headers. + * Checks Authorization Bearer / Splunk, x-api-key, x-goog-api-key, and + * cf-aig-authorization. First matching vk-{id}.{secret} wins. + */ +export function extractVirtualApiKeyCredential( + headers: Record | undefined +): VirtualApiKeyCredential | null { + if (!headers) { + return null; + } + + const authorization = getHeader(headers, "authorization"); + if (authorization) { + const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i); + if (bearerMatch) { + const credential = parseVkCredential(bearerMatch[1]); + if (credential) { + return credential; + } + } + const splunkMatch = authorization.match(/^Splunk\s+(.+)$/i); + if (splunkMatch) { + const credential = parseVkCredential(splunkMatch[1]); + if (credential) { + return credential; + } + } + } + + const cfAig = getHeader(headers, "cf-aig-authorization"); + if (cfAig) { + const bearerMatch = cfAig.match(/^Bearer\s+(.+)$/i); + const credential = parseVkCredential( + bearerMatch ? bearerMatch[1] : cfAig + ); + if (credential) { + return credential; + } + } + + for (const name of ["x-api-key", "x-goog-api-key"] as const) { + const credential = parseVkCredential(getHeader(headers, name)); + if (credential) { + return credential; + } + } + + return null; +} + +async function buildUserData( + userId: string, + orgId: string +): Promise { + const [user] = await db + .select() + .from(users) + .where(eq(users.userId, userId)) + .limit(1); + + if (!user) { + return undefined; + } + + if ( + config.getRawConfig().flags?.require_email_verification && + !user.emailVerified + ) { + return undefined; + } + + const userOrgRoles = await getUserOrgRoles(user.userId, orgId); + if (userOrgRoles.length === 0) { + return undefined; + } + + return { + userId: user.userId, + username: user.username, + email: user.email, + name: user.name, + role: userOrgRoles.map((r) => r.roleName).join(", ") || null + }; +} + +async function userHasResourceAccess( + userId: string, + resourceId: number, + orgId: string +): Promise<{ allowed: boolean; userData?: VirtualApiKeyUserData }> { + const [user] = await db + .select() + .from(users) + .where(eq(users.userId, userId)) + .limit(1); + + if (!user) { + return { allowed: false }; + } + + if ( + config.getRawConfig().flags?.require_email_verification && + !user.emailVerified + ) { + return { allowed: false }; + } + + const userOrgRoles = await getUserOrgRoles(user.userId, orgId); + if (userOrgRoles.length === 0) { + return { allowed: false }; + } + + const allowed = await canUserAccessResource({ + userId, + resourceId, + roleIds: userOrgRoles.map((r) => r.roleId) + }); + + if (!allowed) { + return { allowed: false }; + } + + return { + allowed: true, + userData: { + userId: user.userId, + username: user.username, + email: user.email, + name: user.name, + role: userOrgRoles.map((r) => r.roleName).join(", ") || null + } + }; +} + +async function manualKeyHasResourceAccess( + key: VirtualApiKey, + resourceId: number +): Promise { + if (key.allResources) { + return true; + } + + const [row] = await db + .select({ resourceId: virtualApiKeyResources.resourceId }) + .from(virtualApiKeyResources) + .where( + and( + eq(virtualApiKeyResources.virtualApiKeyId, key.virtualApiKeyId), + eq(virtualApiKeyResources.resourceId, resourceId) + ) + ) + .limit(1); + + return Boolean(row); +} + +async function touchLastUsedAt(virtualApiKeyId: string): Promise { + try { + await db + .update(virtualApiKeys) + .set({ lastUsedAt: Date.now() }) + .where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId)); + } catch { + // Best-effort; do not fail auth on audit timestamp updates. + } +} + +export async function verifyVirtualApiKey({ + credential, + resourceId, + orgId +}: { + credential: VirtualApiKeyCredential; + resourceId: number; + orgId: string; +}): Promise<{ + valid: boolean; + error?: string; + key?: VirtualApiKey; + userData?: VirtualApiKeyUserData; +}> { + const [key] = await db + .select() + .from(virtualApiKeys) + .where(eq(virtualApiKeys.virtualApiKeyId, credential.virtualApiKeyId)) + .limit(1); + + if (!key) { + return { valid: false, error: "Virtual API key not found" }; + } + + if (key.orgId !== orgId) { + return { valid: false, error: "Virtual API key org mismatch" }; + } + + let plaintext: string; + try { + plaintext = decryptVirtualApiKeyToken(key.token); + } catch { + return { valid: false, error: "Virtual API key secret is invalid" }; + } + + if (plaintext !== credential.secret) { + return { valid: false, error: "Invalid virtual API key secret" }; + } + + if (key.expiresAt && !isWithinExpirationDate(new Date(key.expiresAt))) { + return { valid: false, error: "Virtual API key has expired" }; + } + + if (key.kind === "manual") { + const scoped = await manualKeyHasResourceAccess(key, resourceId); + if (!scoped) { + return { + valid: false, + error: "Virtual API key is not scoped to this resource" + }; + } + + let userData: VirtualApiKeyUserData | undefined; + if (key.userId) { + userData = await buildUserData(key.userId, orgId); + } + + await touchLastUsedAt(key.virtualApiKeyId); + return { valid: true, key, userData }; + } + + if (key.kind === "user") { + if (!key.userId) { + return { valid: false, error: "User virtual API key has no user" }; + } + + const access = await userHasResourceAccess( + key.userId, + resourceId, + orgId + ); + if (!access.allowed || !access.userData) { + return { + valid: false, + error: "User is not allowed to access this resource" + }; + } + + await touchLastUsedAt(key.virtualApiKeyId); + return { valid: true, key, userData: access.userData }; + } + + return { valid: false, error: "Unknown virtual API key kind" }; +} diff --git a/server/lib/aiCapabilities.ts b/server/lib/aiCapabilities.ts index 89fbd3d75..268acd36e 100644 --- a/server/lib/aiCapabilities.ts +++ b/server/lib/aiCapabilities.ts @@ -1,17 +1,7 @@ import type { Request } from "express"; +import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities"; -export const AI_CAPABILITIES = [ - "openai_chat", - "openai_responses", - "anthropic_messages", - "gemini_generate_content", - "bedrock_model_invoke", - "google_generate_content", - "google_raw_predict", - "bedrock_converse" -] as const; - -export type AiCapability = (typeof AI_CAPABILITIES)[number]; +export { AI_CAPABILITIES, type AiCapability }; export type AiCapabilityRoute = { method: "POST"; diff --git a/server/lib/aiGatewayTrust.ts b/server/lib/aiGatewayTrust.ts new file mode 100644 index 000000000..2eb3c104d --- /dev/null +++ b/server/lib/aiGatewayTrust.ts @@ -0,0 +1,38 @@ +import { createHash } from "crypto"; +import config from "@server/lib/config"; + +export const AI_GATEWAY_TRUST_HEADER = "X-Pangolin-Ai-Gateway-Auth"; + +/** + * Derive a Traefik-injected trust token from the server secret. + * Traefik overwrites this header on inference routes so the AI gateway can + * trust Badger-injected Remote-* identity without re-validating credentials. + */ +export function deriveAiGatewayTrustToken(secret: string): string { + return createHash("sha256") + .update(`ai-gateway-trust:${secret}`) + .digest("hex"); +} + +export function getAiGatewayTrustToken(): string { + const secret = config.getRawConfig().server.secret; + if (!secret) { + throw new Error("Server secret is required for AI gateway trust token"); + } + return deriveAiGatewayTrustToken(secret); +} + +export function isAiGatewayTrustHeaderValid( + headers: Record | undefined, + expectedToken?: string +): boolean { + if (!headers) { + return false; + } + const expected = expectedToken ?? getAiGatewayTrustToken(); + const raw = + headers[AI_GATEWAY_TRUST_HEADER] ?? + headers[AI_GATEWAY_TRUST_HEADER.toLowerCase()]; + const value = Array.isArray(raw) ? raw[0] : raw; + return typeof value === "string" && value === expected; +} diff --git a/server/lib/aiProviderDefaults.ts b/server/lib/aiProviderDefaults.ts index a8b3a0ca0..217709f41 100644 --- a/server/lib/aiProviderDefaults.ts +++ b/server/lib/aiProviderDefaults.ts @@ -3,82 +3,29 @@ import { parseCapabilities, type AiCapability } from "@server/lib/aiCapabilities"; +import { stripVirtualApiKeyAuthHeaders } from "@app/lib/virtualApiKeyFormat"; +import { + AI_PROVIDER_AUTH_TYPES, + AI_PROVIDER_DEFAULTS, + authTypeRequiresApiKey, + defaultsForProviderType, + providerRequiresUpstreamUrl, + type AiBudgetUnit, + type AiProviderAuthType, + type AiProviderRoutingMode, + type AiProviderType +} from "@app/lib/aiProviderDefaults"; -export type AiProviderType = - | "openai" - | "anthropic" - | "googleGemini" - | "vertexAi" - | "bedrock" - | "microsoftFoundry" - | "openRouter" - | "vercelAiGateway" - | "custom"; - -export const AI_PROVIDER_AUTH_TYPES = [ - "bearer", - "x-api-key", - "x-goog-api-key", - "hec", - "cf-aig-authorization", - "none", - "passthrough" -] as const; - -export type AiProviderAuthType = (typeof AI_PROVIDER_AUTH_TYPES)[number]; -export type AiBudgetUnit = "usd" | "tokens"; -export type AiProviderRoutingMode = "url" | "target"; - -type AiProviderDefaults = { - upstreamUrl: string | null; - authType: AiProviderAuthType; - capabilities: readonly AiCapability[]; -}; - -export const AI_PROVIDER_DEFAULTS: Record< - Exclude, - AiProviderDefaults -> = { - openai: { - upstreamUrl: "https://api.openai.com/v1", - authType: "bearer", - capabilities: ["openai_chat", "openai_responses"] - }, - anthropic: { - upstreamUrl: "https://api.anthropic.com", - authType: "x-api-key", - capabilities: ["anthropic_messages"] - }, - googleGemini: { - upstreamUrl: "https://generativelanguage.googleapis.com", - authType: "x-goog-api-key", - capabilities: ["gemini_generate_content"] - }, - vertexAi: { - upstreamUrl: null, - authType: "bearer", - capabilities: ["google_generate_content", "google_raw_predict"] - }, - bedrock: { - upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - authType: "bearer", - capabilities: ["bedrock_converse"] - }, - microsoftFoundry: { - upstreamUrl: null, - authType: "bearer", - capabilities: ["openai_chat", "openai_responses", "anthropic_messages"] - }, - openRouter: { - upstreamUrl: "https://openrouter.ai/api/v1", - authType: "bearer", - capabilities: ["openai_chat"] - }, - vercelAiGateway: { - upstreamUrl: "https://ai-gateway.vercel.sh/v1", - authType: "bearer", - capabilities: ["openai_chat", "openai_responses"] - } +export { + AI_PROVIDER_AUTH_TYPES, + AI_PROVIDER_DEFAULTS, + authTypeRequiresApiKey, + defaultsForProviderType, + providerRequiresUpstreamUrl, + type AiBudgetUnit, + type AiProviderAuthType, + type AiProviderRoutingMode, + type AiProviderType }; const CONFLICTING_AUTH_HEADERS = [ @@ -88,23 +35,6 @@ const CONFLICTING_AUTH_HEADERS = [ "cf-aig-authorization" ] as const; -export function authTypeRequiresApiKey(authType: AiProviderAuthType): boolean { - return authType !== "none" && authType !== "passthrough"; -} - -export function providerRequiresUpstreamUrl( - type: AiProviderType, - routingMode: AiProviderRoutingMode = "url" -): boolean { - if (routingMode === "target") { - return false; - } - if (type === "custom") { - return true; - } - return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null; -} - export function resolveAiProviderCreateFields(input: { type: AiProviderType; upstreamUrl?: string | null; @@ -191,15 +121,18 @@ export function applyAiProviderCustomHeaders( /** * Apply provider auth to upstream headers. - * - Injected modes: strip client auth headers, then set the provider key. - * - none: strip client auth headers, send no auth. - * - passthrough: leave client auth headers as-is. + * - Always strips Pangolin virtual API key credentials from client auth headers. + * - Injected modes: strip conflicting client auth headers, then set the provider key. + * - none: strip conflicting client auth headers, send no auth. + * - passthrough: leave remaining client auth headers as-is (after VAK strip). */ export function applyAiProviderAuthHeaders( headers: Record, authType: AiProviderAuthType, apiKey: string | null ): void { + stripVirtualApiKeyAuthHeaders(headers); + if (authType === "passthrough") { return; } @@ -251,12 +184,3 @@ export function resolveCapabilitiesForCreate(input: { } return [...AI_PROVIDER_DEFAULTS[input.type].capabilities]; } - -export function defaultsForProviderType( - type: AiProviderType -): readonly AiCapability[] { - if (type === "custom") { - return []; - } - return AI_PROVIDER_DEFAULTS[type].capabilities; -} diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index a6535c766..559f1f7ad 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -24,6 +24,10 @@ import { resources, sites, Target, targets } from "@server/db"; import createPathRewriteMiddleware from "./middleware"; import { sanitize, encodePath, validatePathRewriteConfig } from "./utils"; import regionalCache from "@server/lib/cache"; +import { + AI_GATEWAY_TRUST_HEADER, + getAiGatewayTrustToken +} from "@server/lib/aiGatewayTrust"; const redirectHttpsMiddlewareName = "redirect-to-https"; const badgerMiddlewareName = "badger"; @@ -808,7 +812,8 @@ export async function getTraefikConfig( headers: { customRequestHeaders: { ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": fullDomain + "p-host": fullDomain, + [AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken() } } }; @@ -911,7 +916,8 @@ export async function getTraefikConfig( headers: { customRequestHeaders: { ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": fullDomain + "p-host": fullDomain, + [AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken() } } }; diff --git a/server/lib/virtualApiKey.ts b/server/lib/virtualApiKey.ts index 66ebb4b5a..e4f7480db 100644 --- a/server/lib/virtualApiKey.ts +++ b/server/lib/virtualApiKey.ts @@ -14,6 +14,14 @@ import config from "@server/lib/config"; import { decrypt, encrypt } from "@server/lib/crypto"; import { and, eq, inArray } from "drizzle-orm"; +export { + VIRTUAL_API_KEY_PREFIX, + formatVirtualApiKeyCredential, + formatVirtualApiKeyPreview, + looksLikeVirtualApiKeyCredential, + stripVirtualApiKeyAuthHeaders +} from "@app/lib/virtualApiKeyFormat"; + export type MintedVirtualApiKeySecret = { virtualApiKeyId: string; secret: string; diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index f51dad24e..dc7f0fef0 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -59,6 +59,10 @@ import { } from "#private/lib/certificates"; import { build } from "@server/build"; import regionalCache from "#private/lib/cache"; +import { + AI_GATEWAY_TRUST_HEADER, + getAiGatewayTrustToken +} from "@server/lib/aiGatewayTrust"; const redirectHttpsMiddlewareName = "redirect-to-https"; const redirectToRootMiddlewareName = "redirect-to-root"; @@ -1634,18 +1638,19 @@ export async function getTraefikConfig( config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares = [badgerMiddlewareName]; - if (aiGatewayOverride) { - const irHeadersMiddlewareName = `${irKey}-headers-middleware`; - config_output.http.middlewares[irHeadersMiddlewareName] = { - headers: { - customRequestHeaders: { - ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": fullDomain - } + const irHeadersMiddlewareName = `${irKey}-headers-middleware`; + config_output.http.middlewares[irHeadersMiddlewareName] = { + headers: { + customRequestHeaders: { + ...(aiGatewayOverride && aiGatewayHost + ? { Host: aiGatewayHost } + : {}), + ...(aiGatewayOverride ? { "p-host": fullDomain } : {}), + [AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken() } - }; - routerMiddlewares.push(irHeadersMiddlewareName); - } + } + }; + routerMiddlewares.push(irHeadersMiddlewareName); routerMiddlewares.push(...additionalMiddlewares); @@ -1733,18 +1738,19 @@ export async function getTraefikConfig( config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares: string[] = []; - if (aiGatewayOverride) { - const srHeadersMiddlewareName = `${srKey}-headers-middleware`; - config_output.http.middlewares[srHeadersMiddlewareName] = { - headers: { - customRequestHeaders: { - ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": fullDomain - } + const srHeadersMiddlewareName = `${srKey}-headers-middleware`; + config_output.http.middlewares[srHeadersMiddlewareName] = { + headers: { + customRequestHeaders: { + ...(aiGatewayOverride && aiGatewayHost + ? { Host: aiGatewayHost } + : {}), + ...(aiGatewayOverride ? { "p-host": fullDomain } : {}), + [AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken() } - }; - routerMiddlewares.push(srHeadersMiddlewareName); - } + } + }; + routerMiddlewares.push(srHeadersMiddlewareName); routerMiddlewares.push(...additionalMiddlewares); diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 7648e2355..5aa69d7fc 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -39,6 +39,10 @@ import { import { getUserOrgRoles } from "@server/lib/userOrgRoles"; import { isIpInCidr } from "@server/lib/ip"; import { localCache } from "@server/lib/cache"; +import { + AI_GATEWAY_TRUST_HEADER, + isAiGatewayTrustHeaderValid +} from "@server/lib/aiGatewayTrust"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { @@ -217,9 +221,42 @@ async function buildRequestUser( async function resolveRequestUser( req: Request, - _resourceId: number | null, + resourceId: number | null, orgId: string | null ): Promise { + // Public inference: identity comes from Badger via Remote-* only when the + // Traefik trust header proves the request passed verify-session (VAK). + if (isAiGatewayTrustHeaderValid(req.headers as Record)) { + const userId = getRequestHeader(req, "remote-user-id"); + if (userId) { + const username = getRequestHeader(req, "remote-user") || userId; + const email = getRequestHeader(req, "remote-email"); + const name = getRequestHeader(req, "remote-name"); + const role = getRequestHeader(req, "remote-role"); + const orgRoles = orgId ? await getUserOrgRoles(userId, orgId) : []; + + return { + userId, + username, + email: email || null, + name: name || null, + role: + role || orgRoles.map((r) => r.roleName).join(", ") || null, + roleIds: orgRoles.map((r) => r.roleId) + }; + } + + // Trusted request with no associated user (manual key without userId). + if (resourceId != null) { + return null; + } + } + + // Public inference must come through Badger; do not authorize via app session. + if (resourceId != null) { + return null; + } + const sessionToken = req.cookies?.[SESSION_COOKIE_NAME]; if (sessionToken) { const { session, user } = await validateSessionToken(sessionToken); @@ -251,6 +288,14 @@ async function resolveRequestUser( return buildRequestUser(client.userId, orgId); } +function getRequestHeader(req: Request, name: string): string | undefined { + const raw = req.headers[name.toLowerCase()]; + if (Array.isArray(raw)) { + return raw[0]; + } + return raw; +} + async function resolveTarget(host: string): Promise { const [[resourceRow], [siteResourceRow]] = await Promise.all([ db @@ -696,6 +741,21 @@ export async function handleAiGatewayProxy( orgId } = target; + // Public inference must pass Badger verify-session first. Traefik + // injects the trust header only on that path; the gateway trusts it + // and does not re-verify the virtual API key. + if ( + resourceId != null && + !isAiGatewayTrustHeaderValid(req.headers as Record) + ) { + return res.status(HttpCode.UNAUTHORIZED).json({ + error: { + message: + "Request must be authenticated via the inference resource" + } + }); + } + const capableAttachments = attachments.filter((a) => providerHasCapability(a.provider.capabilities, capability) ); @@ -823,7 +883,8 @@ export async function handleAiGatewayProxy( "transfer-encoding", "upgrade", "content-length", - "accept-encoding" + "accept-encoding", + AI_GATEWAY_TRUST_HEADER.toLowerCase() ]); const headers: Record = {}; diff --git a/server/routers/aiGateway/targetRouting.ts b/server/routers/aiGateway/targetRouting.ts index 00ef39d12..9ffbf1d1b 100644 --- a/server/routers/aiGateway/targetRouting.ts +++ b/server/routers/aiGateway/targetRouting.ts @@ -33,6 +33,7 @@ import { type RequestUser } from "@server/routers/aiGateway/pipeline"; import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse"; +import { AI_GATEWAY_TRUST_HEADER } from "@server/lib/aiGatewayTrust"; // Short TTL: long enough to spare the DB on a burst of requests, short // enough that target/site changes (added, removed, exit node moved) show up @@ -62,7 +63,8 @@ const SKIP_HEADERS = new Set([ "transfer-encoding", "upgrade", "content-length", - "accept-encoding" + "accept-encoding", + AI_GATEWAY_TRUST_HEADER.toLowerCase() ]); type ResolvedProviderTarget = { diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 884fb7ae4..3fb97dce5 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -19,6 +19,7 @@ Reasons: 106 - Valid email 107 - Valid SSO 108 - Connected Client +109 - Valid Virtual API Key 201 - Resource Not Found 202 - Resource Blocked @@ -90,7 +91,9 @@ async function flushAuditLogs() { auditLogBuffer.unshift(...logsToWrite); logger.info(`Re-queued ${logsToWrite.length} audit logs for retry`); } else { - logger.error(`Buffer full, dropped ${logsToWrite.length} audit logs`); + logger.error( + `Buffer full, dropped ${logsToWrite.length} audit logs` + ); } } finally { isFlushInProgress = false; diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index b0aed6e30..ee69ce163 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -5,6 +5,10 @@ import { } from "@server/auth/sessions/resource"; import { generateSessionToken } from "@server/auth/sessions/app"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; +import { + extractVirtualApiKeyCredential, + verifyVirtualApiKey +} from "@server/auth/verifyVirtualApiKey"; import { getResourceByDomain, getResourceRules, @@ -127,7 +131,8 @@ export async function verifyResourceSession( // Extract HTTP Basic Auth credentials if present const clientHeaderAuth = extractBasicAuth(headers); - const clientUserAgent = headers?.["user-agent"] || headers?.["User-Agent"]; + const clientUserAgent = + headers?.["user-agent"] || headers?.["User-Agent"]; const clientIsBrowser = isBrowserUserAgent(clientUserAgent); const clientIp = requestIp @@ -254,20 +259,28 @@ export async function verifyResourceSession( ); if (action == "ACCEPT") { - logger.debug("Resource allowed by rule"); + // Public inference still requires a virtual API key; do not + // bypass that with an allow rule. + if (mode === "inference") { + logger.debug( + "Rule ACCEPT ignored for inference; continuing to virtual API key check" + ); + } else { + logger.debug("Resource allowed by rule"); - logRequestAudit( - { - action: true, - reason: 100, // allowed by rule - resourceId: resource.resourceId, - orgId: resource.orgId, - location: ipCC - }, - parsedBody.data - ); + logRequestAudit( + { + action: true, + reason: 100, // allowed by rule + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); - return allowed(res, undefined, dontStripSession); + return allowed(res, undefined, dontStripSession); + } } else if (action == "DROP") { logger.debug("Resource denied by rule"); @@ -302,20 +315,23 @@ export async function verifyResourceSession( !emailWhitelistEnabled && !headerAuth ) { - logger.debug("Resource allowed because no auth"); + // Public inference always requires a virtual API key. + if (mode !== "inference") { + logger.debug("Resource allowed because no auth"); - logRequestAudit( - { - action: true, - reason: 101, // allowed no auth - resourceId: resource.resourceId, - orgId: resource.orgId, - location: ipCC - }, - parsedBody.data - ); + logRequestAudit( + { + action: true, + reason: 101, // allowed no auth + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); - return allowed(res, undefined, dontStripSession); + return allowed(res, undefined, dontStripSession); + } } // Only offer a browser redirect to clients that can actually follow one and log in @@ -327,6 +343,82 @@ export async function verifyResourceSession( )}?redirect=${encodeURIComponent(originalRequestURL)}` : undefined; + // Virtual API keys for public inference resources (provider-style auth headers). + // Session/SSO may authenticate users elsewhere (e.g. dashboard key pages), but + // only a valid virtual API key is allowed through to the AI gateway. + if (mode === "inference") { + const vakCredential = extractVirtualApiKeyCredential(headers); + if (vakCredential) { + const { + valid, + error, + key, + userData: vakUserData + } = await verifyVirtualApiKey({ + credential: vakCredential, + resourceId: resource.resourceId, + orgId: resource.orgId + }); + + if (error) { + logger.debug("Virtual API key invalid: " + error); + } + + if (!valid) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Virtual API key is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + ); + } + } + + if (valid && key) { + logRequestAudit( + { + action: true, + reason: 109, // valid virtual API key + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + ...(vakUserData + ? { + user: { + username: vakUserData.username, + userId: vakUserData.userId + } + } + : { + apiKey: { + name: key.name, + apiKeyId: key.virtualApiKeyId + } + }), + metadata: { + virtualApiKeyId: key.virtualApiKeyId, + virtualApiKeyKind: key.kind + } + }, + parsedBody.data + ); + + return allowed(res, vakUserData, dontStripSession); + } + } + + logRequestAudit( + { + action: false, + reason: 299, // no more auth methods / VAK required + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + + return notAllowed(res, redirectPath); + } + // check for access token in headers if ( headers && diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx index f9e44cb09..74f5552d0 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx @@ -38,7 +38,7 @@ import { authTypeRequiresApiKey, type AiProviderAuthType, type AiProviderType -} from "@server/lib/aiProviderDefaults"; +} from "@app/lib/aiProviderDefaults"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx index 6dabdbaad..9e41ff9c3 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx @@ -30,7 +30,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { zodResolver } from "@hookform/resolvers/zod"; -import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities"; +import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx index 1aa25e9e5..e685bc560 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx @@ -49,7 +49,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import type { AiProviderAuthType, AiProviderType -} from "@server/lib/aiProviderDefaults"; +} from "@app/lib/aiProviderDefaults"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import { useQuery } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 999782bc3..f5e407cc1 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -50,7 +50,7 @@ import { type AiProviderFormValues } from "@app/lib/aiProviderFormSchema"; import { zodResolver } from "@hookform/resolvers/zod"; -import { authTypeRequiresApiKey } from "@server/lib/aiProviderDefaults"; +import { authTypeRequiresApiKey } from "@app/lib/aiProviderDefaults"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; diff --git a/src/components/AiProviderAuthTypeSelect.tsx b/src/components/AiProviderAuthTypeSelect.tsx index a040c6fa1..460ec6a47 100644 --- a/src/components/AiProviderAuthTypeSelect.tsx +++ b/src/components/AiProviderAuthTypeSelect.tsx @@ -18,7 +18,7 @@ import { cn } from "@app/lib/cn"; import { AI_PROVIDER_AUTH_TYPES, type AiProviderAuthType -} from "@server/lib/aiProviderDefaults"; +} from "@app/lib/aiProviderDefaults"; import { CheckIcon, ChevronsUpDown } from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; diff --git a/src/components/AiProviderCapabilitiesSelect.tsx b/src/components/AiProviderCapabilitiesSelect.tsx index 086b6f03e..ecb636472 100644 --- a/src/components/AiProviderCapabilitiesSelect.tsx +++ b/src/components/AiProviderCapabilitiesSelect.tsx @@ -1,7 +1,7 @@ "use client"; import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input"; -import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities"; +import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; diff --git a/src/components/AiProviderTypeSelect.tsx b/src/components/AiProviderTypeSelect.tsx index ed0565f53..b817d4169 100644 --- a/src/components/AiProviderTypeSelect.tsx +++ b/src/components/AiProviderTypeSelect.tsx @@ -16,7 +16,7 @@ import { } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; import { aiProviderTypeValues } from "@app/lib/aiProviderFormSchema"; -import type { AiProviderType } from "@server/lib/aiProviderDefaults"; +import type { AiProviderType } from "@app/lib/aiProviderDefaults"; import { CheckIcon, ChevronsUpDown } from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; diff --git a/src/components/CreateVirtualApiKeyForm.tsx b/src/components/CreateVirtualApiKeyForm.tsx index 5a3e88ce8..e67488b5d 100644 --- a/src/components/CreateVirtualApiKeyForm.tsx +++ b/src/components/CreateVirtualApiKeyForm.tsx @@ -42,6 +42,7 @@ import { Checkbox } from "@app/components/ui/checkbox"; import { useTranslations } from "next-intl"; import { UserSelector, type SelectedUser } from "@app/components/user-selector"; import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat"; import { MultiResourcesSelector, formatMultiResourcesSelectorLabel @@ -146,7 +147,12 @@ export default function CreateVirtualApiKeyForm({ if (res?.data.data.virtualApiKey) { const key = res.data.data.virtualApiKey; if (key.secret) { - setCredential(`vk-${key.virtualApiKeyId}.${key.secret}`); + setCredential( + formatVirtualApiKeyCredential( + key.virtualApiKeyId, + key.secret + ) + ); } const resourceLookup = new Map( diff --git a/src/components/EditVirtualApiKeyForm.tsx b/src/components/EditVirtualApiKeyForm.tsx index 533de947c..0ef5e936b 100644 --- a/src/components/EditVirtualApiKeyForm.tsx +++ b/src/components/EditVirtualApiKeyForm.tsx @@ -40,6 +40,7 @@ import { Checkbox } from "@app/components/ui/checkbox"; import { useTranslations } from "next-intl"; import { UserSelector, type SelectedUser } from "@app/components/user-selector"; import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat"; import { MultiResourcesSelector, formatMultiResourcesSelectorLabel @@ -151,7 +152,10 @@ export default function EditVirtualApiKeyForm({ const secret = res.data.data.virtualApiKey.secret; if (secret) { setCredential( - `vk-${virtualApiKey.virtualApiKeyId}.${secret}` + formatVirtualApiKeyCredential( + virtualApiKey.virtualApiKeyId, + secret + ) ); } else { toast({ diff --git a/src/components/UserVirtualApiKeys.tsx b/src/components/UserVirtualApiKeys.tsx index 5513b59e4..d90241d0b 100644 --- a/src/components/UserVirtualApiKeys.tsx +++ b/src/components/UserVirtualApiKeys.tsx @@ -27,6 +27,10 @@ import type { ListMyVirtualApiKeysResponse, VirtualApiKeyWithResources } from "@server/routers/virtualApiKey/types"; +import { + formatVirtualApiKeyCredential, + formatVirtualApiKeyPreview +} from "@app/lib/virtualApiKeyFormat"; type UserVirtualApiKeysProps = { orgId: string; @@ -34,10 +38,6 @@ type UserVirtualApiKeysProps = { initialData: ListMyVirtualApiKeysResponse; }; -function keyPreview(virtualApiKeyId: string, lastChars: string): string { - return `vk-${virtualApiKeyId}••••${lastChars}`; -} - function useRevealSecret(orgId: string, virtualApiKeyId: string) { const t = useTranslations(); const api = createApiClient(useEnvContext()); @@ -56,7 +56,9 @@ function useRevealSecret(orgId: string, virtualApiKeyId: string) { .then((res) => { const secret = res.data.data.virtualApiKey.secret; if (secret) { - setCredential(`vk-${virtualApiKeyId}.${secret}`); + setCredential( + formatVirtualApiKeyCredential(virtualApiKeyId, secret) + ); } else { toast({ variant: "destructive", @@ -95,7 +97,7 @@ function OwnedKeySecret({ lastChars: string; }) { const t = useTranslations(); - const preview = keyPreview(virtualApiKeyId, lastChars); + const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars); const { credential, loading, revealSecret } = useRevealSecret( orgId, virtualApiKeyId @@ -137,7 +139,7 @@ function IdentityKeyCenterpiece({ resourceGuid?: string; }) { const t = useTranslations(); - const preview = keyPreview(virtualApiKeyId, lastChars); + const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars); const { credential, loading, revealSecret } = useRevealSecret( orgId, virtualApiKeyId diff --git a/src/components/ViewVirtualApiKeySecret.tsx b/src/components/ViewVirtualApiKeySecret.tsx index 800fb3484..3e155053b 100644 --- a/src/components/ViewVirtualApiKeySecret.tsx +++ b/src/components/ViewVirtualApiKeySecret.tsx @@ -19,6 +19,7 @@ import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat"; type ViewVirtualApiKeySecretProps = { open: boolean; @@ -56,7 +57,12 @@ export default function ViewVirtualApiKeySecret({ } const key = res.data.data.virtualApiKey; if (key.secret) { - setCredential(`vk-${key.virtualApiKeyId}.${key.secret}`); + setCredential( + formatVirtualApiKeyCredential( + key.virtualApiKeyId, + key.secret + ) + ); } else { toast({ variant: "destructive", diff --git a/src/components/VirtualApiKeysTable.tsx b/src/components/VirtualApiKeysTable.tsx index 074d82f74..1f5f8e4e8 100644 --- a/src/components/VirtualApiKeysTable.tsx +++ b/src/components/VirtualApiKeysTable.tsx @@ -46,6 +46,10 @@ import { import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { + formatVirtualApiKeyCredential, + formatVirtualApiKeyPreview +} from "@app/lib/virtualApiKeyFormat"; import { AxiosResponse } from "axios"; export type VirtualApiKeyRow = CreatedVirtualApiKey; @@ -507,7 +511,7 @@ function VirtualApiKeySecretCell({ }) { const t = useTranslations(); const api = createApiClient(useEnvContext()); - const preview = `vk-${virtualApiKeyId}••••${lastChars}`; + const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars); const [credential, setCredential] = useState(null); useEffect(() => { @@ -522,7 +526,9 @@ function VirtualApiKeySecretCell({ } const secret = res.data.data.virtualApiKey.secret; if (secret) { - setCredential(`vk-${virtualApiKeyId}.${secret}`); + setCredential( + formatVirtualApiKeyCredential(virtualApiKeyId, secret) + ); } }) .catch((e) => { diff --git a/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx b/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx index 8a71a07db..2434ed575 100644 --- a/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx +++ b/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx @@ -20,6 +20,10 @@ import type { GetMyVirtualApiKeyResponse, VirtualApiKeyWithResources } from "@server/routers/virtualApiKey/types"; +import { + formatVirtualApiKeyCredential, + formatVirtualApiKeyPreview +} from "@app/lib/virtualApiKeyFormat"; import { useQuery } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; import { Loader2 } from "lucide-react"; @@ -31,10 +35,6 @@ type LauncherInferenceApiKeysSectionProps = { 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()); @@ -53,7 +53,9 @@ function useRevealSecret(orgId: string, virtualApiKeyId: string) { .then((res) => { const secret = res.data.data.virtualApiKey.secret; if (secret) { - setCredential(`vk-${virtualApiKeyId}.${secret}`); + setCredential( + formatVirtualApiKeyCredential(virtualApiKeyId, secret) + ); } else { toast({ variant: "destructive", @@ -92,7 +94,7 @@ function PanelKeySecret({ lastChars: string; }) { const t = useTranslations(); - const preview = keyPreview(virtualApiKeyId, lastChars); + const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars); const { credential, loading, revealSecret } = useRevealSecret( orgId, virtualApiKeyId diff --git a/src/components/user-selector.tsx b/src/components/user-selector.tsx index 58008f465..74258a165 100644 --- a/src/components/user-selector.tsx +++ b/src/components/user-selector.tsx @@ -46,7 +46,7 @@ export function UserSelector({ const [debouncedValue] = useDebounce(userSearchQuery, 150); const { data: users = [] } = useQuery( - orgQueries.users({ orgId, perPage: 10, term: debouncedValue }) + orgQueries.users({ orgId, perPage: 10, query: debouncedValue }) ); const usersShown = useMemo(() => { diff --git a/src/lib/aiCapabilities.ts b/src/lib/aiCapabilities.ts new file mode 100644 index 000000000..faa1f7af6 --- /dev/null +++ b/src/lib/aiCapabilities.ts @@ -0,0 +1,12 @@ +export const AI_CAPABILITIES = [ + "openai_chat", + "openai_responses", + "anthropic_messages", + "gemini_generate_content", + "bedrock_model_invoke", + "google_generate_content", + "google_raw_predict", + "bedrock_converse" +] as const; + +export type AiCapability = (typeof AI_CAPABILITIES)[number]; diff --git a/src/lib/aiProviderDefaults.ts b/src/lib/aiProviderDefaults.ts new file mode 100644 index 000000000..da4c43739 --- /dev/null +++ b/src/lib/aiProviderDefaults.ts @@ -0,0 +1,107 @@ +import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities"; + +export type AiProviderType = + | "openai" + | "anthropic" + | "googleGemini" + | "vertexAi" + | "bedrock" + | "microsoftFoundry" + | "openRouter" + | "vercelAiGateway" + | "custom"; + +export const AI_PROVIDER_AUTH_TYPES = [ + "bearer", + "x-api-key", + "x-goog-api-key", + "hec", + "cf-aig-authorization", + "none", + "passthrough" +] as const; + +export type AiProviderAuthType = (typeof AI_PROVIDER_AUTH_TYPES)[number]; +export type AiBudgetUnit = "usd" | "tokens"; +export type AiProviderRoutingMode = "url" | "target"; + +type AiProviderDefaults = { + upstreamUrl: string | null; + authType: AiProviderAuthType; + capabilities: readonly AiCapability[]; +}; + +export const AI_PROVIDER_DEFAULTS: Record< + Exclude, + AiProviderDefaults +> = { + openai: { + upstreamUrl: "https://api.openai.com/v1", + authType: "bearer", + capabilities: ["openai_chat", "openai_responses"] + }, + anthropic: { + upstreamUrl: "https://api.anthropic.com", + authType: "x-api-key", + capabilities: ["anthropic_messages"] + }, + googleGemini: { + upstreamUrl: "https://generativelanguage.googleapis.com", + authType: "x-goog-api-key", + capabilities: ["gemini_generate_content"] + }, + vertexAi: { + upstreamUrl: null, + authType: "bearer", + capabilities: ["google_generate_content", "google_raw_predict"] + }, + bedrock: { + upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + authType: "bearer", + capabilities: ["bedrock_converse"] + }, + microsoftFoundry: { + upstreamUrl: null, + authType: "bearer", + capabilities: ["openai_chat", "openai_responses", "anthropic_messages"] + }, + openRouter: { + upstreamUrl: "https://openrouter.ai/api/v1", + authType: "bearer", + capabilities: ["openai_chat"] + }, + vercelAiGateway: { + upstreamUrl: "https://ai-gateway.vercel.sh/v1", + authType: "bearer", + capabilities: ["openai_chat", "openai_responses"] + } +}; + +export function authTypeRequiresApiKey(authType: AiProviderAuthType): boolean { + return authType !== "none" && authType !== "passthrough"; +} + +export function providerRequiresUpstreamUrl( + type: AiProviderType, + routingMode: AiProviderRoutingMode = "url" +): boolean { + if (routingMode === "target") { + return false; + } + if (type === "custom") { + return true; + } + return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null; +} + +export function defaultsForProviderType( + type: AiProviderType +): readonly AiCapability[] { + if (type === "custom") { + return []; + } + return AI_PROVIDER_DEFAULTS[type].capabilities; +} + +export { AI_CAPABILITIES }; +export type { AiCapability }; diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index 2f75c92ec..e033c0185 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -1,14 +1,15 @@ import { z } from "zod"; import { + AI_CAPABILITIES, AI_PROVIDER_AUTH_TYPES, AI_PROVIDER_DEFAULTS, authTypeRequiresApiKey, defaultsForProviderType, providerRequiresUpstreamUrl, + type AiCapability, type AiProviderAuthType, type AiProviderType -} from "@server/lib/aiProviderDefaults"; -import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities"; +} from "@app/lib/aiProviderDefaults"; type TranslateFn = (key: string) => string; diff --git a/src/lib/virtualApiKeyFormat.ts b/src/lib/virtualApiKeyFormat.ts new file mode 100644 index 000000000..f09686c15 --- /dev/null +++ b/src/lib/virtualApiKeyFormat.ts @@ -0,0 +1,63 @@ +export const VIRTUAL_API_KEY_PREFIX = "vk-"; + +const VIRTUAL_API_KEY_AUTH_HEADER_NAMES = [ + "authorization", + "x-api-key", + "x-goog-api-key", + "cf-aig-authorization" +] as const; + +export function formatVirtualApiKeyCredential( + virtualApiKeyId: string, + secret: string +): string { + return `${VIRTUAL_API_KEY_PREFIX}${virtualApiKeyId}.${secret}`; +} + +export function formatVirtualApiKeyPreview( + virtualApiKeyId: string, + lastChars: string +): string { + return `${VIRTUAL_API_KEY_PREFIX}${virtualApiKeyId}••••${lastChars}`; +} + +export function looksLikeVirtualApiKeyCredential(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed.startsWith(VIRTUAL_API_KEY_PREFIX)) { + return false; + } + const withoutPrefix = trimmed.slice(VIRTUAL_API_KEY_PREFIX.length); + const dot = withoutPrefix.indexOf("."); + return dot > 0 && dot < withoutPrefix.length - 1; +} + +function headerValueCarriesVirtualApiKey(raw: string): boolean { + const trimmed = raw.trim(); + const bearerMatch = trimmed.match(/^(?:Bearer|Splunk)\s+(.+)$/i); + if (bearerMatch) { + return looksLikeVirtualApiKeyCredential(bearerMatch[1]); + } + return looksLikeVirtualApiKeyCredential(trimmed); +} + +/** + * Remove client headers that carry a Pangolin virtual API key so they are + * never forwarded to upstream providers (including passthrough auth). + */ +export function stripVirtualApiKeyAuthHeaders( + headers: Record +): void { + for (const key of Object.keys(headers)) { + const lower = key.toLowerCase(); + if ( + !(VIRTUAL_API_KEY_AUTH_HEADER_NAMES as readonly string[]).includes( + lower + ) + ) { + continue; + } + if (headerValueCarriesVirtualApiKey(headers[key])) { + delete headers[key]; + } + } +} From 8a04f13dd4ef54c41fc7bc0fedc878d6488a0c0a Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 12 Aug 2026 10:37:19 -0400 Subject: [PATCH 307/494] translate keys and restructure roles and users --- messages/en-US.json | 56 +++++++++++++ .../[orgId]/settings/logs/ai-usage/page.tsx | 15 ++-- src/components/AiUsageAnalyticsData.tsx | 34 ++++---- .../ai-usage-analytics/OverviewTab.tsx | 78 ++++++++++--------- .../ai-usage-analytics/ProvidersTab.tsx | 24 +++--- .../ai-usage-analytics/ResourcesTab.tsx | 41 +++++----- .../ToggleableTrendChart.tsx | 48 +++++++----- .../ai-usage-analytics/TopEntitiesList.tsx | 18 +++-- .../ai-usage-analytics/UsersRolesTab.tsx | 66 +++++++++------- src/components/ai-usage-analytics/shared.ts | 5 +- src/components/ui/chart.tsx | 6 +- 11 files changed, 241 insertions(+), 150 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 166ca8a4b..bb1325ee9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1936,6 +1936,62 @@ "aiResourceAliasRequired": "Alias is required for inference resources", "aiResourceDomainConfiguration": "Domain configuration", "aiResourceDomainConfigurationDescription": "Choose the domain clients will use to reach this inference resource.", + "aiUsageAnalyticsTitle": "AI Usage Analytics", + "aiUsageAnalyticsDescription": "Analyze AI gateway cost, token usage, and activity across providers, resources, roles, and users", + "aiUsageTabOverview": "Overview", + "aiUsageTabProviders": "Providers", + "aiUsageTabResources": "Resources", + "aiUsageTabUsersRoles": "Users & Roles", + "aiUsageFilterProvider": "Provider", + "aiUsageFilterModel": "Model", + "aiUsageFilterResource": "Resource", + "aiUsageFilterRole": "Role", + "aiUsageFilterUser": "User", + "aiUsageFilterAllProviders": "All Providers", + "aiUsageFilterAllModels": "All Models", + "aiUsageFilterAllResources": "All Resources", + "aiUsageFilterAllRoles": "All Roles", + "aiUsageFilterAllUsers": "All Users", + "aiUsageResetFilters": "Reset Filters", + "aiUsageRefresh": "Refresh", + "aiUsageTokenTypePrompt": "Prompt", + "aiUsageTokenTypeCacheRead": "Cache read", + "aiUsageTokenTypeCacheWrite": "Cache write", + "aiUsageTokenTypeCompletion": "Completion", + "aiUsageTokenTypeReasoning": "Reasoning", + "aiUsageRequests": "Requests", + "aiUsageCost": "Cost", + "aiUsageTokens": "Tokens", + "aiUsageOther": "Other", + "aiUsageTotalRequests": "Total Requests", + "aiUsageTotalTokens": "Total Tokens", + "aiUsageTotalCost": "Total Cost", + "aiUsageEstimated": "Estimated", + "aiUsageRequestVolume": "Request Volume", + "aiUsageTokenUsage": "Token Usage", + "aiUsageModelCost": "Model Cost", + "aiUsageModelTokens": "Model Tokens", + "aiUsageTopModels": "Top Models", + "aiUsageTopProviders": "Top Providers", + "aiUsageProviderCost": "Provider Cost", + "aiUsageProviderTokenUsage": "Provider Token Usage", + "aiUsageTopResources": "Top Resources", + "aiUsageResourceCost": "Resource Cost", + "aiUsageResourceTokenUsage": "Resource Token Usage", + "aiUsageResourceTypePublic": "Resource", + "aiUsageResourceTypeSite": "Site resource", + "aiUsageNoResource": "No resource", + "aiUsageRolesTab": "Roles", + "aiUsageUsersTab": "Users", + "aiUsageTopRoles": "Top Roles", + "aiUsageRoleCost": "Role Cost", + "aiUsageRoleTokenUsage": "Role Token Usage", + "aiUsageTopUsers": "Top Users", + "aiUsageUserCost": "User Cost", + "aiUsageUserTokenUsage": "User Token Usage", + "aiUsageUnknownUser": "Unknown user", + "aiUsageLoading": "Loading...", + "aiUsageNoData": "No data", "resourceBudgetSettings": "Budget", "resourceBudgetSettingsDescription": "Configure how this inference resource restricts usage based on spending or token limits", "sidebarApiKeys": "API Keys", diff --git a/src/app/[orgId]/settings/logs/ai-usage/page.tsx b/src/app/[orgId]/settings/logs/ai-usage/page.tsx index f045aead0..72e9cea71 100644 --- a/src/app/[orgId]/settings/logs/ai-usage/page.tsx +++ b/src/app/[orgId]/settings/logs/ai-usage/page.tsx @@ -1,10 +1,14 @@ import { AiUsageAnalyticsData } from "@app/components/AiUsageAnalyticsData"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; -export const metadata: Metadata = { - title: "AI Usage Analytics" -}; +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { + title: t("aiUsageAnalyticsTitle") + }; +} export interface AiUsageAnalyticsPageProps { params: Promise<{ orgId: string }>; @@ -14,12 +18,13 @@ export default async function AiUsageAnalyticsPage( props: AiUsageAnalyticsPageProps ) { const orgId = (await props.params).orgId; + const t = await getTranslations(); return ( <>
diff --git a/src/components/AiUsageAnalyticsData.tsx b/src/components/AiUsageAnalyticsData.tsx index 879aff1bc..748fc7cdf 100644 --- a/src/components/AiUsageAnalyticsData.tsx +++ b/src/components/AiUsageAnalyticsData.tsx @@ -8,6 +8,7 @@ import { } from "@app/lib/queries"; import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query"; import { RefreshCw, XIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { DateRangePicker, type DateTimeValue } from "./DateTimePicker"; import { Button } from "./ui/button"; @@ -35,6 +36,7 @@ export type AiUsageAnalyticsDataProps = { const AI_USAGE_ANALYTICS_QUERY_PREFIX = ["AI_USAGE_ANALYTICS"]; export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) { + const t = useTranslations(); const searchParams = useSearchParams(); const path = usePathname(); const router = useRouter(); @@ -139,10 +141,10 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) { })); const tabs: TabItem[] = [ - { title: "Overview", href: "#" }, - { title: "Provider Usage", href: "#" }, - { title: "Resources", href: "#" }, - { title: "Users & Roles", href: "#" } + { title: t("aiUsageTabOverview"), href: "#" }, + { title: t("aiUsageTabProviders"), href: "#" }, + { title: t("aiUsageTabResources"), href: "#" }, + { title: t("aiUsageTabUsersRoles"), href: "#" } ]; return ( @@ -172,42 +174,42 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
setFilter("providerId", v)} /> setFilter("model", v)} /> setFilter("resourceId", v)} /> setFilter("roleId", v)} /> setFilter("userId", v)} /> @@ -218,7 +220,7 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) { className="gap-2" > - Reset Filters + {t("aiUsageResetFilters")} )}
@@ -243,7 +245,7 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) { isFetching && "animate-spin" )} /> - Refresh + {t("aiUsageRefresh")}
diff --git a/src/components/ai-usage-analytics/OverviewTab.tsx b/src/components/ai-usage-analytics/OverviewTab.tsx index 1bf4dab1d..c539f11d9 100644 --- a/src/components/ai-usage-analytics/OverviewTab.tsx +++ b/src/components/ai-usage-analytics/OverviewTab.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { aiUsageAnalyticsQueries } from "@app/lib/queries"; import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; +import { useTranslations } from "next-intl"; import { Card, CardContent, CardHeader } from "@app/components/ui/card"; import { InfoSection, @@ -24,15 +25,8 @@ type OverviewTabProps = { filters: AiUsageAnalyticsFilters; }; -const TOKEN_TYPE_LABELS: Record = { - promptTokens: "Prompt", - cacheReadTokens: "Cache read", - cacheWriteTokens: "Cache write", - completionTokens: "Completion", - reasoningTokens: "Reasoning" -}; - export function OverviewTab(props: OverviewTabProps) { + const t = useTranslations(); const { data, isLoading } = useQuery( aiUsageAnalyticsQueries.overview({ orgId: props.orgId, @@ -40,8 +34,16 @@ export function OverviewTab(props: OverviewTabProps) { }) ); + const TOKEN_TYPE_LABELS: Record = { + promptTokens: t("aiUsageTokenTypePrompt"), + cacheReadTokens: t("aiUsageTokenTypeCacheRead"), + cacheWriteTokens: t("aiUsageTokenTypeCacheWrite"), + completionTokens: t("aiUsageTokenTypeCompletion"), + reasoningTokens: t("aiUsageTokenTypeReasoning") + }; + const requestsSeries = [ - { key: "requests", label: "Requests", color: SERIES_COLORS[0] } + { key: "requests", label: t("aiUsageRequests"), color: SERIES_COLORS[0] } ]; const tokensSeries = Object.keys(TOKEN_TYPE_LABELS).map((key, i) => ({ key, @@ -49,16 +51,18 @@ export function OverviewTab(props: OverviewTabProps) { color: SERIES_COLORS[i % SERIES_COLORS.length] })); const costSeries = [ - { key: "cost", label: "Cost", color: SERIES_COLORS[0] } + { key: "cost", label: t("aiUsageCost"), color: SERIES_COLORS[0] } ]; const modelCostSeries = buildSeriesFromData( data?.modelCostPerDay ?? [], - (key) => key + (key) => key, + t("aiUsageOther") ); const modelTokensSeries = buildSeriesFromData( data?.modelTokensPerDay ?? [], - (key) => key + (key) => key, + t("aiUsageOther") ); const topModels: TopEntity[] = (data?.topModels ?? []).map((m) => ({ @@ -75,7 +79,9 @@ export function OverviewTab(props: OverviewTabProps) { - Total Requests + + {t("aiUsageTotalRequests")} + {data ? compactNumberFormatter.format( @@ -85,7 +91,9 @@ export function OverviewTab(props: OverviewTabProps) { - Total Tokens + + {t("aiUsageTotalTokens")} + {data ? compactNumberFormatter.format( @@ -95,13 +103,17 @@ export function OverviewTab(props: OverviewTabProps) { - Total Cost + + {t("aiUsageTotalCost")} + {data ? formatCost(data.totalCost) : "--"} - Estimated + + {t("aiUsageEstimated")} + {data ? `${Math.round(data.estimatedPercent)}%` @@ -114,11 +126,9 @@ export function OverviewTab(props: OverviewTabProps) {
- -

Request Volume

-
- +
- -

Token Usage

-
- +
- -

Cost

-
- + - -

Model Cost

-
- +
- -

Model Tokens

-
- + -

Top Models

+

{t("aiUsageTopModels")}

diff --git a/src/components/ai-usage-analytics/ProvidersTab.tsx b/src/components/ai-usage-analytics/ProvidersTab.tsx index 70756d558..e3b7a5d29 100644 --- a/src/components/ai-usage-analytics/ProvidersTab.tsx +++ b/src/components/ai-usage-analytics/ProvidersTab.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { aiUsageAnalyticsQueries } from "@app/lib/queries"; import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; +import { useTranslations } from "next-intl"; import { Card, CardContent, CardHeader } from "@app/components/ui/card"; import { ToggleableTrendChart } from "./ToggleableTrendChart"; import { TopEntitiesList, type TopEntity } from "./TopEntitiesList"; @@ -14,6 +15,7 @@ type ProvidersTabProps = { }; export function ProvidersTab(props: ProvidersTabProps) { + const t = useTranslations(); const { data, isLoading } = useQuery( aiUsageAnalyticsQueries.providers({ orgId: props.orgId, @@ -29,11 +31,13 @@ export function ProvidersTab(props: ProvidersTabProps) { const costSeries = buildSeriesFromData( data?.providerCostPerDay ?? [], - labelFor + labelFor, + t("aiUsageOther") ); const tokensSeries = buildSeriesFromData( data?.providerTokensPerDay ?? [], - labelFor + labelFor, + t("aiUsageOther") ); const topProviders: TopEntity[] = (data?.topProviders ?? []).map((p) => ({ @@ -48,24 +52,22 @@ export function ProvidersTab(props: ProvidersTabProps) {
-

Top Providers

+

{t("aiUsageTopProviders")}

- -

Provider Cost

-
- +
- -

Provider Token Usage

-
- + - key === "none" ? "No resource" : (nameByKey.get(key) ?? key); + key === "none" ? t("aiUsageNoResource") : (nameByKey.get(key) ?? key); const costSeries = buildSeriesFromData( data?.resourceCostPerDay ?? [], - labelFor + labelFor, + t("aiUsageOther") ); const tokensSeries = buildSeriesFromData( data?.resourceTokensPerDay ?? [], - labelFor + labelFor, + t("aiUsageOther") ); const topResources: TopEntity[] = (data?.topResources ?? []).map((r) => ({ key: r.key, - label: r.name ?? (r.key === "none" ? "No resource" : r.key), + label: r.name ?? (r.key === "none" ? t("aiUsageNoResource") : r.key), sublabel: resourceTypeLabel(r.type), requests: r.requests, totalTokens: r.totalTokens, @@ -56,24 +61,22 @@ export function ResourcesTab(props: ResourcesTabProps) {
-

Top Resources

+

{t("aiUsageTopResources")}

- -

Resource Cost

-
- +
- -

Resource Token Usage

-
- + ("bar"); const valueFormatter = props.valueFormatter ?? compactFormatter.format; @@ -61,25 +64,28 @@ export function ToggleableTrendChart(props: ToggleableTrendChartProps) { return (
-
- - +
+

{props.title}

+
+ + +
{!hasData ? ( @@ -87,10 +93,10 @@ export function ToggleableTrendChart(props: ToggleableTrendChartProps) { {props.isLoading ? ( <> - Loading... + {t("aiUsageLoading")} ) : ( - "No data" + t("aiUsageNoData") )}
) : ( diff --git a/src/components/ai-usage-analytics/TopEntitiesList.tsx b/src/components/ai-usage-analytics/TopEntitiesList.tsx index 9f712363f..6a445db6c 100644 --- a/src/components/ai-usage-analytics/TopEntitiesList.tsx +++ b/src/components/ai-usage-analytics/TopEntitiesList.tsx @@ -1,6 +1,7 @@ "use client"; import { LoaderIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; import { compactNumberFormatter, formatCost } from "./shared"; export type TopEntity = { @@ -20,6 +21,7 @@ type TopEntitiesListProps = { }; export function TopEntitiesList(props: TopEntitiesListProps) { + const t = useTranslations(); const totalCost = props.entities.reduce( (sum, e) => sum + (e.costUsd ?? 0), 0 @@ -30,9 +32,15 @@ export function TopEntitiesList(props: TopEntitiesListProps) { {props.entities.length > 0 && (
{props.nameColumnLabel}
-
Requests
-
Tokens
-
Cost
+
+ {t("aiUsageRequests")} +
+
+ {t("aiUsageTokens")} +
+
+ {t("aiUsageCost")} +
%
)} @@ -42,10 +50,10 @@ export function TopEntitiesList(props: TopEntitiesListProps) { {props.isLoading ? ( <> - Loading... + {t("aiUsageLoading")} ) : ( - (props.emptyLabel ?? "No data") + (props.emptyLabel ?? t("aiUsageNoData")) )}
)} diff --git a/src/components/ai-usage-analytics/UsersRolesTab.tsx b/src/components/ai-usage-analytics/UsersRolesTab.tsx index 3d8d411f1..6e49ce9fb 100644 --- a/src/components/ai-usage-analytics/UsersRolesTab.tsx +++ b/src/components/ai-usage-analytics/UsersRolesTab.tsx @@ -3,7 +3,9 @@ import { useQuery } from "@tanstack/react-query"; import { aiUsageAnalyticsQueries } from "@app/lib/queries"; import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; +import { useTranslations } from "next-intl"; import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { HorizontalTabs, type TabItem } from "@app/components/HorizontalTabs"; import { ToggleableTrendChart } from "./ToggleableTrendChart"; import { TopEntitiesList, type TopEntity } from "./TopEntitiesList"; import { buildSeriesFromData, formatCost } from "./shared"; @@ -16,6 +18,7 @@ type UsersRolesTabProps = { const UNKNOWN_USER_KEY = "unknown"; export function UsersRolesTab(props: UsersRolesTabProps) { + const t = useTranslations(); const { data, isLoading } = useQuery( aiUsageAnalyticsQueries.usersRoles({ orgId: props.orgId, @@ -38,24 +41,28 @@ export function UsersRolesTab(props: UsersRolesTabProps) { } const userLabelFor = (key: string) => key === UNKNOWN_USER_KEY - ? "Unknown user" + ? t("aiUsageUnknownUser") : (userEmailByKey.get(key) ?? key); const roleCostSeries = buildSeriesFromData( data?.roleCostPerDay ?? [], - roleLabelFor + roleLabelFor, + t("aiUsageOther") ); const roleTokensSeries = buildSeriesFromData( data?.roleTokensPerDay ?? [], - roleLabelFor + roleLabelFor, + t("aiUsageOther") ); const userCostSeries = buildSeriesFromData( data?.userCostPerDay ?? [], - userLabelFor + userLabelFor, + t("aiUsageOther") ); const userTokensSeries = buildSeriesFromData( data?.userTokensPerDay ?? [], - userLabelFor + userLabelFor, + t("aiUsageOther") ); const topRoles: TopEntity[] = (data?.topRoles ?? []).map((r) => ({ @@ -68,35 +75,39 @@ export function UsersRolesTab(props: UsersRolesTabProps) { const topUsers: TopEntity[] = (data?.topUsers ?? []).map((u) => ({ key: u.userId ?? UNKNOWN_USER_KEY, - label: u.email ?? u.userId ?? "Unknown user", + label: u.email ?? u.userId ?? t("aiUsageUnknownUser"), requests: u.requests, totalTokens: u.totalTokens, costUsd: u.costUsd })); + const tabs: TabItem[] = [ + { title: t("aiUsageRolesTab"), href: "#" }, + { title: t("aiUsageUsersTab"), href: "#" } + ]; + return ( -
+
-

Roles

-

Top Roles

+

+ {t("aiUsageTopRoles")} +

- -

Role Cost

-
- +
- -

Role Token Usage

-
- +
-

Users

-

Top Users

+

+ {t("aiUsageTopUsers")} +

- -

User Cost

-
- +
- -

User Token Usage

-
- +
-
+ ); } diff --git a/src/components/ai-usage-analytics/shared.ts b/src/components/ai-usage-analytics/shared.ts index 1f551f8fa..30ad525b4 100644 --- a/src/components/ai-usage-analytics/shared.ts +++ b/src/components/ai-usage-analytics/shared.ts @@ -18,7 +18,8 @@ export const OTHER_KEY = "other"; // top-list needed. Assigns one categorical color per key, "other" last. export function buildSeriesFromData( data: Array>, - labelFor: (key: string) => string + labelFor: (key: string) => string, + otherLabel = "Other" ): TrendSeries[] { const keys = new Set(); for (const row of data) { @@ -37,7 +38,7 @@ export function buildSeriesFromData( const hasOther = data.some((row) => OTHER_KEY in row); if (hasOther) { - series.push({ key: OTHER_KEY, label: "Other", color: OTHER_COLOR }); + series.push({ key: OTHER_KEY, label: otherLabel, color: OTHER_COLOR }); } return series; diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx index 83535e6cc..168393b29 100644 --- a/src/components/ui/chart.tsx +++ b/src/components/ui/chart.tsx @@ -57,7 +57,11 @@ const ChartContainer = React.forwardRef< {...props} > - + {children}
From 833b27ab4acc95c437404dfe982aa595748934ab Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 12 Aug 2026 10:40:29 -0400 Subject: [PATCH 308/494] separate the users and roles tabs --- messages/en-US.json | 1 - src/components/AiUsageAnalyticsData.tsx | 9 +- .../ai-usage-analytics/RolesTab.tsx | 91 +++++++++ .../ai-usage-analytics/UsersRolesTab.tsx | 172 ------------------ .../ai-usage-analytics/UsersTab.tsx | 97 ++++++++++ 5 files changed, 194 insertions(+), 176 deletions(-) create mode 100644 src/components/ai-usage-analytics/RolesTab.tsx delete mode 100644 src/components/ai-usage-analytics/UsersRolesTab.tsx create mode 100644 src/components/ai-usage-analytics/UsersTab.tsx diff --git a/messages/en-US.json b/messages/en-US.json index bb1325ee9..5da27a834 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1941,7 +1941,6 @@ "aiUsageTabOverview": "Overview", "aiUsageTabProviders": "Providers", "aiUsageTabResources": "Resources", - "aiUsageTabUsersRoles": "Users & Roles", "aiUsageFilterProvider": "Provider", "aiUsageFilterModel": "Model", "aiUsageFilterResource": "Resource", diff --git a/src/components/AiUsageAnalyticsData.tsx b/src/components/AiUsageAnalyticsData.tsx index 748fc7cdf..c19ca2346 100644 --- a/src/components/AiUsageAnalyticsData.tsx +++ b/src/components/AiUsageAnalyticsData.tsx @@ -27,7 +27,8 @@ import { HorizontalTabs, type TabItem } from "./HorizontalTabs"; import { OverviewTab } from "./ai-usage-analytics/OverviewTab"; import { ProvidersTab } from "./ai-usage-analytics/ProvidersTab"; import { ResourcesTab } from "./ai-usage-analytics/ResourcesTab"; -import { UsersRolesTab } from "./ai-usage-analytics/UsersRolesTab"; +import { RolesTab } from "./ai-usage-analytics/RolesTab"; +import { UsersTab } from "./ai-usage-analytics/UsersTab"; export type AiUsageAnalyticsDataProps = { orgId: string; @@ -144,7 +145,8 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) { { title: t("aiUsageTabOverview"), href: "#" }, { title: t("aiUsageTabProviders"), href: "#" }, { title: t("aiUsageTabResources"), href: "#" }, - { title: t("aiUsageTabUsersRoles"), href: "#" } + { title: t("aiUsageRolesTab"), href: "#" }, + { title: t("aiUsageUsersTab"), href: "#" } ]; return ( @@ -255,7 +257,8 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) { - + +
); diff --git a/src/components/ai-usage-analytics/RolesTab.tsx b/src/components/ai-usage-analytics/RolesTab.tsx new file mode 100644 index 000000000..0b60cab3c --- /dev/null +++ b/src/components/ai-usage-analytics/RolesTab.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { aiUsageAnalyticsQueries } from "@app/lib/queries"; +import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; +import { useTranslations } from "next-intl"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { ToggleableTrendChart } from "./ToggleableTrendChart"; +import { TopEntitiesList, type TopEntity } from "./TopEntitiesList"; +import { buildSeriesFromData, formatCost } from "./shared"; + +type RolesTabProps = { + orgId: string; + filters: AiUsageAnalyticsFilters; +}; + +export function RolesTab(props: RolesTabProps) { + const t = useTranslations(); + const { data, isLoading } = useQuery( + aiUsageAnalyticsQueries.usersRoles({ + orgId: props.orgId, + filters: props.filters + }) + ); + + const roleNameByKey = new Map(); + for (const r of data?.topRoles ?? []) { + roleNameByKey.set(String(r.roleId), r.name ?? `Role #${r.roleId}`); + } + const roleLabelFor = (key: string) => + roleNameByKey.get(key) ?? `Role #${key}`; + + const roleCostSeries = buildSeriesFromData( + data?.roleCostPerDay ?? [], + roleLabelFor, + t("aiUsageOther") + ); + const roleTokensSeries = buildSeriesFromData( + data?.roleTokensPerDay ?? [], + roleLabelFor, + t("aiUsageOther") + ); + + const topRoles: TopEntity[] = (data?.topRoles ?? []).map((r) => ({ + key: String(r.roleId), + label: r.name ?? `Role #${r.roleId}`, + requests: r.requests, + totalTokens: r.totalTokens, + costUsd: r.costUsd + })); + + return ( +
+ + +

{t("aiUsageTopRoles")}

+
+ + + +
+
+ + + formatCost(v)} + /> + + + + + + + +
+
+ ); +} diff --git a/src/components/ai-usage-analytics/UsersRolesTab.tsx b/src/components/ai-usage-analytics/UsersRolesTab.tsx deleted file mode 100644 index 6e49ce9fb..000000000 --- a/src/components/ai-usage-analytics/UsersRolesTab.tsx +++ /dev/null @@ -1,172 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { aiUsageAnalyticsQueries } from "@app/lib/queries"; -import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; -import { useTranslations } from "next-intl"; -import { Card, CardContent, CardHeader } from "@app/components/ui/card"; -import { HorizontalTabs, type TabItem } from "@app/components/HorizontalTabs"; -import { ToggleableTrendChart } from "./ToggleableTrendChart"; -import { TopEntitiesList, type TopEntity } from "./TopEntitiesList"; -import { buildSeriesFromData, formatCost } from "./shared"; - -type UsersRolesTabProps = { - orgId: string; - filters: AiUsageAnalyticsFilters; -}; - -const UNKNOWN_USER_KEY = "unknown"; - -export function UsersRolesTab(props: UsersRolesTabProps) { - const t = useTranslations(); - const { data, isLoading } = useQuery( - aiUsageAnalyticsQueries.usersRoles({ - orgId: props.orgId, - filters: props.filters - }) - ); - - const roleNameByKey = new Map(); - for (const r of data?.topRoles ?? []) { - roleNameByKey.set(String(r.roleId), r.name ?? `Role #${r.roleId}`); - } - const roleLabelFor = (key: string) => - roleNameByKey.get(key) ?? `Role #${key}`; - - const userEmailByKey = new Map(); - for (const u of data?.topUsers ?? []) { - if (u.userId) { - userEmailByKey.set(u.userId, u.email ?? u.userId); - } - } - const userLabelFor = (key: string) => - key === UNKNOWN_USER_KEY - ? t("aiUsageUnknownUser") - : (userEmailByKey.get(key) ?? key); - - const roleCostSeries = buildSeriesFromData( - data?.roleCostPerDay ?? [], - roleLabelFor, - t("aiUsageOther") - ); - const roleTokensSeries = buildSeriesFromData( - data?.roleTokensPerDay ?? [], - roleLabelFor, - t("aiUsageOther") - ); - const userCostSeries = buildSeriesFromData( - data?.userCostPerDay ?? [], - userLabelFor, - t("aiUsageOther") - ); - const userTokensSeries = buildSeriesFromData( - data?.userTokensPerDay ?? [], - userLabelFor, - t("aiUsageOther") - ); - - const topRoles: TopEntity[] = (data?.topRoles ?? []).map((r) => ({ - key: String(r.roleId), - label: r.name ?? `Role #${r.roleId}`, - requests: r.requests, - totalTokens: r.totalTokens, - costUsd: r.costUsd - })); - - const topUsers: TopEntity[] = (data?.topUsers ?? []).map((u) => ({ - key: u.userId ?? UNKNOWN_USER_KEY, - label: u.email ?? u.userId ?? t("aiUsageUnknownUser"), - requests: u.requests, - totalTokens: u.totalTokens, - costUsd: u.costUsd - })); - - const tabs: TabItem[] = [ - { title: t("aiUsageRolesTab"), href: "#" }, - { title: t("aiUsageUsersTab"), href: "#" } - ]; - - return ( - -
- - -

- {t("aiUsageTopRoles")} -

-
- - - -
-
- - - formatCost(v)} - /> - - - - - - - -
-
- -
- - -

- {t("aiUsageTopUsers")} -

-
- - - -
-
- - - formatCost(v)} - /> - - - - - - - -
-
-
- ); -} diff --git a/src/components/ai-usage-analytics/UsersTab.tsx b/src/components/ai-usage-analytics/UsersTab.tsx new file mode 100644 index 000000000..25e914e3e --- /dev/null +++ b/src/components/ai-usage-analytics/UsersTab.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { aiUsageAnalyticsQueries } from "@app/lib/queries"; +import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; +import { useTranslations } from "next-intl"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { ToggleableTrendChart } from "./ToggleableTrendChart"; +import { TopEntitiesList, type TopEntity } from "./TopEntitiesList"; +import { buildSeriesFromData, formatCost } from "./shared"; + +type UsersTabProps = { + orgId: string; + filters: AiUsageAnalyticsFilters; +}; + +const UNKNOWN_USER_KEY = "unknown"; + +export function UsersTab(props: UsersTabProps) { + const t = useTranslations(); + const { data, isLoading } = useQuery( + aiUsageAnalyticsQueries.usersRoles({ + orgId: props.orgId, + filters: props.filters + }) + ); + + const userEmailByKey = new Map(); + for (const u of data?.topUsers ?? []) { + if (u.userId) { + userEmailByKey.set(u.userId, u.email ?? u.userId); + } + } + const userLabelFor = (key: string) => + key === UNKNOWN_USER_KEY + ? t("aiUsageUnknownUser") + : (userEmailByKey.get(key) ?? key); + + const userCostSeries = buildSeriesFromData( + data?.userCostPerDay ?? [], + userLabelFor, + t("aiUsageOther") + ); + const userTokensSeries = buildSeriesFromData( + data?.userTokensPerDay ?? [], + userLabelFor, + t("aiUsageOther") + ); + + const topUsers: TopEntity[] = (data?.topUsers ?? []).map((u) => ({ + key: u.userId ?? UNKNOWN_USER_KEY, + label: u.email ?? u.userId ?? t("aiUsageUnknownUser"), + requests: u.requests, + totalTokens: u.totalTokens, + costUsd: u.costUsd + })); + + return ( +
+ + +

{t("aiUsageTopUsers")}

+
+ + + +
+
+ + + formatCost(v)} + /> + + + + + + + +
+
+ ); +} From 355294a2d4c8abb364d709ae8e5929422cd482b0 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 12 Aug 2026 10:48:06 -0400 Subject: [PATCH 309/494] fix logo warning in logs --- src/components/LayoutHeader.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index ac265ff08..47eea0893 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -42,8 +42,8 @@ export function LayoutHeader({ const pathname = usePathname(); const logoWidth = isUnlocked() - ? env.branding.logo?.navbar?.width || 98 - : 98; + ? env.branding.logo?.navbar?.width || 128 + : 128; const logoHeight = isUnlocked() ? env.branding.logo?.navbar?.height || 32 : 32; From 379b53bcca69442db529cbc3de1f2794504422f1 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 12 Aug 2026 11:15:50 -0400 Subject: [PATCH 310/494] redirect to api key page when access gateway in browser --- server/routers/badger/verifySession.ts | 12 +++++++--- .../routers/resource/getResourceAuthInfo.ts | 4 +++- .../resource/[resourceGuid]/keys/page.tsx | 4 +++- src/app/auth/resource/[resourceGuid]/page.tsx | 23 +++++++++++++++++-- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index ee69ce163..b95b5925c 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -337,10 +337,16 @@ export async function verifyResourceSession( // Only offer a browser redirect to clients that can actually follow one and log in // (an interactive browser). Non-browser clients (curl, scripts, bots, etc.) just get // an unauthorized response from Badger instead of a login redirect URL. + // Inference browsers go to the dashboard keys page (not back to the inference host) + // so a valid session cannot create a redirect loop. const redirectPath = clientIsBrowser - ? `/auth/resource/${encodeURIComponent( - resource.resourceGuid - )}?redirect=${encodeURIComponent(originalRequestURL)}` + ? mode === "inference" + ? `/${resource.orgId}/resource/${encodeURIComponent( + resource.resourceGuid + )}/keys` + : `/auth/resource/${encodeURIComponent( + resource.resourceGuid + )}?redirect=${encodeURIComponent(originalRequestURL)}` : undefined; // Virtual API keys for public inference resources (provider-style auth headers). diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index f1328833d..667870959 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -42,6 +42,7 @@ export type GetResourceAuthInfoResponse = { skipToIdpId: number | null; orgId: string; postAuthPath: string | null; + mode: string; }; export async function getResourceAuthInfo( @@ -227,7 +228,8 @@ export async function getResourceAuthInfo( whitelist: effectivePolicy?.emailWhitelistEnabled ?? false, skipToIdpId: effectivePolicy?.idpId ?? resource.skipToIdpId, orgId: resource.orgId, - postAuthPath: resource.postAuthPath ?? null + postAuthPath: resource.postAuthPath ?? null, + mode: resource.mode }, success: true, error: false, diff --git a/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx b/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx index 29e11eb92..98d4b6582 100644 --- a/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx +++ b/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx @@ -41,7 +41,9 @@ export default async function ResourceKeysPage(props: ResourceKeysPageProps) { const user = await getUser(); if (!user) { - redirect("/"); + redirect( + `/auth/login?redirect=/${orgId}/resource/${resourceGuid}/keys` + ); } const cookieHeader = await authCookieHeader(); diff --git a/src/app/auth/resource/[resourceGuid]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx index dafc13f8f..4376bebc2 100644 --- a/src/app/auth/resource/[resourceGuid]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -71,6 +71,9 @@ export default async function ResourceAuthPage(props: { ); } + const isInference = authInfo.mode === "inference"; + const keysPath = `/${authInfo.orgId}/resource/${authInfo.resourceGuid}/keys`; + const hasLoginPageDomain = await isOrgSubscribed( authInfo.orgId, tierMatrix.loginPageDomain @@ -159,10 +162,18 @@ export default async function ResourceAuthPage(props: { if (user && !user.emailVerified && env.flags.emailVerificationRequired) { redirect( - `/auth/verify-email?redirect=/auth/resource/${authInfo.resourceGuid}` + `/auth/verify-email?redirect=${encodeURIComponent( + isInference + ? keysPath + : `/auth/resource/${authInfo.resourceGuid}` + )}` ); } + if (isInference && !user) { + redirect(`/auth/login?redirect=${encodeURIComponent(keysPath)}`); + } + const cookie = await authCookieHeader(); // Check org policy compliance before proceeding @@ -181,7 +192,9 @@ export default async function ResourceAuthPage(props: { // If user is not compliant with org policies, show policy requirements if (orgPolicyCheck && !orgPolicyCheck.allowed && orgPolicyCheck.policies) { - const resourceAuthPageUrl = `/auth/resource/${authInfo.resourceGuid}${redirectUrl !== authInfo.url ? `?redirect=${encodeURIComponent(redirectUrl)}` : ""}`; + const resourceAuthPageUrl = isInference + ? keysPath + : `/auth/resource/${authInfo.resourceGuid}${redirectUrl !== authInfo.url ? `?redirect=${encodeURIComponent(redirectUrl)}` : ""}`; return (
Date: Wed, 12 Aug 2026 11:18:16 -0400 Subject: [PATCH 311/494] support bypass auth rules on gateway --- server/routers/badger/verifySession.ts | 32 ++++++++++---------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index b95b5925c..0b7cbeab0 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -259,28 +259,20 @@ export async function verifyResourceSession( ); if (action == "ACCEPT") { - // Public inference still requires a virtual API key; do not - // bypass that with an allow rule. - if (mode === "inference") { - logger.debug( - "Rule ACCEPT ignored for inference; continuing to virtual API key check" - ); - } else { - logger.debug("Resource allowed by rule"); + logger.debug("Resource allowed by rule"); - logRequestAudit( - { - action: true, - reason: 100, // allowed by rule - resourceId: resource.resourceId, - orgId: resource.orgId, - location: ipCC - }, - parsedBody.data - ); + logRequestAudit( + { + action: true, + reason: 100, // allowed by rule + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); - return allowed(res, undefined, dontStripSession); - } + return allowed(res, undefined, dontStripSession); } else if (action == "DROP") { logger.debug("Resource denied by rule"); From bed5817da3e7897c6488a358de63f33fa05eafa0 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 12 Aug 2026 11:20:25 -0400 Subject: [PATCH 312/494] send, process, store, display virtual api key ai information in usage and sessions --- messages/en-US.json | 10 + server/db/pg/schema/schema.ts | 30 ++- server/db/sqlite/schema/schema.ts | 30 ++- server/lib/aiBudgetEnforcement.ts | 36 ++- server/routers/aiGateway/logAiSession.ts | 4 + server/routers/aiGateway/pipeline.ts | 68 ++++-- server/routers/aiGateway/targetRouting.ts | 2 + .../auditLogs/aiUsageAnalyticsShared.ts | 6 +- server/routers/auditLogs/index.ts | 1 + server/routers/auditLogs/queryAiSessionLog.ts | 83 ++++++- .../auditLogs/queryAiUsageFilterOptions.ts | 73 +++++- .../auditLogs/queryAiUsageVirtualApiKeys.ts | 222 ++++++++++++++++++ server/routers/auditLogs/types.ts | 8 + server/routers/badger/verifySession.ts | 21 +- server/routers/external.ts | 7 + server/routers/integration.ts | 7 + src/app/[orgId]/settings/logs/ai/page.tsx | 103 +++++++- src/components/AiUsageAnalyticsData.tsx | 32 ++- .../ai-usage-analytics/VirtualApiKeysTab.tsx | 108 +++++++++ src/lib/queries.ts | 66 +++++- 20 files changed, 849 insertions(+), 68 deletions(-) create mode 100644 server/routers/auditLogs/queryAiUsageVirtualApiKeys.ts create mode 100644 src/components/ai-usage-analytics/VirtualApiKeysTab.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 5da27a834..43625ee73 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1989,6 +1989,14 @@ "aiUsageUserCost": "User Cost", "aiUsageUserTokenUsage": "User Token Usage", "aiUsageUnknownUser": "Unknown user", + "aiUsageVirtualApiKeysTab": "Virtual API Keys", + "aiUsageFilterVirtualApiKey": "Virtual API Key", + "aiUsageFilterAllVirtualApiKeys": "All Virtual API Keys", + "aiUsageTopVirtualApiKeys": "Top Virtual API Keys", + "aiUsageVirtualApiKeyCost": "Virtual API Key Cost", + "aiUsageVirtualApiKeyTokenUsage": "Virtual API Key Token Usage", + "aiUsageUnknownVirtualApiKey": "No virtual API key", + "aiUsageUnnamedVirtualApiKey": "Unnamed key", "aiUsageLoading": "Loading...", "aiUsageNoData": "No data", "resourceBudgetSettings": "Budget", @@ -3457,6 +3465,8 @@ "provider": "Provider", "capability": "Capability", "model": "Model", + "virtualApiKey": "Virtual API Key", + "noVirtualApiKey": "No virtual API key", "stream": "Stream", "streaming": "Streaming", "nonStreaming": "Non-streaming", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index afaccc480..a5ebfa530 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1819,18 +1819,22 @@ export const aiUsageRecords = pgTable( .references(() => orgs.orgId, { onDelete: "cascade" }), providerId: integer("providerId") .notNull() - .references(() => aiProviders.providerId, { onDelete: "cascade" }), + .references(() => aiProviders.providerId, { onDelete: "set null" }), resourceId: integer("resourceId").references( () => resources.resourceId, - { onDelete: "cascade" } + { onDelete: "set null" } ), siteResourceId: integer("siteResourceId").references( () => siteResources.siteResourceId, - { onDelete: "cascade" } + { onDelete: "set null" } ), userId: varchar("userId").references(() => users.userId, { onDelete: "set null" }), + virtualApiKeyId: varchar("virtualApiKeyId").references( + () => virtualApiKeys.virtualApiKeyId, + { onDelete: "set null" } + ), // Links this usage record back to the aiSessionLog row for the same // request (aiSessionLog.sessionId), so token/cost usage can be shown // alongside the session transcript. Not a DB-level FK - aiSessionLog @@ -1870,6 +1874,11 @@ export const aiUsageRecords = pgTable( t.userId, t.createdAt ), + index("idx_ai_usage_records_org_virtual_api_key_created").on( + t.orgId, + t.virtualApiKeyId, + t.createdAt + ), index("idx_ai_usage_records_session").on(t.sessionId) ] ); @@ -1927,19 +1936,23 @@ export const aiSessionLog = pgTable( }), providerId: integer("providerId") .notNull() - .references(() => aiProviders.providerId, { onDelete: "cascade" }), + .references(() => aiProviders.providerId, { onDelete: "set null" }), capability: varchar("capability").notNull(), resourceId: integer("resourceId").references( () => resources.resourceId, - { onDelete: "cascade" } + { onDelete: "set null" } ), siteResourceId: integer("siteResourceId").references( () => siteResources.siteResourceId, - { onDelete: "cascade" } + { onDelete: "set null" } ), userId: varchar("userId").references(() => users.userId, { onDelete: "set null" }), + virtualApiKeyId: varchar("virtualApiKeyId").references( + () => virtualApiKeys.virtualApiKeyId, + { onDelete: "set null" } + ), requestedModel: varchar("requestedModel"), isStream: boolean("isStream").notNull().default(false), requestBody: text("requestBody"), @@ -1979,6 +1992,11 @@ export const aiSessionLog = pgTable( t.userId, t.createdAt ), + index("idx_ai_session_log_org_virtual_api_key_created").on( + t.orgId, + t.virtualApiKeyId, + t.createdAt + ), index("idx_ai_session_log_session").on(t.sessionId) ] ); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index bf3be9f87..450b99fce 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1807,18 +1807,22 @@ export const aiUsageRecords = sqliteTable( .references(() => orgs.orgId, { onDelete: "cascade" }), providerId: integer("providerId") .notNull() - .references(() => aiProviders.providerId, { onDelete: "cascade" }), + .references(() => aiProviders.providerId, { onDelete: "set null" }), resourceId: integer("resourceId").references( () => resources.resourceId, - { onDelete: "cascade" } + { onDelete: "set null" } ), siteResourceId: integer("siteResourceId").references( () => siteResources.siteResourceId, - { onDelete: "cascade" } + { onDelete: "set null" } ), userId: text("userId").references(() => users.userId, { onDelete: "set null" }), + virtualApiKeyId: text("virtualApiKeyId").references( + () => virtualApiKeys.virtualApiKeyId, + { onDelete: "set null" } + ), // Links this usage record back to the aiSessionLog row for the same // request (aiSessionLog.sessionId), so token/cost usage can be shown // alongside the session transcript. Not a DB-level FK - aiSessionLog @@ -1860,6 +1864,11 @@ export const aiUsageRecords = sqliteTable( t.userId, t.createdAt ), + index("idx_ai_usage_records_org_virtual_api_key_created").on( + t.orgId, + t.virtualApiKeyId, + t.createdAt + ), index("idx_ai_usage_records_session").on(t.sessionId) ] ); @@ -1917,19 +1926,23 @@ export const aiSessionLog = sqliteTable( }), providerId: integer("providerId") .notNull() - .references(() => aiProviders.providerId, { onDelete: "cascade" }), + .references(() => aiProviders.providerId, { onDelete: "set null" }), capability: text("capability").notNull(), resourceId: integer("resourceId").references( () => resources.resourceId, - { onDelete: "cascade" } + { onDelete: "set null" } ), siteResourceId: integer("siteResourceId").references( () => siteResources.siteResourceId, - { onDelete: "cascade" } + { onDelete: "set null" } ), userId: text("userId").references(() => users.userId, { onDelete: "set null" }), + virtualApiKeyId: text("virtualApiKeyId").references( + () => virtualApiKeys.virtualApiKeyId, + { onDelete: "set null" } + ), requestedModel: text("requestedModel"), isStream: integer("isStream", { mode: "boolean" }) .notNull() @@ -1973,6 +1986,11 @@ export const aiSessionLog = sqliteTable( t.userId, t.createdAt ), + index("idx_ai_session_log_org_virtual_api_key_created").on( + t.orgId, + t.virtualApiKeyId, + t.createdAt + ), index("idx_ai_session_log_session").on(t.sessionId) ] ); diff --git a/server/lib/aiBudgetEnforcement.ts b/server/lib/aiBudgetEnforcement.ts index d73f5ba59..f6daf9694 100644 --- a/server/lib/aiBudgetEnforcement.ts +++ b/server/lib/aiBudgetEnforcement.ts @@ -1,4 +1,14 @@ -import { and, eq, gte, inArray, isNull, or, sql, SQL, type InferInsertModel } from "drizzle-orm"; +import { + and, + eq, + gte, + inArray, + isNull, + or, + sql, + SQL, + type InferInsertModel +} from "drizzle-orm"; import { AiBudget, aiBudgetBreachEvents, @@ -424,6 +434,7 @@ export type UsageRecordInput = { resourceId: number | null; siteResourceId: number | null; userId: string | null; + virtualApiKeyId: string | null; requestedModel: string; usage: AiUsage; costUsd: number | null; @@ -460,7 +471,10 @@ async function flushUsageRecords() { isUsageFlushInProgress = true; - const recordsToWrite = usageRecordBuffer.splice(0, usageRecordBuffer.length); + const recordsToWrite = usageRecordBuffer.splice( + 0, + usageRecordBuffer.length + ); try { // Use a transaction to ensure all inserts succeed or fail together @@ -472,16 +486,25 @@ async function flushUsageRecords() { await tx.insert(aiUsageRecords).values(batch); } }); - logger.debug(`Flushed ${recordsToWrite.length} AI usage records to database`); + logger.debug( + `Flushed ${recordsToWrite.length} AI usage records to database` + ); } catch (error) { logger.error("Error flushing AI usage records:", error); // On transaction error, put records back at the front of the buffer // to retry, but only if the buffer isn't too large - if (usageRecordBuffer.length < USAGE_MAX_BUFFER_SIZE - recordsToWrite.length) { + if ( + usageRecordBuffer.length < + USAGE_MAX_BUFFER_SIZE - recordsToWrite.length + ) { usageRecordBuffer.unshift(...recordsToWrite); - logger.info(`Re-queued ${recordsToWrite.length} AI usage records for retry`); + logger.info( + `Re-queued ${recordsToWrite.length} AI usage records for retry` + ); } else { - logger.error(`Buffer full, dropped ${recordsToWrite.length} AI usage records`); + logger.error( + `Buffer full, dropped ${recordsToWrite.length} AI usage records` + ); } } finally { isUsageFlushInProgress = false; @@ -544,6 +567,7 @@ export async function recordUsage(input: UsageRecordInput): Promise { resourceId: input.resourceId, siteResourceId: input.siteResourceId, userId: input.userId, + virtualApiKeyId: input.virtualApiKeyId, sessionId: input.sessionId, requestedModel: input.requestedModel, promptTokens: usage.promptTokens, diff --git a/server/routers/aiGateway/logAiSession.ts b/server/routers/aiGateway/logAiSession.ts index ee05517f5..5a2e29bdb 100644 --- a/server/routers/aiGateway/logAiSession.ts +++ b/server/routers/aiGateway/logAiSession.ts @@ -180,6 +180,7 @@ export function logAiSession(data: { resourceId: number | null; siteResourceId: number | null; requestUserId: string | null; + virtualApiKeyId: string | null; }): void { (async () => { try { @@ -237,6 +238,9 @@ export function logAiSession(data: { resourceId: data.resourceId ?? undefined, siteResourceId: data.siteResourceId ?? undefined, userId: sanitizeString(data.requestUserId ?? undefined), + virtualApiKeyId: sanitizeString( + data.virtualApiKeyId ?? undefined + ), requestedModel: sanitizeString(data.requestedModel), isStream: data.isStream, requestBody: sanitizeString(requestBodyText.value), diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 5aa69d7fc..2be7a2592 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -161,6 +161,15 @@ export type RequestUser = { roleIds: number[]; }; +// Identity resolved for a gateway request: the app/session or virtual-API-key +// user (if any) plus the virtual API key that authenticated the request (if +// any) - a manual virtual API key with no associated user has a +// virtualApiKeyId but no user. +export type RequestIdentity = { + user: RequestUser | null; + virtualApiKeyId: string | null; +}; + // Identity headers forwarded to the upstream inference endpoint when the // requesting user is known. Omitted entirely (not sent empty) when we // couldn't resolve a user for the request. @@ -223,10 +232,12 @@ async function resolveRequestUser( req: Request, resourceId: number | null, orgId: string | null -): Promise { +): Promise { // Public inference: identity comes from Badger via Remote-* only when the // Traefik trust header proves the request passed verify-session (VAK). if (isAiGatewayTrustHeaderValid(req.headers as Record)) { + const virtualApiKeyId = + getRequestHeader(req, "remote-virtual-api-key-id") || null; const userId = getRequestHeader(req, "remote-user-id"); if (userId) { const username = getRequestHeader(req, "remote-user") || userId; @@ -236,32 +247,41 @@ async function resolveRequestUser( const orgRoles = orgId ? await getUserOrgRoles(userId, orgId) : []; return { - userId, - username, - email: email || null, - name: name || null, - role: - role || orgRoles.map((r) => r.roleName).join(", ") || null, - roleIds: orgRoles.map((r) => r.roleId) + user: { + userId, + username, + email: email || null, + name: name || null, + role: + role || + orgRoles.map((r) => r.roleName).join(", ") || + null, + roleIds: orgRoles.map((r) => r.roleId) + }, + virtualApiKeyId }; } - // Trusted request with no associated user (manual key without userId). + // Trusted request with no associated user (manual key without + // userId) - still attribute usage to the virtual API key itself. if (resourceId != null) { - return null; + return { user: null, virtualApiKeyId }; } } // Public inference must come through Badger; do not authorize via app session. if (resourceId != null) { - return null; + return { user: null, virtualApiKeyId: null }; } const sessionToken = req.cookies?.[SESSION_COOKIE_NAME]; if (sessionToken) { const { session, user } = await validateSessionToken(sessionToken); if (session && user) { - return buildRequestUser(user.userId, orgId); + return { + user: await buildRequestUser(user.userId, orgId), + virtualApiKeyId: null + }; } } @@ -269,7 +289,7 @@ async function resolveRequestUser( const ip = req.ip; if (!ip) { - return null; + return { user: null, virtualApiKeyId: null }; } const exitNodeRanges = await getExitNodeRanges(); @@ -277,15 +297,18 @@ async function resolveRequestUser( isIpInCidr(ip, range) ); if (!inExitNodeRange) { - return null; + return { user: null, virtualApiKeyId: null }; } const client = await findClientByIp(ip); if (!client || !client.userId) { - return null; + return { user: null, virtualApiKeyId: null }; } - return buildRequestUser(client.userId, orgId); + return { + user: await buildRequestUser(client.userId, orgId), + virtualApiKeyId: null + }; } function getRequestHeader(req: Request, name: string): string | undefined { @@ -608,6 +631,7 @@ export function recordAiGatewayCompletion(args: { resourceId: number | null; siteResourceId: number | null; requestUserId: string | null; + virtualApiKeyId: string | null; budgets: AiBudget[]; }): void { const { @@ -623,6 +647,7 @@ export function recordAiGatewayCompletion(args: { resourceId, siteResourceId, requestUserId, + virtualApiKeyId, budgets } = args; @@ -668,6 +693,7 @@ export function recordAiGatewayCompletion(args: { resourceId, siteResourceId, userId: requestUserId, + virtualApiKeyId, requestedModel: model ?? "unknown", usage, costUsd: cost?.totalCost ?? null, @@ -699,7 +725,8 @@ export function recordAiGatewayCompletion(args: { orgId, resourceId, siteResourceId, - requestUserId + requestUserId, + virtualApiKeyId }); } @@ -770,7 +797,7 @@ export async function handleAiGatewayProxy( const requestedModel = def.extractModel(req); - const [requestUser, selection] = await Promise.all([ + const [identity, selection] = await Promise.all([ resolveRequestUser(req, resourceId, orgId), selectProvider( capableAttachments, @@ -778,6 +805,7 @@ export async function handleAiGatewayProxy( requestedModel ) ]); + const requestUser = identity.user; if (requestUser) { logger.debug( @@ -836,7 +864,8 @@ export async function handleAiGatewayProxy( resourceId, siteResourceId, requestedModel, - budgets: appliedBudgets + budgets: appliedBudgets, + virtualApiKeyId: identity.virtualApiKeyId } ); } @@ -987,6 +1016,7 @@ export async function handleAiGatewayProxy( resourceId, siteResourceId, requestUserId: requestUser?.userId ?? null, + virtualApiKeyId: identity.virtualApiKeyId, budgets: appliedBudgets }); } diff --git a/server/routers/aiGateway/targetRouting.ts b/server/routers/aiGateway/targetRouting.ts index 9ffbf1d1b..8c4f244aa 100644 --- a/server/routers/aiGateway/targetRouting.ts +++ b/server/routers/aiGateway/targetRouting.ts @@ -179,6 +179,7 @@ export async function proxyAiGatewayToSiteTarget( siteResourceId: number | null; requestedModel: string | undefined; budgets: AiBudget[]; + virtualApiKeyId: string | null; } ): Promise { const providerTargets = await getProviderTargets(provider.providerId); @@ -319,6 +320,7 @@ export async function proxyAiGatewayToSiteTarget( resourceId: ctx.resourceId, siteResourceId: ctx.siteResourceId, requestUserId: requestUser?.userId ?? null, + virtualApiKeyId: ctx.virtualApiKeyId, budgets: ctx.budgets }); } diff --git a/server/routers/auditLogs/aiUsageAnalyticsShared.ts b/server/routers/auditLogs/aiUsageAnalyticsShared.ts index 95a6f825b..ada94a9b9 100644 --- a/server/routers/auditLogs/aiUsageAnalyticsShared.ts +++ b/server/routers/auditLogs/aiUsageAnalyticsShared.ts @@ -58,7 +58,8 @@ export const aiUsageAnalyticsFiltersQuery = z.object({ .transform(Number) .pipe(z.int().positive()) .optional(), - userId: z.string().optional() + userId: z.string().optional(), + virtualApiKeyId: z.string().optional() }); export const aiUsageAnalyticsParams = z.object({ @@ -114,6 +115,9 @@ export function buildAiUsageWhere( ) : undefined, data.userId ? eq(aiUsageRecords.userId, data.userId) : undefined, + data.virtualApiKeyId + ? eq(aiUsageRecords.virtualApiKeyId, data.virtualApiKeyId) + : undefined, roleUserIds ? inArray(aiUsageRecords.userId, roleUserIds) : undefined ); } diff --git a/server/routers/auditLogs/index.ts b/server/routers/auditLogs/index.ts index 12735d650..59969134f 100644 --- a/server/routers/auditLogs/index.ts +++ b/server/routers/auditLogs/index.ts @@ -8,3 +8,4 @@ export * from "./queryAiUsageOverview"; export * from "./queryAiUsageProviders"; export * from "./queryAiUsageResources"; export * from "./queryAiUsageUsersRoles"; +export * from "./queryAiUsageVirtualApiKeys"; diff --git a/server/routers/auditLogs/queryAiSessionLog.ts b/server/routers/auditLogs/queryAiSessionLog.ts index d61bdf5c8..437d74550 100644 --- a/server/routers/auditLogs/queryAiSessionLog.ts +++ b/server/routers/auditLogs/queryAiSessionLog.ts @@ -6,6 +6,7 @@ import { resources, siteResources, users, + virtualApiKeys, db, primaryDb } from "@server/db"; @@ -67,6 +68,7 @@ export const queryAiSessionLogsQuery = z.strictObject({ .pipe(z.int().positive()) .optional(), actor: z.string().optional(), + virtualApiKeyId: z.string().optional(), model: z.string().optional(), isStream: z .union([z.boolean(), z.string()]) @@ -117,7 +119,9 @@ function getWhere(data: Q) { data.providerId ? eq(aiSessionLog.providerId, data.providerId) : undefined, - data.capability ? eq(aiSessionLog.capability, data.capability) : undefined, + data.capability + ? eq(aiSessionLog.capability, data.capability) + : undefined, data.resourceId ? or( eq(aiSessionLog.resourceId, data.resourceId), @@ -125,9 +129,10 @@ function getWhere(data: Q) { ) : undefined, data.actor ? eq(aiSessionLog.userId, data.actor) : undefined, - data.model - ? eq(aiSessionLog.requestedModel, data.model) + data.virtualApiKeyId + ? eq(aiSessionLog.virtualApiKeyId, data.virtualApiKeyId) : undefined, + data.model ? eq(aiSessionLog.requestedModel, data.model) : undefined, data.isStream !== undefined ? eq(aiSessionLog.isStream, data.isStream) : undefined @@ -145,6 +150,7 @@ export function queryAiSession(data: Q) { resourceId: aiSessionLog.resourceId, siteResourceId: aiSessionLog.siteResourceId, userId: aiSessionLog.userId, + virtualApiKeyId: aiSessionLog.virtualApiKeyId, requestedModel: aiSessionLog.requestedModel, isStream: aiSessionLog.isStream, requestBody: aiSessionLog.requestBody, @@ -182,6 +188,14 @@ async function enrichWithDetails( ) ]; + const virtualApiKeyIds = [ + ...new Set( + logs + .map((log) => log.virtualApiKeyId) + .filter((id): id is string => id !== null && id !== undefined) + ) + ]; + const providerMap = new Map< number, { name: string | null; type: string | null } @@ -254,6 +268,28 @@ async function enrichWithDetails( } } + const virtualApiKeyMap = new Map< + string, + { name: string | null; lastChars: string } + >(); + if (virtualApiKeyIds.length > 0) { + const virtualApiKeyDetails = await db + .select({ + virtualApiKeyId: virtualApiKeys.virtualApiKeyId, + name: virtualApiKeys.name, + lastChars: virtualApiKeys.lastChars + }) + .from(virtualApiKeys) + .where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds)); + + for (const k of virtualApiKeyDetails) { + virtualApiKeyMap.set(k.virtualApiKeyId, { + name: k.name, + lastChars: k.lastChars + }); + } + } + const usageMap = new Map< string, { @@ -328,6 +364,12 @@ async function enrichWithDetails( resourceName, resourceNiceId, userEmail: log.userId ? (userMap.get(log.userId) ?? null) : null, + virtualApiKeyName: log.virtualApiKeyId + ? (virtualApiKeyMap.get(log.virtualApiKeyId)?.name ?? null) + : null, + virtualApiKeyLastChars: log.virtualApiKeyId + ? (virtualApiKeyMap.get(log.virtualApiKeyId)?.lastChars ?? null) + : null, usage: usageMap.get(log.sessionId) ?? null }; }); @@ -385,7 +427,8 @@ async function queryUniqueFilterAttributes( uniqueUsers, uniqueResources, uniqueSiteResources, - uniqueModels + uniqueModels, + uniqueVirtualApiKeys ] = await Promise.all([ logsDb .selectDistinct({ id: aiSessionLog.providerId }) @@ -411,6 +454,11 @@ async function queryUniqueFilterAttributes( .selectDistinct({ model: aiSessionLog.requestedModel }) .from(aiSessionLog) .where(baseConditions) + .limit(DISTINCT_LIMIT + 1), + logsDb + .selectDistinct({ id: aiSessionLog.virtualApiKeyId }) + .from(aiSessionLog) + .where(baseConditions) .limit(DISTINCT_LIMIT + 1) ]); @@ -498,10 +546,37 @@ async function queryUniqueFilterAttributes( ]; } + const virtualApiKeyIds = uniqueVirtualApiKeys + .map((row) => row.id) + .filter((id): id is string => id !== null); + + let virtualApiKeyList: Array<{ + id: string; + name: string | null; + lastChars: string | null; + }> = []; + if (virtualApiKeyIds.length > 0) { + const virtualApiKeyDetails = await primaryDb + .select({ + virtualApiKeyId: virtualApiKeys.virtualApiKeyId, + name: virtualApiKeys.name, + lastChars: virtualApiKeys.lastChars + }) + .from(virtualApiKeys) + .where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds)); + + virtualApiKeyList = virtualApiKeyDetails.map((k) => ({ + id: k.virtualApiKeyId, + name: k.name, + lastChars: k.lastChars + })); + } + return { providers: sortNamedFilterOptions(providers), resources: sortNamedFilterOptions(resourcesWithNames), users: userList, + virtualApiKeys: virtualApiKeyList, models: models.sort() }; } diff --git a/server/routers/auditLogs/queryAiUsageFilterOptions.ts b/server/routers/auditLogs/queryAiUsageFilterOptions.ts index 46cd3199e..6d7191187 100644 --- a/server/routers/auditLogs/queryAiUsageFilterOptions.ts +++ b/server/routers/auditLogs/queryAiUsageFilterOptions.ts @@ -6,7 +6,8 @@ import { siteResources, users, roles, - userOrgRoles + userOrgRoles, + virtualApiKeys } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; @@ -43,8 +44,9 @@ const queryAiUsageFilterOptionsParams = z.object({ orgId: z.string() }); -const queryAiUsageFilterOptionsCombined = - queryAiUsageFilterOptionsQuery.merge(queryAiUsageFilterOptionsParams); +const queryAiUsageFilterOptionsCombined = queryAiUsageFilterOptionsQuery.merge( + queryAiUsageFilterOptionsParams +); type Q = z.infer; function sortNamedFilterOptions( @@ -73,7 +75,8 @@ async function query(data: Q) { uniqueModels, uniqueResources, uniqueSiteResources, - uniqueUsers + uniqueUsers, + uniqueVirtualApiKeys ] = await Promise.all([ db .selectDistinct({ id: aiUsageRecords.providerId }) @@ -105,6 +108,13 @@ async function query(data: Q) { .selectDistinct({ userId: aiUsageRecords.userId }) .from(aiUsageRecords) .where(and(baseConditions, not(isNull(aiUsageRecords.userId)))) + .limit(DISTINCT_LIMIT + 1), + db + .selectDistinct({ id: aiUsageRecords.virtualApiKeyId }) + .from(aiUsageRecords) + .where( + and(baseConditions, not(isNull(aiUsageRecords.virtualApiKeyId))) + ) .limit(DISTINCT_LIMIT + 1) ]); @@ -120,11 +130,17 @@ async function query(data: Q) { let providers: Array<{ id: number; name: string | null }> = []; if (providerIds.length > 0) { const providerDetails = await db - .select({ providerId: aiProviders.providerId, name: aiProviders.name }) + .select({ + providerId: aiProviders.providerId, + name: aiProviders.name + }) .from(aiProviders) .where(inArray(aiProviders.providerId, providerIds)); - providers = providerDetails.map((p) => ({ id: p.providerId, name: p.name })); + providers = providerDetails.map((p) => ({ + id: p.providerId, + name: p.name + })); } const resourceIds = uniqueResources @@ -155,7 +171,10 @@ async function query(data: Q) { .where(inArray(siteResources.siteResourceId, siteResourceIds)); resourcesWithNames = resourcesWithNames.concat( - siteResourceDetails.map((r) => ({ id: r.siteResourceId, name: r.name })) + siteResourceDetails.map((r) => ({ + id: r.siteResourceId, + name: r.name + })) ); } @@ -190,11 +209,37 @@ async function query(data: Q) { roleList = [...roleMap.entries()].map(([id, name]) => ({ id, name })); } + const virtualApiKeyIds = uniqueVirtualApiKeys + .map((row) => row.id) + .filter((id): id is string => id !== null); + + let virtualApiKeyList: Array<{ + id: string; + name: string | null; + lastChars: string; + }> = []; + if (virtualApiKeyIds.length > 0) { + const virtualApiKeyDetails = await db + .select({ + virtualApiKeyId: virtualApiKeys.virtualApiKeyId, + name: virtualApiKeys.name, + lastChars: virtualApiKeys.lastChars + }) + .from(virtualApiKeys) + .where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds)); + virtualApiKeyList = virtualApiKeyDetails.map((k) => ({ + id: k.virtualApiKeyId, + name: k.name, + lastChars: k.lastChars + })); + } + return { providers: sortNamedFilterOptions(providers), resources: sortNamedFilterOptions(resourcesWithNames), roles: sortNamedFilterOptions(roleList), users: userList, + virtualApiKeys: virtualApiKeyList, models }; } @@ -227,7 +272,9 @@ registry.registerPath({ } }); -export type QueryAiUsageFilterOptionsResponse = Awaited>; +export type QueryAiUsageFilterOptionsResponse = Awaited< + ReturnType +>; export async function queryAiUsageFilterOptions( req: Request, @@ -238,7 +285,10 @@ export async function queryAiUsageFilterOptions( const parsedQuery = queryAiUsageFilterOptionsQuery.safeParse(req.query); if (!parsedQuery.success) { return next( - createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error)) + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) ); } @@ -247,7 +297,10 @@ export async function queryAiUsageFilterOptions( ); if (!parsedParams.success) { return next( - createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error)) + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) ); } diff --git a/server/routers/auditLogs/queryAiUsageVirtualApiKeys.ts b/server/routers/auditLogs/queryAiUsageVirtualApiKeys.ts new file mode 100644 index 000000000..9d1b52ad2 --- /dev/null +++ b/server/routers/auditLogs/queryAiUsageVirtualApiKeys.ts @@ -0,0 +1,222 @@ +import { db, aiUsageRecords, virtualApiKeys } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { count, desc, inArray, sql } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { + aiUsageAnalyticsFiltersQuery, + aiUsageAnalyticsParams, + buildAiUsageWhere, + resolveRoleUserIds, + dayBucketExpr, + pickTopNKeys, + bucketTopNPerDay, + DISTINCT_LIMIT, + type AiUsageAnalyticsQuery +} from "./aiUsageAnalyticsShared"; + +type Q = AiUsageAnalyticsQuery; + +const UNKNOWN_VIRTUAL_API_KEY_KEY = "unknown"; + +async function query(data: Q) { + const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId); + const baseConditions = buildAiUsageWhere(data, roleUserIds); + const dayExpr = dayBucketExpr(); + + const virtualApiKeyByDay = await db + .select({ + day: dayExpr.as("day"), + virtualApiKeyId: aiUsageRecords.virtualApiKeyId, + cost: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`, + tokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr, aiUsageRecords.virtualApiKeyId) + .orderBy(dayExpr); + + const virtualApiKeyTotalsRaw = await db + .select({ + virtualApiKeyId: aiUsageRecords.virtualApiKeyId, + requests: count(), + totalTokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`, + costUsd: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(aiUsageRecords.virtualApiKeyId) + .orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`)) + .limit(DISTINCT_LIMIT + 1); + + if (virtualApiKeyTotalsRaw.length > DISTINCT_LIMIT) { + throw createHttpError( + HttpCode.BAD_REQUEST, + "Too many distinct virtual API keys. Please narrow your query." + ); + } + + const virtualApiKeyIds = virtualApiKeyTotalsRaw + .map((r) => r.virtualApiKeyId) + .filter((id): id is string => id !== null); + + const detailsMap = new Map< + string, + { name: string | null; lastChars: string; kind: "user" | "manual" } + >(); + if (virtualApiKeyIds.length > 0) { + const details = await db + .select({ + virtualApiKeyId: virtualApiKeys.virtualApiKeyId, + name: virtualApiKeys.name, + lastChars: virtualApiKeys.lastChars, + kind: virtualApiKeys.kind + }) + .from(virtualApiKeys) + .where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds)); + for (const k of details) { + detailsMap.set(k.virtualApiKeyId, { + name: k.name, + lastChars: k.lastChars, + kind: k.kind + }); + } + } + + const topVirtualApiKeys = virtualApiKeyTotalsRaw.map((r) => { + const details = r.virtualApiKeyId + ? detailsMap.get(r.virtualApiKeyId) + : undefined; + return { + virtualApiKeyId: r.virtualApiKeyId, + name: details?.name ?? null, + lastChars: details?.lastChars ?? null, + kind: details?.kind ?? null, + requests: r.requests, + totalTokens: r.totalTokens, + costUsd: r.costUsd + }; + }); + + const virtualApiKeyCostTotals = new Map(); + const virtualApiKeyTokenTotals = new Map(); + for (const row of virtualApiKeyByDay) { + const key = row.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY; + virtualApiKeyCostTotals.set( + key, + (virtualApiKeyCostTotals.get(key) ?? 0) + row.cost + ); + virtualApiKeyTokenTotals.set( + key, + (virtualApiKeyTokenTotals.get(key) ?? 0) + row.tokens + ); + } + const topVirtualApiKeysByCost = pickTopNKeys(virtualApiKeyCostTotals); + const topVirtualApiKeysByTokens = pickTopNKeys(virtualApiKeyTokenTotals); + + const virtualApiKeyCostPerDay = bucketTopNPerDay( + virtualApiKeyByDay.map((r) => ({ + day: r.day, + key: r.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY, + value: r.cost + })), + topVirtualApiKeysByCost + ); + const virtualApiKeyTokensPerDay = bucketTopNPerDay( + virtualApiKeyByDay.map((r) => ({ + day: r.day, + key: r.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY, + value: r.tokens + })), + topVirtualApiKeysByTokens + ); + + return { + topVirtualApiKeys, + virtualApiKeyCostPerDay, + virtualApiKeyTokensPerDay + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/ai/usage/virtual-api-keys", + description: + "Query the AI usage analytics virtual API key breakdown for an organization", + tags: [OpenAPITags.Logs], + request: { + query: aiUsageAnalyticsFiltersQuery, + params: aiUsageAnalyticsParams + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export type QueryAiUsageVirtualApiKeysResponse = Awaited< + ReturnType +>; + +export async function queryAiUsageVirtualApiKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = aiUsageAnalyticsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = await query({ ...parsedQuery.data, ...parsedParams.data }); + + return response(res, { + data, + success: true, + error: false, + message: + "AI usage virtual API key breakdown retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index b63acbf30..dc8d4015f 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -110,6 +110,9 @@ export type QueryAiSessionLogResponse = { resourceType: "public" | "site" | null; userId: string | null; userEmail: string | null; + virtualApiKeyId: string | null; + virtualApiKeyName: string | null; + virtualApiKeyLastChars: string | null; requestedModel: string | null; isStream: boolean; requestBody: string | null; @@ -148,6 +151,11 @@ export type QueryAiSessionLogResponse = { id: string; email: string | null; }[]; + virtualApiKeys: { + id: string; + name: string | null; + lastChars: string | null; + }[]; models: string[]; }; }; diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index ee69ce163..525128fc8 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -96,6 +96,10 @@ export type VerifyUserResponse = { userData?: BasicUserData; pangolinVersion?: string; dontStripSession?: boolean; + // Set independently of userData so a manual virtual API key with no + // associated user still gets attributed to the key that authenticated + // the request (see the mode === "inference" branch below). + virtualApiKeyId?: string; }; export async function verifyResourceSession( @@ -401,7 +405,12 @@ export async function verifyResourceSession( parsedBody.data ); - return allowed(res, vakUserData, dontStripSession); + return allowed( + res, + vakUserData, + dontStripSession, + key.virtualApiKeyId + ); } } @@ -987,16 +996,20 @@ async function notAllowed( function allowed( res: Response, userData?: BasicUserData, - dontStripSession?: boolean + dontStripSession?: boolean, + virtualApiKeyId?: string ) { const baseData = userData !== undefined && userData !== null ? { valid: true, ...userData, pangolinVersion: APP_VERSION } : { valid: true, pangolinVersion: APP_VERSION }; + const withVirtualApiKey = virtualApiKeyId + ? { ...baseData, virtualApiKeyId } + : baseData; const data = { data: dontStripSession - ? { ...baseData, dontStripSession: true } - : baseData, + ? { ...withVirtualApiKey, dontStripSession: true } + : withVirtualApiKey, success: true, error: false, message: "Access allowed", diff --git a/server/routers/external.ts b/server/routers/external.ts index b50de6bea..6f44c1efe 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1537,6 +1537,13 @@ authenticated.get( logs.queryAiUsageUsersRoles ); +authenticated.get( + "/org/:orgId/logs/ai/usage/virtual-api-keys", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageVirtualApiKeys +); + authenticated.get( "/org/:orgId/blueprints", verifyOrgAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 02e1ab987..cc283990e 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -1580,6 +1580,13 @@ authenticated.get( logs.queryAiUsageUsersRoles ); +authenticated.get( + "/org/:orgId/logs/ai/usage/virtual-api-keys", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageVirtualApiKeys +); + authenticated.get( "/org/:orgId/logs/analytics", verifyApiKeyOrgAccess, diff --git a/src/app/[orgId]/settings/logs/ai/page.tsx b/src/app/[orgId]/settings/logs/ai/page.tsx index 5d51c118b..15c573337 100644 --- a/src/app/[orgId]/settings/logs/ai/page.tsx +++ b/src/app/[orgId]/settings/logs/ai/page.tsx @@ -12,6 +12,7 @@ import { useTranslations } from "next-intl"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref"; import { logQueries } from "@app/lib/queries"; +import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat"; import { ColumnDef } from "@tanstack/react-table"; import { useQuery } from "@tanstack/react-query"; import axios from "axios"; @@ -50,6 +51,7 @@ export default function AiSessionLogsPage() { capability?: string; resourceId?: string; actor?: string; + virtualApiKeyId?: string; model?: string; isStream?: string; }>({ @@ -57,6 +59,7 @@ export default function AiSessionLogsPage() { capability: searchParams.get("capability") || undefined, resourceId: searchParams.get("resourceId") || undefined, actor: searchParams.get("actor") || undefined, + virtualApiKeyId: searchParams.get("virtualApiKeyId") || undefined, model: searchParams.get("model") || undefined, isStream: searchParams.get("isStream") || undefined }); @@ -135,6 +138,7 @@ export default function AiSessionLogsPage() { providers: [], resources: [], users: [], + virtualApiKeys: [], models: [] }; @@ -358,7 +362,9 @@ export default function AiSessionLogsPage() { }, cell: ({ row }) => { if (!row.original.resourceNiceId) { - return -; + return ( + - + ); } return ( ); } + }, + { + accessorKey: "virtualApiKeyId", + header: ({ column }) => { + return ( +
+ ({ + value: key.id, + label: + key.name ?? + (key.lastChars + ? formatVirtualApiKeyPreview( + key.id, + key.lastChars + ) + : key.id) + }) + )} + selectedValue={filters.virtualApiKeyId} + onValueChange={(value) => + handleFilterChange("virtualApiKeyId", value) + } + label={t("virtualApiKey")} + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + /> +
+ ); + }, + cell: ({ row }) => { + if (!row.original.virtualApiKeyId) { + return ( + - + ); + } + return ( +
+ + {row.original.virtualApiKeyName ?? + t("aiUsageUnnamedVirtualApiKey")} + + {row.original.virtualApiKeyLastChars && ( + + {formatVirtualApiKeyPreview( + row.original.virtualApiKeyId, + row.original.virtualApiKeyLastChars + )} + + )} +
+ ); + } } ]; const renderExpandedRow = (row: any) => { return (
-
+
{t("aiSessionId")}

@@ -490,6 +550,30 @@ export default function AiSessionLogsPage() { : "N/A"}

+
+ {t("virtualApiKey")} +

+ {row.virtualApiKeyId ? ( + <> + {row.virtualApiKeyName ?? + t("aiUsageUnnamedVirtualApiKey")} + {row.virtualApiKeyLastChars && ( + <> + {" "} + ( + {formatVirtualApiKeyPreview( + row.virtualApiKeyId, + row.virtualApiKeyLastChars + )} + ) + + )} + + ) : ( + t("noVirtualApiKey") + )} +

+
{row.usage && (
@@ -599,15 +683,25 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] { ]; const actors = ["alice@example.com", "bob@example.com", null]; const models = ["gpt-4o", "claude-sonnet-5", "gemini-2.5-pro"]; + const virtualApiKeysSample = [ + { id: "vak00001", name: "CI pipeline", lastChars: "ab12" }, + { id: "vak00002", name: null, lastChars: "cd34" }, + null + ]; const now = Date.now(); const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000; return Array.from({ length: 10 }, (_, i) => { - const provider = providers[Math.floor(Math.random() * providers.length)]; + const provider = + providers[Math.floor(Math.random() * providers.length)]; const resource = resourcesSample[Math.floor(Math.random() * resourcesSample.length)]; const actor = actors[Math.floor(Math.random() * actors.length)]; + const virtualApiKey = + virtualApiKeysSample[ + Math.floor(Math.random() * virtualApiKeysSample.length) + ]; return { id: i, @@ -625,6 +719,9 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] { resourceType: "public", userId: actor ? `user-${i}` : null, userEmail: actor, + virtualApiKeyId: virtualApiKey?.id ?? null, + virtualApiKeyName: virtualApiKey?.name ?? null, + virtualApiKeyLastChars: virtualApiKey?.lastChars ?? null, requestedModel: models[Math.floor(Math.random() * models.length)], isStream: Math.random() > 0.5, requestBody: null, diff --git a/src/components/AiUsageAnalyticsData.tsx b/src/components/AiUsageAnalyticsData.tsx index c19ca2346..a5d3db2b8 100644 --- a/src/components/AiUsageAnalyticsData.tsx +++ b/src/components/AiUsageAnalyticsData.tsx @@ -29,6 +29,8 @@ import { ProvidersTab } from "./ai-usage-analytics/ProvidersTab"; import { ResourcesTab } from "./ai-usage-analytics/ResourcesTab"; import { RolesTab } from "./ai-usage-analytics/RolesTab"; import { UsersTab } from "./ai-usage-analytics/UsersTab"; +import { VirtualApiKeysTab } from "./ai-usage-analytics/VirtualApiKeysTab"; +import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat"; export type AiUsageAnalyticsDataProps = { orgId: string; @@ -140,13 +142,20 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) { value: u.id, label: u.email ?? u.id })); + const virtualApiKeyOptions = (filterOptions?.virtualApiKeys ?? []).map( + (k) => ({ + value: k.id, + label: k.name ?? formatVirtualApiKeyPreview(k.id, k.lastChars) + }) + ); const tabs: TabItem[] = [ { title: t("aiUsageTabOverview"), href: "#" }, { title: t("aiUsageTabProviders"), href: "#" }, { title: t("aiUsageTabResources"), href: "#" }, { title: t("aiUsageRolesTab"), href: "#" }, - { title: t("aiUsageUsersTab"), href: "#" } + { title: t("aiUsageUsersTab"), href: "#" }, + { title: t("aiUsageVirtualApiKeysTab"), href: "#" } ]; return ( @@ -180,7 +189,9 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) { value={filters.providerId?.toString()} options={providerOptions} placeholder={t("aiUsageFilterAllProviders")} - onValueChange={(v) => setFilter("providerId", v)} + onValueChange={(v) => + setFilter("providerId", v) + } /> setFilter("resourceId", v)} + onValueChange={(v) => + setFilter("resourceId", v) + } /> setFilter("userId", v)} /> + + setFilter("virtualApiKeyId", v) + } + /> {!isEmptySearchParams && (
); diff --git a/src/components/ai-usage-analytics/VirtualApiKeysTab.tsx b/src/components/ai-usage-analytics/VirtualApiKeysTab.tsx new file mode 100644 index 000000000..9ed8edb3d --- /dev/null +++ b/src/components/ai-usage-analytics/VirtualApiKeysTab.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { aiUsageAnalyticsQueries } from "@app/lib/queries"; +import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; +import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat"; +import { useTranslations } from "next-intl"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { ToggleableTrendChart } from "./ToggleableTrendChart"; +import { TopEntitiesList, type TopEntity } from "./TopEntitiesList"; +import { buildSeriesFromData, formatCost } from "./shared"; + +type VirtualApiKeysTabProps = { + orgId: string; + filters: AiUsageAnalyticsFilters; +}; + +const UNKNOWN_VIRTUAL_API_KEY_KEY = "unknown"; + +export function VirtualApiKeysTab(props: VirtualApiKeysTabProps) { + const t = useTranslations(); + const { data, isLoading } = useQuery( + aiUsageAnalyticsQueries.virtualApiKeys({ + orgId: props.orgId, + filters: props.filters + }) + ); + + const labelByKey = new Map(); + for (const k of data?.topVirtualApiKeys ?? []) { + if (k.virtualApiKeyId) { + labelByKey.set(k.virtualApiKeyId, k.name ?? k.virtualApiKeyId); + } + } + const virtualApiKeyLabelFor = (key: string) => + key === UNKNOWN_VIRTUAL_API_KEY_KEY + ? t("aiUsageUnknownVirtualApiKey") + : (labelByKey.get(key) ?? key); + + const virtualApiKeyCostSeries = buildSeriesFromData( + data?.virtualApiKeyCostPerDay ?? [], + virtualApiKeyLabelFor, + t("aiUsageOther") + ); + const virtualApiKeyTokensSeries = buildSeriesFromData( + data?.virtualApiKeyTokensPerDay ?? [], + virtualApiKeyLabelFor, + t("aiUsageOther") + ); + + const topVirtualApiKeys: TopEntity[] = (data?.topVirtualApiKeys ?? []).map( + (k) => ({ + key: k.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY, + label: k.virtualApiKeyId + ? (k.name ?? t("aiUsageUnnamedVirtualApiKey")) + : t("aiUsageUnknownVirtualApiKey"), + sublabel: + k.virtualApiKeyId && k.lastChars + ? formatVirtualApiKeyPreview(k.virtualApiKeyId, k.lastChars) + : undefined, + requests: k.requests, + totalTokens: k.totalTokens, + costUsd: k.costUsd + }) + ); + + return ( +
+ + +

+ {t("aiUsageTopVirtualApiKeys")} +

+
+ + + +
+
+ + + formatCost(v)} + /> + + + + + + + +
+
+ ); +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index bf7f69b8f..56a41cee8 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -12,7 +12,8 @@ import type { QueryAiUsageOverviewResponse, QueryAiUsageProvidersResponse, QueryAiUsageResourcesResponse, - QueryAiUsageUsersRolesResponse + QueryAiUsageUsersRolesResponse, + QueryAiUsageVirtualApiKeysResponse } from "@server/routers/auditLogs"; import type { QueryAccessAuditLogResponse, @@ -954,7 +955,8 @@ export const aiUsageAnalyticsFiltersSchema = z.object({ model: z.string().optional().catch(undefined), resourceId: z.coerce.number().optional().catch(undefined), roleId: z.coerce.number().optional().catch(undefined), - userId: z.string().optional().catch(undefined) + userId: z.string().optional().catch(undefined), + virtualApiKeyId: z.string().optional().catch(undefined) }); export type AiUsageAnalyticsFilters = z.output< @@ -1086,6 +1088,7 @@ export const aiSessionLogsFiltersSchema = z.object({ capability: z.string().optional().catch(undefined), resourceId: z.string().optional().catch(undefined), actor: z.string().optional().catch(undefined), + virtualApiKeyId: z.string().optional().catch(undefined), model: z.string().optional().catch(undefined), isStream: z.string().optional().catch(undefined) }); @@ -1284,7 +1287,12 @@ export const aiUsageAnalyticsQueries = { filters: Pick; }) => queryOptions({ - queryKey: ["AI_USAGE_ANALYTICS", orgId, "FILTERS", filters] as const, + queryKey: [ + "AI_USAGE_ANALYTICS", + orgId, + "FILTERS", + filters + ] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse @@ -1304,7 +1312,12 @@ export const aiUsageAnalyticsQueries = { filters: AiUsageAnalyticsFilters; }) => queryOptions({ - queryKey: ["AI_USAGE_ANALYTICS", orgId, "OVERVIEW", filters] as const, + queryKey: [ + "AI_USAGE_ANALYTICS", + orgId, + "OVERVIEW", + filters + ] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse @@ -1330,7 +1343,12 @@ export const aiUsageAnalyticsQueries = { filters: AiUsageAnalyticsFilters; }) => queryOptions({ - queryKey: ["AI_USAGE_ANALYTICS", orgId, "PROVIDERS", filters] as const, + queryKey: [ + "AI_USAGE_ANALYTICS", + orgId, + "PROVIDERS", + filters + ] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse @@ -1356,7 +1374,12 @@ export const aiUsageAnalyticsQueries = { filters: AiUsageAnalyticsFilters; }) => queryOptions({ - queryKey: ["AI_USAGE_ANALYTICS", orgId, "RESOURCES", filters] as const, + queryKey: [ + "AI_USAGE_ANALYTICS", + orgId, + "RESOURCES", + filters + ] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse @@ -1403,6 +1426,37 @@ export const aiUsageAnalyticsQueries = { } return false; } + }), + + virtualApiKeys: ({ + orgId, + filters + }: { + orgId: string; + filters: AiUsageAnalyticsFilters; + }) => + queryOptions({ + queryKey: [ + "AI_USAGE_ANALYTICS", + orgId, + "VIRTUAL_API_KEYS", + filters + ] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/logs/ai/usage/virtual-api-keys`, { + params: filters, + signal + }); + return res.data.data; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; + } }) }; From 115c3cbf077bb8be67c436bdaef08441e0c6aa57 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 12 Aug 2026 11:32:22 -0400 Subject: [PATCH 313/494] show sso page in browser for gateway resource --- messages/en-US.json | 10 +++--- server/routers/badger/verifySession.ts | 14 +++------ .../virtualApiKey/listMyVirtualApiKeys.ts | 8 +++-- server/routers/virtualApiKey/types.ts | 1 + .../resource/[resourceGuid]/keys/page.tsx | 10 ++---- src/app/auth/resource/[resourceGuid]/page.tsx | 22 ++++++------- src/components/UserVirtualApiKeys.tsx | 31 +++++++------------ .../LauncherInferenceApiKeysSection.tsx | 7 ++++- 8 files changed, 45 insertions(+), 58 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 5da27a834..abf67bb7a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1699,16 +1699,16 @@ "virtualApiKeysFilterUnassigned": "Unassigned", "myVirtualApiKeysTitle": "Your API Keys", "myVirtualApiKeysDescription": "View your identity key and any virtual API keys attributed to you in this organization", - "myVirtualApiKeysResourceTitle": "Your API Keys for This Resource", - "myVirtualApiKeysResourceDescription": "View your identity key and virtual API keys attributed to you that can access this resource", + "myVirtualApiKeysResourceTitle": "Your API Keys for {resourceName}", + "myVirtualApiKeysResourceDescription": "View your identity key and virtual API keys attributed to you that can access {resourceName}", "myVirtualApiKeysIdentityTitle": "Identity Key", "myVirtualApiKeysIdentityHeadline": "Your Personal API Key", "myVirtualApiKeysIdentityDescription": "Your personal key for this organization. It is unique to your account and used to identify you when calling AI Gateway resources.", - "myVirtualApiKeysIdentityResourceHeadline": "Your Personal API Key for This Resource", - "myVirtualApiKeysIdentityResourceDescription": "Your personal key for this organization. Use it to call this AI Gateway resource.", + "myVirtualApiKeysIdentityResourceHeadline": "Your Personal API Key for {resourceName}", + "myVirtualApiKeysIdentityResourceDescription": "Your personal key for this organization. Use it to call {resourceName}.", "myVirtualApiKeysManualTitle": "Attributed Keys", "myVirtualApiKeysManualDescription": "Manual virtual API keys an admin associated with your account", - "myVirtualApiKeysManualResourceDescription": "Manual virtual API keys associated with your account that can access this resource", + "myVirtualApiKeysManualResourceDescription": "Manual virtual API keys associated with your account that can access {resourceName}", "myVirtualApiKeysManualEmpty": "No attributed keys yet", "myVirtualApiKeysKindUser": "Identity", "myVirtualApiKeysKindManual": "Manual", diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 0b7cbeab0..f5b6a25b9 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -329,16 +329,10 @@ export async function verifyResourceSession( // Only offer a browser redirect to clients that can actually follow one and log in // (an interactive browser). Non-browser clients (curl, scripts, bots, etc.) just get // an unauthorized response from Badger instead of a login redirect URL. - // Inference browsers go to the dashboard keys page (not back to the inference host) - // so a valid session cannot create a redirect loop. const redirectPath = clientIsBrowser - ? mode === "inference" - ? `/${resource.orgId}/resource/${encodeURIComponent( - resource.resourceGuid - )}/keys` - : `/auth/resource/${encodeURIComponent( - resource.resourceGuid - )}?redirect=${encodeURIComponent(originalRequestURL)}` + ? `/auth/resource/${encodeURIComponent( + resource.resourceGuid + )}?redirect=${encodeURIComponent(originalRequestURL)}` : undefined; // Virtual API keys for public inference resources (provider-style auth headers). @@ -414,7 +408,7 @@ export async function verifyResourceSession( parsedBody.data ); - return notAllowed(res, redirectPath); + return notAllowed(res, redirectPath, resource.orgId); } // check for access token in headers diff --git a/server/routers/virtualApiKey/listMyVirtualApiKeys.ts b/server/routers/virtualApiKey/listMyVirtualApiKeys.ts index 244377ef1..5177a975a 100644 --- a/server/routers/virtualApiKey/listMyVirtualApiKeys.ts +++ b/server/routers/virtualApiKey/listMyVirtualApiKeys.ts @@ -123,10 +123,12 @@ export async function listMyVirtualApiKeys( } let resourceId: number | undefined; + let resourceName: string | undefined; if (resourceGuid) { const [resource] = await db .select({ - resourceId: resources.resourceId + resourceId: resources.resourceId, + name: resources.name }) .from(resources) .where( @@ -147,6 +149,7 @@ export async function listMyVirtualApiKeys( } resourceId = resource.resourceId; + resourceName = resource.name; } const { key: userKeyRow } = await getOrCreateUserVirtualApiKey({ @@ -203,7 +206,8 @@ export async function listMyVirtualApiKeys( userKey: toKeyWithResources(userKeyRow, resourceIdsByKey), manualKeys: manualRows.map((row) => toKeyWithResources(row, resourceIdsByKey) - ) + ), + ...(resourceName !== undefined ? { resourceName } : {}) }, success: true, error: false, diff --git a/server/routers/virtualApiKey/types.ts b/server/routers/virtualApiKey/types.ts index 73e475632..91bf17c0a 100644 --- a/server/routers/virtualApiKey/types.ts +++ b/server/routers/virtualApiKey/types.ts @@ -22,6 +22,7 @@ export type CreateOrEditVirtualApiKeyResponse = { export type ListMyVirtualApiKeysResponse = { userKey: VirtualApiKeyWithResources; manualKeys: VirtualApiKeyWithResources[]; + resourceName?: string | null; }; export type GetMyVirtualApiKeyResponse = { diff --git a/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx b/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx index 98d4b6582..dd673ac85 100644 --- a/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx +++ b/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx @@ -18,7 +18,7 @@ import { cache } from "react"; export async function generateMetadata(): Promise { const t = await getTranslations(); return { - title: t("myVirtualApiKeysResourceTitle") + title: t("myVirtualApiKeysTitle") }; } @@ -42,7 +42,7 @@ export default async function ResourceKeysPage(props: ResourceKeysPageProps) { if (!user) { redirect( - `/auth/login?redirect=/${orgId}/resource/${resourceGuid}/keys` + `/auth/resource/${encodeURIComponent(resourceGuid)}?redirect=${encodeURIComponent(`/${orgId}/resource/${resourceGuid}/keys`)}` ); } @@ -113,11 +113,7 @@ export default async function ResourceKeysPage(props: ResourceKeysPageProps) { launcherMode showViewAsAdmin={isAdminOrOwner} > - + ); diff --git a/src/app/auth/resource/[resourceGuid]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx index 4376bebc2..3c4fd2691 100644 --- a/src/app/auth/resource/[resourceGuid]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -163,17 +163,11 @@ export default async function ResourceAuthPage(props: { if (user && !user.emailVerified && env.flags.emailVerificationRequired) { redirect( `/auth/verify-email?redirect=${encodeURIComponent( - isInference - ? keysPath - : `/auth/resource/${authInfo.resourceGuid}` + `/auth/resource/${authInfo.resourceGuid}` )}` ); } - if (isInference && !user) { - redirect(`/auth/login?redirect=${encodeURIComponent(keysPath)}`); - } - const cookie = await authCookieHeader(); // Check org policy compliance before proceeding @@ -192,9 +186,7 @@ export default async function ResourceAuthPage(props: { // If user is not compliant with org policies, show policy requirements if (orgPolicyCheck && !orgPolicyCheck.allowed && orgPolicyCheck.policies) { - const resourceAuthPageUrl = isInference - ? keysPath - : `/auth/resource/${authInfo.resourceGuid}${redirectUrl !== authInfo.url ? `?redirect=${encodeURIComponent(redirectUrl)}` : ""}`; + const resourceAuthPageUrl = `/auth/resource/${authInfo.resourceGuid}${redirectUrl !== authInfo.url ? `?redirect=${encodeURIComponent(redirectUrl)}` : ""}`; return (
); @@ -334,7 +330,7 @@ export default async function ResourceAuthPage(props: { name: authInfo.resourceName, id: authInfo.resourceId }} - redirect={redirectUrl} + redirect={postAuthRedirect} idps={loginIdps} orgId={build === "saas" ? authInfo.orgId : undefined} branding={ diff --git a/src/components/UserVirtualApiKeys.tsx b/src/components/UserVirtualApiKeys.tsx index d90241d0b..578f007b7 100644 --- a/src/components/UserVirtualApiKeys.tsx +++ b/src/components/UserVirtualApiKeys.tsx @@ -4,11 +4,9 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; import { AxiosResponse } from "axios"; import moment from "moment"; -import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import CopyTextBox from "@app/components/CopyTextBox"; import CopyToClipboard from "@app/components/CopyToClipboard"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { SettingsContainer, SettingsFormCell, @@ -34,7 +32,6 @@ import { type UserVirtualApiKeysProps = { orgId: string; - resourceGuid?: string; initialData: ListMyVirtualApiKeysResponse; }; @@ -131,12 +128,12 @@ function IdentityKeyCenterpiece({ orgId, virtualApiKeyId, lastChars, - resourceGuid + resourceName }: { orgId: string; virtualApiKeyId: string; lastChars: string; - resourceGuid?: string; + resourceName?: string | null; }) { const t = useTranslations(); const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars); @@ -145,11 +142,11 @@ function IdentityKeyCenterpiece({ virtualApiKeyId ); const displayValue = credential ?? preview; - const headline = resourceGuid - ? t("myVirtualApiKeysIdentityResourceHeadline") + const headline = resourceName + ? t("myVirtualApiKeysIdentityResourceHeadline", { resourceName }) : t("myVirtualApiKeysIdentityHeadline"); - const description = resourceGuid - ? t("myVirtualApiKeysIdentityResourceDescription") + const description = resourceName + ? t("myVirtualApiKeysIdentityResourceDescription", { resourceName }) : t("myVirtualApiKeysIdentityDescription"); return ( @@ -220,17 +217,10 @@ function ManualKeyRow({ export default function UserVirtualApiKeys({ orgId, - resourceGuid, initialData }: UserVirtualApiKeysProps) { const t = useTranslations(); - - const title = resourceGuid - ? t("myVirtualApiKeysResourceTitle") - : t("myVirtualApiKeysTitle"); - const description = resourceGuid - ? t("myVirtualApiKeysResourceDescription") - : t("myVirtualApiKeysDescription"); + const resourceName = initialData.resourceName; return ( <> @@ -239,7 +229,7 @@ export default function UserVirtualApiKeys({ orgId={orgId} virtualApiKeyId={initialData.userKey.virtualApiKeyId} lastChars={initialData.userKey.lastChars} - resourceGuid={resourceGuid} + resourceName={resourceName} /> {initialData.manualKeys.length > 0 ? ( @@ -249,9 +239,10 @@ export default function UserVirtualApiKeys({ {t("myVirtualApiKeysManualTitle")} - {resourceGuid + {resourceName ? t( - "myVirtualApiKeysManualResourceDescription" + "myVirtualApiKeysManualResourceDescription", + { resourceName } ) : t("myVirtualApiKeysManualDescription")} diff --git a/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx b/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx index 2434ed575..9624a3a43 100644 --- a/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx +++ b/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx @@ -202,7 +202,12 @@ export function LauncherInferenceApiKeysSection({ {t( - "myVirtualApiKeysManualResourceDescription" + "myVirtualApiKeysManualResourceDescription", + { + resourceName: + data.resourceName ?? + t("resource") + } )} From 93cba1d098e55bdf719cd7b61a8aaf4fb1535870 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 12 Aug 2026 12:34:25 -0400 Subject: [PATCH 314/494] support sending capability specific error codes --- server/lib/aiCapabilities.ts | 112 +++++++++++++++++++++++-- server/lib/aiGatewayAuthError.ts | 27 ++++++ server/routers/aiGateway/pipeline.ts | 16 ++-- server/routers/badger/verifySession.ts | 37 +++++++- 4 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 server/lib/aiGatewayAuthError.ts diff --git a/server/lib/aiCapabilities.ts b/server/lib/aiCapabilities.ts index 268acd36e..c9f78f10e 100644 --- a/server/lib/aiCapabilities.ts +++ b/server/lib/aiCapabilities.ts @@ -18,8 +18,41 @@ export type AiCapabilityDefinition = { model: string ) => string; isStreaming: (req: Request, contentType: string) => boolean; + /** Protocol-shaped body returned when gateway auth fails for this capability. */ + authErrorBody: Record; }; +const AUTH_MESSAGE = "Invalid API key provided."; + +export const OPENAI_AUTH_ERROR_BODY = { + error: { + message: AUTH_MESSAGE, + type: "authentication_error", + param: null, + code: "invalid_api_key" + } +} as const; + +const ANTHROPIC_AUTH_ERROR_BODY = { + type: "error", + error: { + type: "authentication_error", + message: AUTH_MESSAGE + } +} as const; + +const GOOGLE_AUTH_ERROR_BODY = { + error: { + code: 401, + message: AUTH_MESSAGE, + status: "UNAUTHENTICATED" + } +} as const; + +const BEDROCK_AUTH_ERROR_BODY = { + message: "The security token included in the request is invalid." +} as const; + function bodyModel(req: Request): string | undefined { return typeof req.body?.model === "string" ? req.body.model : undefined; } @@ -111,7 +144,8 @@ export const AI_CAPABILITY_DEFS: Record = extractModel: bodyModel, resolveUpstreamUrl: (base, req) => joinUpstreamUrl(base, pathFromRequest(req)), - isStreaming: isBodyOrSseStreaming + isStreaming: isBodyOrSseStreaming, + authErrorBody: OPENAI_AUTH_ERROR_BODY }, openai_responses: { id: "openai_responses", @@ -119,7 +153,8 @@ export const AI_CAPABILITY_DEFS: Record = extractModel: bodyModel, resolveUpstreamUrl: (base, req) => joinUpstreamUrl(base, pathFromRequest(req)), - isStreaming: isBodyOrSseStreaming + isStreaming: isBodyOrSseStreaming, + authErrorBody: OPENAI_AUTH_ERROR_BODY }, anthropic_messages: { id: "anthropic_messages", @@ -127,7 +162,8 @@ export const AI_CAPABILITY_DEFS: Record = extractModel: bodyModel, resolveUpstreamUrl: (base, req) => joinUpstreamUrl(base, pathFromRequest(req)), - isStreaming: isBodyOrSseStreaming + isStreaming: isBodyOrSseStreaming, + authErrorBody: ANTHROPIC_AUTH_ERROR_BODY }, gemini_generate_content: { id: "gemini_generate_content", @@ -144,7 +180,8 @@ export const AI_CAPABILITY_DEFS: Record = extractModel: paramModel, resolveUpstreamUrl: (base, req) => joinUpstreamUrl(base, pathFromRequest(req)), - isStreaming: isGeminiStyleStreaming + isStreaming: isGeminiStyleStreaming, + authErrorBody: GOOGLE_AUTH_ERROR_BODY }, google_generate_content: { id: "google_generate_content", @@ -162,7 +199,8 @@ export const AI_CAPABILITY_DEFS: Record = extractModel: paramModel, resolveUpstreamUrl: (base, req) => joinUpstreamUrl(base, pathFromRequest(req)), - isStreaming: isGeminiStyleStreaming + isStreaming: isGeminiStyleStreaming, + authErrorBody: GOOGLE_AUTH_ERROR_BODY }, google_raw_predict: { id: "google_raw_predict", @@ -182,7 +220,8 @@ export const AI_CAPABILITY_DEFS: Record = isStreaming: (req, contentType) => pathIncludes(req, "streamRawPredict") || pathIncludes(req, "alt=sse") || - contentTypeIsSse(contentType) + contentTypeIsSse(contentType), + authErrorBody: GOOGLE_AUTH_ERROR_BODY }, bedrock_model_invoke: { id: "bedrock_model_invoke", @@ -199,7 +238,8 @@ export const AI_CAPABILITY_DEFS: Record = isStreaming: (req, contentType) => pathIncludes(req, "invoke-with-response-stream") || contentTypeIsAmazonEventStream(contentType) || - contentTypeIsSse(contentType) + contentTypeIsSse(contentType), + authErrorBody: BEDROCK_AUTH_ERROR_BODY }, bedrock_converse: { id: "bedrock_converse", @@ -213,7 +253,8 @@ export const AI_CAPABILITY_DEFS: Record = isStreaming: (req, contentType) => pathIncludes(req, "converse-stream") || contentTypeIsAmazonEventStream(contentType) || - contentTypeIsSse(contentType) + contentTypeIsSse(contentType), + authErrorBody: BEDROCK_AUTH_ERROR_BODY } }; @@ -224,6 +265,61 @@ export function isAiCapability(value: unknown): value is AiCapability { ); } +/** + * Convert an Express-style route path from AI_CAPABILITY_DEFS into a RegExp. + * Handles `:param` segments and escaped literal colons (`\:`). + */ +export function routePatternToRegExp(routePath: string): RegExp { + let pattern = ""; + for (let i = 0; i < routePath.length; i++) { + const ch = routePath[i]; + if ( + ch === "\\" && + i + 1 < routePath.length && + routePath[i + 1] === ":" + ) { + pattern += ":"; + i++; + continue; + } + if (ch === ":") { + // Named param: consume until next / or end + i++; + while ( + i < routePath.length && + routePath[i] !== "/" && + !(routePath[i] === "\\" && routePath[i + 1] === ":") + ) { + i++; + } + i--; // loop will ++ + pattern += "[^/]+"; + continue; + } + // Escape regex special chars + if (/[.*+?^${}()|[\]\\]/.test(ch)) { + pattern += "\\" + ch; + } else { + pattern += ch; + } + } + return new RegExp(`^${pattern}$`); +} + +export function resolveAiCapabilityFromPath(path: string): AiCapability | null { + const pathname = path.split("?")[0] || "/"; + const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`; + + for (const def of Object.values(AI_CAPABILITY_DEFS)) { + for (const route of def.routes) { + if (routePatternToRegExp(route.path).test(normalized)) { + return def.id; + } + } + } + return null; +} + export function parseCapabilities(raw: unknown): AiCapability[] { if (raw == null) { return []; diff --git a/server/lib/aiGatewayAuthError.ts b/server/lib/aiGatewayAuthError.ts new file mode 100644 index 000000000..55e4d5e28 --- /dev/null +++ b/server/lib/aiGatewayAuthError.ts @@ -0,0 +1,27 @@ +import { + AI_CAPABILITY_DEFS, + OPENAI_AUTH_ERROR_BODY, + type AiCapability +} from "@server/lib/aiCapabilities"; +import HttpCode from "@server/types/HttpCode"; + +export type ClientErrorResponse = { + statusCode?: number; + contentType?: string; + body: string; +}; + +export function buildInferenceAuthClientError( + capability: AiCapability | null +): ClientErrorResponse { + const body = + capability != null + ? AI_CAPABILITY_DEFS[capability].authErrorBody + : OPENAI_AUTH_ERROR_BODY; + + return { + statusCode: HttpCode.UNAUTHORIZED, + contentType: "application/json", + body: JSON.stringify(body) + }; +} diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 5aa69d7fc..d77937cd3 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -366,10 +366,6 @@ async function resolveTarget(host: string): Promise { .where(eq(resourceAiModels.resourceId, resourceRow.resourceId)) ]); - if (attachmentRows.length === 0) { - return null; - } - return { resourceId: resourceRow.resourceId, siteResourceId: null, @@ -427,10 +423,6 @@ async function resolveTarget(host: string): Promise { ) ]); - if (attachmentRows.length === 0) { - return null; - } - return { resourceId: null, siteResourceId: siteResourceRow.siteResourceId, @@ -756,6 +748,14 @@ export async function handleAiGatewayProxy( }); } + if (attachments.length === 0) { + return res.status(HttpCode.FORBIDDEN).json({ + error: { + message: "No AI providers configured for this resource" + } + }); + } + const capableAttachments = attachments.filter((a) => providerHasCapability(a.provider.capabilities, capability) ); diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index f5b6a25b9..706626373 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -48,6 +48,11 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { getCountryCodeForIp } from "@server/lib/geoip"; import { getAsnForIp } from "@server/lib/asn"; +import { + buildInferenceAuthClientError, + type ClientErrorResponse +} from "@server/lib/aiGatewayAuthError"; +import { resolveAiCapabilityFromPath } from "@server/lib/aiCapabilities"; import { verifyPassword } from "@server/auth/password"; import { checkOrgAccessPolicy, @@ -89,6 +94,8 @@ type BasicUserData = { role: string | null; }; +export type { ClientErrorResponse }; + export type VerifyUserResponse = { valid: boolean; headerAuthChallenged?: boolean; @@ -96,8 +103,27 @@ export type VerifyUserResponse = { userData?: BasicUserData; pangolinVersion?: string; dontStripSession?: boolean; + clientError?: ClientErrorResponse; }; +function notAllowedWithClientError( + res: Response, + clientError: ClientErrorResponse +) { + const data = { + data: { + valid: false, + clientError, + pangolinVersion: APP_VERSION + }, + success: true, + error: false, + message: "Access denied", + status: HttpCode.OK + }; + return response(res, data); +} + export async function verifyResourceSession( req: Request, res: Response, @@ -408,7 +434,16 @@ export async function verifyResourceSession( parsedBody.data ); - return notAllowed(res, redirectPath, resource.orgId); + // Browsers go to the resource auth / API key page. API clients get + // a capability-shaped JSON auth error instead of a redirect. + if (clientIsBrowser) { + return notAllowed(res, redirectPath, resource.orgId); + } + + return notAllowedWithClientError( + res, + buildInferenceAuthClientError(resolveAiCapabilityFromPath(path)) + ); } // check for access token in headers From c6bd657ee62491e10b76798b419c18debce2e9be Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 12 Aug 2026 18:48:14 +0200 Subject: [PATCH 315/494] =?UTF-8?q?=E2=9C=A8=20send=20webhook=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../private/lib/alerts/processTestAlerts.ts | 16 ++++---- server/private/lib/alerts/sendAlertEmail.ts | 5 +-- server/private/lib/alerts/sendAlertWebhook.ts | 41 ++++++++++++++----- .../AlertRuleGraphEditor.tsx | 28 ++++--------- src/lib/queries.ts | 2 +- 5 files changed, 50 insertions(+), 42 deletions(-) diff --git a/server/private/lib/alerts/processTestAlerts.ts b/server/private/lib/alerts/processTestAlerts.ts index 1a6c1d7c6..f7fa47b20 100644 --- a/server/private/lib/alerts/processTestAlerts.ts +++ b/server/private/lib/alerts/processTestAlerts.ts @@ -7,15 +7,13 @@ import type { } from "@server/routers/alertRule/types"; import { eq, inArray } from "drizzle-orm"; import { sendAlertEmail } from "./sendAlertEmail"; -import { decrypt } from "@server/lib/crypto"; -import config from "@server/lib/config"; import { sendAlertWebhook } from "./sendAlertWebhook"; export async function processTestAlerts(context: TestAlertContext) { + // Process email actions const emailActions = context.actions.filter( (action) => action.type === "email" ); - // Process email actions for (const action of emailActions) { try { const recipients = await resolveEmailRecipients(action); @@ -30,10 +28,10 @@ export async function processTestAlerts(context: TestAlertContext) { } } + // Process webhook actions const webhookActions = context.actions.filter( (action) => action.type === "webhook" ); - const serverSecret = config.getRawConfig().server.secret!; for (const action of webhookActions) { try { @@ -41,8 +39,9 @@ export async function processTestAlerts(context: TestAlertContext) { if (action.config) { try { - const decrypted = decrypt(action.config, serverSecret); - webhookConfig = JSON.parse(decrypted) as WebhookAlertConfig; + webhookConfig = JSON.parse( + action.config + ) as WebhookAlertConfig; } catch (err) { logger.error( `processTestAlerts: failed to decrypt webhook`, @@ -52,7 +51,10 @@ export async function processTestAlerts(context: TestAlertContext) { } } - await sendAlertWebhook(action.webhookUrl, webhookConfig, context); + await sendAlertWebhook(action.webhookUrl, webhookConfig, { + ...context, + isTest: true + }); } catch (err) { logger.error( `processTestAlerts: failed to send alert webhook `, diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index 0eef6fb5c..ab7d49acc 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -15,10 +15,7 @@ import { sendEmail } from "@server/emails"; import AlertNotification from "@server/emails/templates/AlertNotification"; import config from "@server/lib/config"; import logger from "@server/logger"; -import { - AlertContext, - type AlertEventType -} from "@server/routers/alertRule/types"; +import { type AlertEventType } from "@server/routers/alertRule/types"; type EmailAlertContext = { eventType: AlertEventType; diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts index 27e142cc4..d3a5d421b 100644 --- a/server/private/lib/alerts/sendAlertWebhook.ts +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -14,13 +14,28 @@ import logger from "@server/logger"; import { AlertContext, - WebhookAlertConfig + WebhookAlertConfig, + type AlertEventType } from "@server/routers/alertRule/types"; const REQUEST_TIMEOUT_MS = 15_000; const MAX_RETRIES = 3; const RETRY_BASE_DELAY_MS = 500; +type WebhookAlertContext = { + eventType: AlertEventType; + orgId: string; + /** Set for site_online / site_offline events */ + siteId?: number; + /** Set for health_check_* events */ + healthCheckId?: number; + /** Set for resource_* events */ + resourceId?: number; + /** Human-readable context data included in emails and webhook payloads */ + data: Record; + isTest?: boolean; +}; + /** * Sends a single webhook POST for an alert event. * @@ -40,14 +55,14 @@ const RETRY_BASE_DELAY_MS = 500; export async function sendAlertWebhook( url: string, webhookConfig: WebhookAlertConfig, - context: AlertContext + context: WebhookAlertContext ): Promise { const eventType = context.eventType; const timestamp = new Date().toISOString(); const status = deriveStatus(eventType, context.data); const data = { orgId: context.orgId, ...context.data }; - let body: string; + let body: Record; if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) { body = renderTemplate(webhookConfig.bodyTemplate, { event: eventType, @@ -56,7 +71,11 @@ export async function sendAlertWebhook( data }); } else { - body = JSON.stringify({ event: eventType, timestamp, status, data }); + body = { event: eventType, timestamp, status, data }; + } + + if (body.data && context.isTest) { + body.data.test = true; } const headers = buildHeaders(webhookConfig); @@ -75,7 +94,7 @@ export async function sendAlertWebhook( response = await fetch(url, { method: webhookConfig.method ?? "POST", headers, - body, + body: JSON.stringify(body), signal: controller.signal }); } catch (err: unknown) { @@ -247,7 +266,10 @@ interface TemplateContext { * left untouched. * 3. The fixed top-level keys: event, timestamp, status. */ -function renderTemplate(template: string, ctx: TemplateContext): string { +function renderTemplate( + template: string, + ctx: TemplateContext +): Record { // Step 1 – expand {{data}} first so its contents are already serialised // and won't be touched by later passes. let rendered = template.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data)); @@ -280,20 +302,19 @@ function renderTemplate(template: string, ctx: TemplateContext): string { // Validate the rendered result is valid JSON; if not, log a warning and // fall back to the default payload so the webhook still fires. try { - JSON.parse(rendered); - return rendered; + return JSON.parse(rendered); } catch { logger.warn( `sendAlertWebhook: body template produced invalid JSON for event ` + `"${ctx.event}" destined for a webhook. Falling back to default ` + `payload. Check that {{data}} is NOT wrapped in quotes in your template.` ); - return JSON.stringify({ + return { event: ctx.event, timestamp: ctx.timestamp, status: ctx.status, data: ctx.data - }); + }; } } diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index 243ce82f8..a3e5e89cb 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -180,8 +180,9 @@ export default function AlertRuleGraphEditor({ const testAlert = async () => { const isValid = await form.trigger(); + const values = form.getValues(); + if (!isValid) { - const values = form.getValues(); if (values.actions.length === 0) { toast({ variant: "warning", @@ -193,27 +194,14 @@ export default function AlertRuleGraphEditor({ return; } - const values = form.getValues(); try { const payload = formValuesToApiPayload(values); - if (isNew) { - const res = await api.post< - AxiosResponse - >(`/org/${orgId}/test-alert-rule`, payload); - toast({ - title: t("alertingTestAlertSent"), - description: t("alertingTestAlertSentDescription") - }); - } else { - await api.post( - `/org/${orgId}/alert-rule/${alertRuleId}`, - payload - ); - toast({ - title: t("alertingTestAlertSent"), - description: t("alertingTestAlertSentDescription") - }); - } + await api.post(`/org/${orgId}/test-alert-rule`, payload); + + toast({ + title: t("alertingTestAlertSent"), + description: t("alertingTestAlertSentDescription") + }); } catch (e) { toast({ title: t("error"), diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 96024c3a0..e9e324c08 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1371,7 +1371,7 @@ export const approvalQueries = { }, refetchInterval: (query) => { if (query.state.data) { - return durationToMs(30, "seconds"); + return durationToMs(1.5, "minutes"); } return false; } From 4b61e12ca6ae837e4b7341222afb60ffb824d8b3 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 12 Aug 2026 18:56:56 +0200 Subject: [PATCH 316/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20trigger=20alert=20?= =?UTF-8?q?correctly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routers/alertRule/testAlertRule.ts | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/server/private/routers/alertRule/testAlertRule.ts b/server/private/routers/alertRule/testAlertRule.ts index 6104dbc6b..e870e9488 100644 --- a/server/private/routers/alertRule/testAlertRule.ts +++ b/server/private/routers/alertRule/testAlertRule.ts @@ -11,29 +11,17 @@ * This file is not licensed under the AGPLv3. */ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { - alertRules, - alertSites, - alertHealthChecks, - alertResources -} from "@server/db"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; -import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; -import { - ListAlertRulesResponse, - type AlertAction, - type EmailAlertAction -} from "@server/routers/alertRule/types"; -import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts"; import { getRandomItemInArray } from "@app/lib/getRandomItemInArray"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts"; +import { type AlertAction } from "@server/routers/alertRule/types"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import type { TriggerSiteAlertResponse } from "../alertEvents"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -203,6 +191,14 @@ export async function testAlertRule( actions: collectedActions, data }); + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Alert triggered successfully", + status: HttpCode.OK + }); } catch (error) { logger.error(error); return next( From 47ae017f94e7a6c2f45c85540f80dbe7ed25a457 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 12 Aug 2026 13:21:46 -0400 Subject: [PATCH 317/494] add virtual api keys to budgets --- messages/en-US.json | 2 + server/db/pg/schema/schema.ts | 11 +- server/db/sqlite/schema/schema.ts | 11 +- server/lib/aiBudgetEnforcement.ts | 20 +++- server/routers/aiBudget/createAiBudget.ts | 41 +++++-- server/routers/aiBudget/index.ts | 1 + .../aiBudget/listAiBudgetsForVirtualApiKey.ts | 69 ++++++++++++ server/routers/aiBudget/updateAiBudget.ts | 63 +++++++++-- server/routers/aiBudget/validation.ts | 6 +- server/routers/aiGateway/pipeline.ts | 3 +- server/routers/external.ts | 7 ++ src/components/CreateVirtualApiKeyForm.tsx | 92 +++++++++++++++- src/components/EditVirtualApiKeyForm.tsx | 104 +++++++++++++++++- src/lib/aiBudgetScope.ts | 17 ++- 14 files changed, 415 insertions(+), 32 deletions(-) create mode 100644 server/routers/aiBudget/listAiBudgetsForVirtualApiKey.ts diff --git a/messages/en-US.json b/messages/en-US.json index b42aa4f10..0abe669ea 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1697,6 +1697,8 @@ "virtualApiKeysErrorFetchSecret": "Error loading secret", "virtualApiKeysErrorFetchSecretDescription": "Failed to load the virtual API key secret", "virtualApiKeysFilterUnassigned": "Unassigned", + "virtualApiKeysInferenceBudget": "Inference Budget", + "virtualApiKeysInferenceBudgetDescription": "Configure how this key restricts AI usage based on spending or token limits", "myVirtualApiKeysTitle": "Your API Keys", "myVirtualApiKeysDescription": "View your identity key and any virtual API keys attributed to you in this organization", "myVirtualApiKeysResourceTitle": "Your API Keys for {resourceName}", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index a5ebfa530..8efebc619 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1776,6 +1776,10 @@ export const aiBudgets = pgTable( roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }), + virtualApiKeyId: varchar("virtualApiKeyId").references( + () => virtualApiKeys.virtualApiKeyId, + { onDelete: "cascade" } + ), amount: real("amount").notNull(), unit: varchar("unit").$type<"usd" | "tokens">().notNull(), period: varchar("period") @@ -1806,7 +1810,12 @@ export const aiBudgets = pgTable( t.unit, t.period ), - unique("ai_budget_role_uniq").on(t.roleId, t.unit, t.period) + unique("ai_budget_role_uniq").on(t.roleId, t.unit, t.period), + unique("ai_budget_virtual_api_key_uniq").on( + t.virtualApiKeyId, + t.unit, + t.period + ) ] ); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 450b99fce..e46aef8d2 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1762,6 +1762,10 @@ export const aiBudgets = sqliteTable( roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }), + virtualApiKeyId: text("virtualApiKeyId").references( + () => virtualApiKeys.virtualApiKeyId, + { onDelete: "cascade" } + ), amount: real("amount").notNull(), unit: text("unit").$type<"usd" | "tokens">().notNull(), period: text("period") @@ -1794,7 +1798,12 @@ export const aiBudgets = sqliteTable( t.unit, t.period ), - unique("ai_budget_role_uniq").on(t.roleId, t.unit, t.period) + unique("ai_budget_role_uniq").on(t.roleId, t.unit, t.period), + unique("ai_budget_virtual_api_key_uniq").on( + t.virtualApiKeyId, + t.unit, + t.period + ) ] ); diff --git a/server/lib/aiBudgetEnforcement.ts b/server/lib/aiBudgetEnforcement.ts index f6daf9694..05decd6ae 100644 --- a/server/lib/aiBudgetEnforcement.ts +++ b/server/lib/aiBudgetEnforcement.ts @@ -53,7 +53,8 @@ function applicableBudgetsCacheKey(ctx: BudgetScopeContext): string { ctx.requestedModel, ctx.resourceId ?? "", ctx.siteResourceId ?? "", - roleKey + roleKey, + ctx.virtualApiKeyId ?? "" ].join(":"); } @@ -83,6 +84,7 @@ export type BudgetScopeContext = { siteResourceId: number | null; roleIds: number[]; requestUserId: string | null; + virtualApiKeyId: string | null; }; /** @@ -142,6 +144,11 @@ async function fetchApplicableBudgets( if (ctx.roleIds.length > 0) { scopeConditions.push(inArray(aiBudgets.roleId, ctx.roleIds)); } + if (ctx.virtualApiKeyId != null) { + scopeConditions.push( + eq(aiBudgets.virtualApiKeyId, ctx.virtualApiKeyId) + ); + } return db .select() @@ -274,6 +281,17 @@ export async function sumUsageForBudget( ); } + if (budget.virtualApiKeyId != null) { + return sumUsageAmount( + and( + eq(aiUsageRecords.orgId, ctx.orgId), + eq(aiUsageRecords.virtualApiKeyId, budget.virtualApiKeyId), + gte(aiUsageRecords.createdAt, start) + )!, + budget.unit + ); + } + return 0; } diff --git a/server/routers/aiBudget/createAiBudget.ts b/server/routers/aiBudget/createAiBudget.ts index 3ddd76174..0972f9d29 100644 --- a/server/routers/aiBudget/createAiBudget.ts +++ b/server/routers/aiBudget/createAiBudget.ts @@ -7,7 +7,8 @@ import { db, resources, roles, - siteResources + siteResources, + virtualApiKeys } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -35,6 +36,7 @@ const bodySchema = z resourceId: z.coerce.number().int().positive().optional(), siteResourceId: z.coerce.number().int().positive().optional(), roleId: z.coerce.number().int().positive().optional(), + virtualApiKeyId: z.string().nonempty().optional(), amount: z.number().positive(), unit: aiBudgetUnitSchema, period: aiBudgetPeriodSchema.optional().default("monthly"), @@ -98,6 +100,7 @@ export async function createAiBudget( resourceId, siteResourceId, roleId, + virtualApiKeyId, amount, unit, period, @@ -189,6 +192,22 @@ export async function createAiBudget( } } + if (virtualApiKeyId !== undefined) { + const [key] = await db + .select({ orgId: virtualApiKeys.orgId }) + .from(virtualApiKeys) + .where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId)) + .limit(1); + if (!key || key.orgId !== orgId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Virtual API key with ID ${virtualApiKeyId} not found in this organization` + ) + ); + } + } + const scopeCondition = providerId !== undefined ? eq(aiBudgets.providerId, providerId) @@ -200,14 +219,17 @@ export async function createAiBudget( ? eq(aiBudgets.siteResourceId, siteResourceId) : roleId !== undefined ? eq(aiBudgets.roleId, roleId) - : and( - eq(aiBudgets.orgId, orgId), - isNull(aiBudgets.providerId), - isNull(aiBudgets.modelId), - isNull(aiBudgets.resourceId), - isNull(aiBudgets.siteResourceId), - isNull(aiBudgets.roleId) - ); + : virtualApiKeyId !== undefined + ? eq(aiBudgets.virtualApiKeyId, virtualApiKeyId) + : and( + eq(aiBudgets.orgId, orgId), + isNull(aiBudgets.providerId), + isNull(aiBudgets.modelId), + isNull(aiBudgets.resourceId), + isNull(aiBudgets.siteResourceId), + isNull(aiBudgets.roleId), + isNull(aiBudgets.virtualApiKeyId) + ); const [existing] = await db .select({ budgetId: aiBudgets.budgetId }) @@ -239,6 +261,7 @@ export async function createAiBudget( resourceId: resourceId ?? null, siteResourceId: siteResourceId ?? null, roleId: roleId ?? null, + virtualApiKeyId: virtualApiKeyId ?? null, amount, unit, period, diff --git a/server/routers/aiBudget/index.ts b/server/routers/aiBudget/index.ts index 36eed7ba4..7052dbe4e 100644 --- a/server/routers/aiBudget/index.ts +++ b/server/routers/aiBudget/index.ts @@ -5,6 +5,7 @@ export * from "./listAiBudgetsForModel"; export * from "./listAiBudgetsForResource"; export * from "./listAiBudgetsForSiteResource"; export * from "./listAiBudgetsForRole"; +export * from "./listAiBudgetsForVirtualApiKey"; export * from "./getAiBudget"; export * from "./updateAiBudget"; export * from "./deleteAiBudget"; diff --git a/server/routers/aiBudget/listAiBudgetsForVirtualApiKey.ts b/server/routers/aiBudget/listAiBudgetsForVirtualApiKey.ts new file mode 100644 index 000000000..be2e45775 --- /dev/null +++ b/server/routers/aiBudget/listAiBudgetsForVirtualApiKey.ts @@ -0,0 +1,69 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiBudgets, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { asc, eq } from "drizzle-orm"; +import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types"; + +const paramsSchema = z.strictObject({ + virtualApiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "get", + path: "/virtual-api-key/{virtualApiKeyId}/ai-budgets", + description: "List AI budgets scoped to a virtual API key.", + tags: [OpenAPITags.AiBudget], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listAiBudgetsForVirtualApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { virtualApiKeyId } = parsedParams.data; + + const budgets = await db + .select() + .from(aiBudgets) + .where(eq(aiBudgets.virtualApiKeyId, virtualApiKeyId)) + .orderBy(asc(aiBudgets.budgetId)); + + return response(res, { + data: { budgets }, + success: true, + error: false, + message: "AI budgets retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiBudget/updateAiBudget.ts b/server/routers/aiBudget/updateAiBudget.ts index 723b346c7..c7bf70cae 100644 --- a/server/routers/aiBudget/updateAiBudget.ts +++ b/server/routers/aiBudget/updateAiBudget.ts @@ -7,7 +7,8 @@ import { db, resources, roles, - siteResources + siteResources, + virtualApiKeys } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -34,6 +35,7 @@ const bodySchema = z.strictObject({ resourceId: z.coerce.number().int().positive().nullable().optional(), siteResourceId: z.coerce.number().int().positive().nullable().optional(), roleId: z.coerce.number().int().positive().nullable().optional(), + virtualApiKeyId: z.string().nonempty().nullable().optional(), amount: z.number().positive().optional(), unit: aiBudgetUnitSchema.optional(), period: aiBudgetPeriodSchema.optional(), @@ -128,6 +130,10 @@ export async function updateAiBudget( : existing.siteResourceId; const nextRoleId = body.roleId !== undefined ? body.roleId : existing.roleId; + const nextVirtualApiKeyId = + body.virtualApiKeyId !== undefined + ? body.virtualApiKeyId + : existing.virtualApiKeyId; const nextUnit = body.unit !== undefined ? body.unit : existing.unit; const nextPeriod = body.period !== undefined ? body.period : existing.period; @@ -138,7 +144,8 @@ export async function updateAiBudget( modelId: z.number().nullable().optional(), resourceId: z.number().nullable().optional(), siteResourceId: z.number().nullable().optional(), - roleId: z.number().nullable().optional() + roleId: z.number().nullable().optional(), + virtualApiKeyId: z.string().nullable().optional() }) .superRefine((data, ctx) => refineBudgetScopeFields(data, ctx)) .safeParse({ @@ -146,7 +153,8 @@ export async function updateAiBudget( modelId: nextModelId, resourceId: nextResourceId, siteResourceId: nextSiteResourceId, - roleId: nextRoleId + roleId: nextRoleId, + virtualApiKeyId: nextVirtualApiKeyId }); if (!scopeValidation.success) { @@ -245,6 +253,30 @@ export async function updateAiBudget( } } + if ( + body.virtualApiKeyId !== undefined && + body.virtualApiKeyId !== null + ) { + const [key] = await db + .select({ orgId: virtualApiKeys.orgId }) + .from(virtualApiKeys) + .where( + eq( + virtualApiKeys.virtualApiKeyId, + body.virtualApiKeyId + ) + ) + .limit(1); + if (!key || key.orgId !== orgId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Virtual API key with ID ${body.virtualApiKeyId} not found in this organization` + ) + ); + } + } + const scopeCondition = nextProviderId !== null ? eq(aiBudgets.providerId, nextProviderId) @@ -256,14 +288,20 @@ export async function updateAiBudget( ? eq(aiBudgets.siteResourceId, nextSiteResourceId) : nextRoleId !== null ? eq(aiBudgets.roleId, nextRoleId) - : and( - eq(aiBudgets.orgId, orgId), - isNull(aiBudgets.providerId), - isNull(aiBudgets.modelId), - isNull(aiBudgets.resourceId), - isNull(aiBudgets.siteResourceId), - isNull(aiBudgets.roleId) - ); + : nextVirtualApiKeyId !== null + ? eq( + aiBudgets.virtualApiKeyId, + nextVirtualApiKeyId + ) + : and( + eq(aiBudgets.orgId, orgId), + isNull(aiBudgets.providerId), + isNull(aiBudgets.modelId), + isNull(aiBudgets.resourceId), + isNull(aiBudgets.siteResourceId), + isNull(aiBudgets.roleId), + isNull(aiBudgets.virtualApiKeyId) + ); const [conflict] = await db .select({ budgetId: aiBudgets.budgetId }) @@ -305,6 +343,9 @@ export async function updateAiBudget( if (body.roleId !== undefined) { updateData.roleId = body.roleId; } + if (body.virtualApiKeyId !== undefined) { + updateData.virtualApiKeyId = body.virtualApiKeyId; + } if (body.amount !== undefined) { updateData.amount = body.amount; } diff --git a/server/routers/aiBudget/validation.ts b/server/routers/aiBudget/validation.ts index 303039948..e7cae351f 100644 --- a/server/routers/aiBudget/validation.ts +++ b/server/routers/aiBudget/validation.ts @@ -20,6 +20,7 @@ export function refineBudgetScopeFields( resourceId?: number | null; siteResourceId?: number | null; roleId?: number | null; + virtualApiKeyId?: string | null; }, ctx: z.RefinementCtx ) { @@ -28,7 +29,8 @@ export function refineBudgetScopeFields( data.modelId, data.resourceId, data.siteResourceId, - data.roleId + data.roleId, + data.virtualApiKeyId ]; const setCount = scopeFields.filter( @@ -39,7 +41,7 @@ export function refineBudgetScopeFields( ctx.addIssue({ code: "custom", message: - "Only one of providerId, modelId, resourceId, siteResourceId, or roleId may be set on a budget", + "Only one of providerId, modelId, resourceId, siteResourceId, roleId, or virtualApiKeyId may be set on a budget", path: ["providerId"] }); } diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 12b1cbb79..2d916a97a 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -830,7 +830,8 @@ export async function handleAiGatewayProxy( resourceId, siteResourceId, roleIds: requestUser?.roleIds ?? [], - requestUserId: requestUser?.userId ?? null + requestUserId: requestUser?.userId ?? null, + virtualApiKeyId: identity.virtualApiKeyId }); appliedBudgets = budgetCheck.budgets; diff --git a/server/routers/external.ts b/server/routers/external.ts index 6f44c1efe..c391665b9 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1791,6 +1791,13 @@ authenticated.get( aiBudget.listAiBudgetsForRole ); +authenticated.get( + "/virtual-api-key/:virtualApiKeyId/ai-budgets", + verifyVirtualApiKeyAccess, + verifyUserHasAction(ActionsEnum.listAiBudgets), + aiBudget.listAiBudgetsForVirtualApiKey +); + authenticated.get( "/org/:orgId/labels", verifyOrgAccess, diff --git a/src/components/CreateVirtualApiKeyForm.tsx b/src/components/CreateVirtualApiKeyForm.tsx index e67488b5d..c443ad4b9 100644 --- a/src/components/CreateVirtualApiKeyForm.tsx +++ b/src/components/CreateVirtualApiKeyForm.tsx @@ -48,6 +48,12 @@ import { formatMultiResourcesSelectorLabel } from "@app/components/multi-resource-selector"; import type { SelectedResource } from "@app/components/resource-selector"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { + BudgetRowsFields, + getBudgetRowsErrors, + type BudgetRow +} from "@app/components/BudgetsEditor"; export type CreatedVirtualApiKey = { virtualApiKeyId: string; @@ -93,6 +99,10 @@ export default function CreateVirtualApiKeyForm({ const [selectedResources, setSelectedResources] = useState< SelectedResource[] >([]); + const [pendingBudgetRows, setPendingBudgetRows] = useState( + [] + ); + const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false); const formSchema = z.object({ name: z.string().min(1), @@ -113,9 +123,29 @@ export default function CreateVirtualApiKeyForm({ setAllResources(false); setSelectedUser(null); setSelectedResources([]); + setPendingBudgetRows([]); + setAttemptedBudgetsSave(false); form.reset(); } + function handleFormSubmit(values: z.infer) { + const { conflictingKeys, invalidAmountKeys } = + getBudgetRowsErrors(pendingBudgetRows); + if (conflictingKeys.size > 0 || invalidAmountKeys.size > 0) { + setAttemptedBudgetsSave(true); + toast({ + variant: "destructive", + title: t("aiBudgetErrorSave"), + description: conflictingKeys.size + ? t("aiBudgetConflictError") + : t("aiBudgetInvalidAmountError") + }); + return; + } + + return onSubmit(values); + } + async function onSubmit(values: z.infer) { setLoading(true); @@ -155,6 +185,33 @@ export default function CreateVirtualApiKeyForm({ ); } + const pendingBudgets = pendingBudgetRows.filter( + (budget) => budget.amount.trim() !== "" + ); + if (pendingBudgets.length > 0) { + try { + await Promise.all( + pendingBudgets.map((budget) => + api.put(`/org/${org.org.orgId}/ai-budget`, { + virtualApiKeyId: key.virtualApiKeyId, + amount: Number(budget.amount), + unit: budget.unit, + period: budget.period + }) + ) + ); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiBudgetErrorSave"), + description: formatAxiosError( + e, + t("aiBudgetErrorSave") + ) + }); + } + } + const resourceLookup = new Map( selectedResources.map((r) => [ r.resourceId, @@ -219,10 +276,26 @@ export default function CreateVirtualApiKeyForm({ {!credential && (
+ +
)}
+
+ +
+

+ {t( + "virtualApiKeysInferenceBudgetDescription" + )} +

+ +
+ )} diff --git a/src/components/EditVirtualApiKeyForm.tsx b/src/components/EditVirtualApiKeyForm.tsx index 0ef5e936b..1d3e78dbd 100644 --- a/src/components/EditVirtualApiKeyForm.tsx +++ b/src/components/EditVirtualApiKeyForm.tsx @@ -50,6 +50,16 @@ import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import CopyTextBox from "@app/components/CopyTextBox"; import type { CreatedVirtualApiKey } from "@app/components/CreateVirtualApiKeyForm"; import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { + BudgetRowsFields, + getBudgetRowsErrors, + rowsFromBudgets, + saveBudgetRows, + type BudgetRow +} from "@app/components/BudgetsEditor"; +import { aiBudgetQueries } from "@app/lib/queries"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; type FormProps = { open: boolean; @@ -93,6 +103,7 @@ export default function EditVirtualApiKeyForm({ const { env } = useEnvContext(); const api = createApiClient({ env }); const t = useTranslations(); + const queryClient = useQueryClient(); const [loading, setLoading] = useState(false); const [selectedUser, setSelectedUser] = useState(null); @@ -101,6 +112,19 @@ export default function EditVirtualApiKeyForm({ >([]); const [credential, setCredential] = useState(null); const [credentialLoading, setCredentialLoading] = useState(false); + const [pendingBudgetRows, setPendingBudgetRows] = useState( + [] + ); + const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false); + + const budgetScope = { + type: "virtualApiKey" as const, + id: virtualApiKey?.virtualApiKeyId ?? "" + }; + const budgetsQuery = useQuery({ + ...aiBudgetQueries.scoped({ scope: budgetScope }), + enabled: open && !!virtualApiKey + }); const formSchema = z .object({ @@ -191,6 +215,32 @@ export default function EditVirtualApiKeyForm({ }; }, [open, virtualApiKey, form]); + useEffect(() => { + if (!open || !budgetsQuery.data) { + return; + } + setPendingBudgetRows(rowsFromBudgets(budgetsQuery.data)); + setAttemptedBudgetsSave(false); + }, [open, budgetsQuery.data]); + + function handleFormSubmit(values: z.infer) { + const { conflictingKeys, invalidAmountKeys } = + getBudgetRowsErrors(pendingBudgetRows); + if (conflictingKeys.size > 0 || invalidAmountKeys.size > 0) { + setAttemptedBudgetsSave(true); + toast({ + variant: "destructive", + title: t("aiBudgetErrorSave"), + description: conflictingKeys.size + ? t("aiBudgetConflictError") + : t("aiBudgetInvalidAmountError") + }); + return; + } + + return onSubmit(values); + } + async function onSubmit(values: z.infer) { if (!virtualApiKey) { return; @@ -223,6 +273,26 @@ export default function EditVirtualApiKeyForm({ if (res?.data.data.virtualApiKey) { const key = res.data.data.virtualApiKey; + + try { + await saveBudgetRows({ + api, + orgId: virtualApiKey.orgId, + scope: budgetScope, + existingBudgets: budgetsQuery.data ?? [], + rows: pendingBudgetRows + }); + await queryClient.invalidateQueries( + aiBudgetQueries.scoped({ scope: budgetScope }) + ); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiBudgetErrorSave"), + description: formatAxiosError(e, t("aiBudgetErrorSave")) + }); + } + const resourceLookup = new Map( selectedResources.map((r) => [ r.resourceId, @@ -280,10 +350,26 @@ export default function EditVirtualApiKeyForm({
+ +
)}
+
+ +
+

+ {t( + "virtualApiKeysInferenceBudgetDescription" + )} +

+ +
+
diff --git a/src/lib/aiBudgetScope.ts b/src/lib/aiBudgetScope.ts index beabc85a3..333f63411 100644 --- a/src/lib/aiBudgetScope.ts +++ b/src/lib/aiBudgetScope.ts @@ -5,11 +5,12 @@ export type AiBudgetScopeType = | "model" | "resource" | "siteResource" - | "role"; + | "role" + | "virtualApiKey"; export type AiBudgetScope = { type: AiBudgetScopeType; - id: number; + id: number | string; }; export type AiBudgetScopeBodyField = @@ -17,11 +18,15 @@ export type AiBudgetScopeBodyField = | "modelId" | "resourceId" | "siteResourceId" - | "roleId"; + | "roleId" + | "virtualApiKeyId"; const scopeConfig: Record< AiBudgetScopeType, - { listPath: (id: number) => string; bodyField: AiBudgetScopeBodyField } + { + listPath: (id: number | string) => string; + bodyField: AiBudgetScopeBodyField; + } > = { provider: { listPath: (id) => `/ai-provider/${id}/ai-budgets`, @@ -42,6 +47,10 @@ const scopeConfig: Record< role: { listPath: (id) => `/role/${id}/ai-budgets`, bodyField: "roleId" + }, + virtualApiKey: { + listPath: (id) => `/virtual-api-key/${id}/ai-budgets`, + bodyField: "virtualApiKeyId" } }; From dd78c2cc082adf541a6747ccf59a6905f7bbd163 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 12 Aug 2026 15:20:41 -0400 Subject: [PATCH 318/494] dont show servers idps in create user when idp mode is org on enterprise --- server/lib/idp/idpExistsForOrg.ts | 3 ++- server/routers/user/createOrgUser.ts | 11 +++++++++++ src/app/[orgId]/settings/access/users/create/page.tsx | 8 +++++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/server/lib/idp/idpExistsForOrg.ts b/server/lib/idp/idpExistsForOrg.ts index 52530e7ba..772e69bcf 100644 --- a/server/lib/idp/idpExistsForOrg.ts +++ b/server/lib/idp/idpExistsForOrg.ts @@ -1,8 +1,9 @@ import { db, idp, idpOrg, Transaction } from "@server/db"; import { and, eq } from "drizzle-orm"; +import { build } from "@server/build"; export function isOrgIdentityProviderMode(): boolean { - return process.env.IDENTITY_PROVIDER_MODE === "org"; + return build === "saas" || process.env.IDENTITY_PROVIDER_MODE === "org"; } /** diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index d80d85999..ea02884fd 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -20,6 +20,7 @@ import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { assignUserToOrg } from "@server/lib/userOrg"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; +import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -239,6 +240,16 @@ export async function createOrgUser( ); } + const providerExists = await idpExistsForOrg(idpId, orgId); + if (!providerExists) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Identity provider not found in this organization" + ) + ); + } + const [idpRes] = await db .select() .from(idp) diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index cafbca55f..4f6a2b974 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -237,10 +237,13 @@ export default function Page() { return; } + const useOrgIdps = + build === "saas" || env.app.identityProviderMode === "org"; + const res = await api .get< AxiosResponse - >(build === "saas" ? `/org/${orgId}/idp` : "/idp") + >(useOrgIdps ? `/org/${orgId}/idp` : "/idp") .catch((e) => { console.error(e); toast({ @@ -301,8 +304,7 @@ export default function Page() { ); const [isSubmittingExternal, setIsSubmittingExternal] = useState(false); - const loading = - isSubmittingInternal || isSubmittingExternal; + const loading = isSubmittingInternal || isSubmittingExternal; async function onSubmitInternal() { const isValid = await internalForm.trigger(); From 49b4fcf0634e74c55e520b2bb53e50411585acf5 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 12 Aug 2026 21:36:40 +0200 Subject: [PATCH 319/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/alert-rule-editor/AlertRuleGraphEditor.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index a3e5e89cb..9114df3ed 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -171,15 +171,10 @@ export default function AlertRuleGraphEditor({ variant: "destructive" }); } - // const submit = form.handleSubmit(async (values) => { - - // }); - - // await submit(); }; const testAlert = async () => { - const isValid = await form.trigger(); + const isValid = await form.trigger("actions"); const values = form.getValues(); if (!isValid) { From 653dd920ad25ef86ff8123963d5acfd326ca8f5d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 12 Aug 2026 15:54:22 -0400 Subject: [PATCH 320/494] Allow remote header overrides and fix display issues with log --- messages/en-US.json | 1 + server/lib/readConfigFile.ts | 22 ++ server/lib/traefik/TraefikConfigManager.ts | 26 ++- server/lib/virtualApiKey.ts | 19 +- server/routers/aiGateway/pipeline.ts | 30 ++- .../routers/traefik/traefikConfigProvider.ts | 22 +- .../virtualApiKey/listMyVirtualApiKeys.ts | 18 +- src/app/[orgId]/settings/logs/ai/page.tsx | 3 - .../[orgId]/settings/logs/request/page.tsx | 2 + src/components/AiUsageAnalyticsData.tsx | 203 ++++++++---------- 10 files changed, 213 insertions(+), 133 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 0abe669ea..7630612ca 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3437,6 +3437,7 @@ "validPassword": "Valid Password", "validEmail": "Valid email", "validSSO": "Valid SSO", + "validVirtualAPIKey": "Valid Virtual API Key", "view": "View", "configManaged": "Config Managed", "connectedClient": "Connected Client", diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index d5c52f13a..58d7b4006 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -111,6 +111,20 @@ export const configSchema = z }) .optional() .prefault({}), + remote_headers: z + .object({ + user_id: z.string().optional().default("Remote-User-Id"), + virtual_api_key_id: z + .string() + .optional() + .default("Remote-Virtual-Api-Key-Id"), + user: z.string().optional().default("Remote-User"), + email: z.string().optional().default("Remote-Email"), + name: z.string().optional().default("Remote-Name"), + role: z.string().optional().default("Remote-Role") + }) + .optional() + .prefault({}), resource_session_request_param: z .string() .optional() @@ -154,6 +168,14 @@ export const configSchema = z id: "P-Access-Token-Id", token: "P-Access-Token" }, + remote_headers: { + user_id: "Remote-User-Id", + virtual_api_key_id: "Remote-Virtual-Api-Key-Id", + user: "Remote-User", + email: "Remote-Email", + name: "Remote-Name", + role: "Remote-Role" + }, resource_session_request_param: "resource_session_request_param", dashboard_session_length_hours: 720, diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index a52c5232a..91bc37249 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -605,7 +605,31 @@ export class TraefikConfigManager { resourceSessionRequestParam: config.getRawConfig().server - .resource_session_request_param + .resource_session_request_param, + + remoteUserIdHeader: + config.getRawConfig().server.remote_headers + .user_id, + + remoteVirtualApiKeyIdHeader: + config.getRawConfig().server.remote_headers + .virtual_api_key_id, + + remoteUserHeader: + config.getRawConfig().server.remote_headers + .user, + + remoteEmailHeader: + config.getRawConfig().server.remote_headers + .email, + + remoteNameHeader: + config.getRawConfig().server.remote_headers + .name, + + remoteRoleHeader: + config.getRawConfig().server.remote_headers + .role } } }; diff --git a/server/lib/virtualApiKey.ts b/server/lib/virtualApiKey.ts index e4f7480db..471be89fd 100644 --- a/server/lib/virtualApiKey.ts +++ b/server/lib/virtualApiKey.ts @@ -5,6 +5,7 @@ import { import { db, resources, + User, virtualApiKeyResources, virtualApiKeys, type Transaction, @@ -148,12 +149,12 @@ async function selectUserVirtualApiKey( export async function getOrCreateUserVirtualApiKey(params: { orgId: string; - userId: string; + user: User; createdByUserId?: string | null; }): Promise<{ key: VirtualApiKey; secret: string }> { - const { orgId, userId, createdByUserId } = params; + const { orgId, user, createdByUserId } = params; - const existing = await selectUserVirtualApiKey(orgId, userId); + const existing = await selectUserVirtualApiKey(orgId, user.userId); if (existing) { return { key: existing, @@ -171,8 +172,8 @@ export async function getOrCreateUserVirtualApiKey(params: { virtualApiKeyId: minted.virtualApiKeyId, orgId, kind: "user", - userId, - name: null, + userId: user.userId, + name: `${user.name ?? user.username}'s API Key`, description: null, token: encryptVirtualApiKeyToken(minted.secret), lastChars: minted.lastChars, @@ -186,7 +187,7 @@ export async function getOrCreateUserVirtualApiKey(params: { return { key: created, secret: minted.secret }; } catch { - const raced = await selectUserVirtualApiKey(orgId, userId); + const raced = await selectUserVirtualApiKey(orgId, user.userId); if (raced) { return { key: raced, @@ -199,11 +200,11 @@ export async function getOrCreateUserVirtualApiKey(params: { export async function rotateUserVirtualApiKey(params: { orgId: string; - userId: string; + user: User; createdByUserId?: string | null; }): Promise<{ key: VirtualApiKey; secret: string }> { - const { orgId, userId, createdByUserId } = params; - const existing = await selectUserVirtualApiKey(orgId, userId); + const { orgId, user, createdByUserId } = params; + const existing = await selectUserVirtualApiKey(orgId, user.userId); if (!existing) { return getOrCreateUserVirtualApiKey(params); diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 2d916a97a..06ca9cdae 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -180,15 +180,16 @@ export function applyRequestUserHeaders( if (!requestUser) { return; } - headers["Remote-User"] = requestUser.username; + const remoteHeaders = config.getRawConfig().server.remote_headers; + headers[remoteHeaders.user] = requestUser.username; if (requestUser.email) { - headers["Remote-Email"] = requestUser.email; + headers[remoteHeaders.email] = requestUser.email; } if (requestUser.name) { - headers["Remote-Name"] = requestUser.name; + headers[remoteHeaders.name] = requestUser.name; } if (requestUser.role) { - headers["Remote-Role"] = requestUser.role; + headers[remoteHeaders.role] = requestUser.role; } } @@ -236,14 +237,20 @@ async function resolveRequestUser( // Public inference: identity comes from Badger via Remote-* only when the // Traefik trust header proves the request passed verify-session (VAK). if (isAiGatewayTrustHeaderValid(req.headers as Record)) { + const remoteHeaders = config.getRawConfig().server.remote_headers; const virtualApiKeyId = - getRequestHeader(req, "remote-virtual-api-key-id") || null; - const userId = getRequestHeader(req, "remote-user-id"); + getRequestHeader(req, remoteHeaders.virtual_api_key_id) || null; + const userId = getRequestHeader(req, remoteHeaders.user_id); + logger.debug("+++++++AI gateway request identity from trust header", { + virtualApiKeyId, + userId + }); if (userId) { - const username = getRequestHeader(req, "remote-user") || userId; - const email = getRequestHeader(req, "remote-email"); - const name = getRequestHeader(req, "remote-name"); - const role = getRequestHeader(req, "remote-role"); + const username = + getRequestHeader(req, remoteHeaders.user) || userId; + const email = getRequestHeader(req, remoteHeaders.email); + const name = getRequestHeader(req, remoteHeaders.name); + const role = getRequestHeader(req, remoteHeaders.role); const orgRoles = orgId ? await getUserOrgRoles(userId, orgId) : []; return { @@ -742,6 +749,9 @@ export async function handleAiGatewayProxy( } logger.info(`AI gateway ${capability} request for host: ${host}`); + logger.debug("AI gateway request headers", { + headers: req.headers + }); const target = await resolveTarget(host); if (!target) { diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index e1847d2ed..02e05f5e0 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -68,7 +68,27 @@ export async function traefikConfigProvider( resourceSessionRequestParam: config.getRawConfig().server - .resource_session_request_param + .resource_session_request_param, + + remoteUserIdHeader: + config.getRawConfig().server.remote_headers + .user_id, + + remoteVirtualApiKeyIdHeader: + config.getRawConfig().server.remote_headers + .virtual_api_key_id, + + remoteUserHeader: + config.getRawConfig().server.remote_headers.user, + + remoteEmailHeader: + config.getRawConfig().server.remote_headers.email, + + remoteNameHeader: + config.getRawConfig().server.remote_headers.name, + + remoteRoleHeader: + config.getRawConfig().server.remote_headers.role } } }; diff --git a/server/routers/virtualApiKey/listMyVirtualApiKeys.ts b/server/routers/virtualApiKey/listMyVirtualApiKeys.ts index 5177a975a..46531560a 100644 --- a/server/routers/virtualApiKey/listMyVirtualApiKeys.ts +++ b/server/routers/virtualApiKey/listMyVirtualApiKeys.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { db, resources, + users, virtualApiKeyResources, virtualApiKeys, type VirtualApiKey @@ -152,9 +153,24 @@ export async function listMyVirtualApiKeys( resourceName = resource.name; } + const [user] = await db + .select() + .from(users) + .where(eq(users.userId, userId)) + .limit(1); + + if (!user) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with ID ${userId} not found` + ) + ); + } + const { key: userKeyRow } = await getOrCreateUserVirtualApiKey({ orgId, - userId, + user, createdByUserId: userId }); diff --git a/src/app/[orgId]/settings/logs/ai/page.tsx b/src/app/[orgId]/settings/logs/ai/page.tsx index 15c573337..8c077929b 100644 --- a/src/app/[orgId]/settings/logs/ai/page.tsx +++ b/src/app/[orgId]/settings/logs/ai/page.tsx @@ -577,9 +577,6 @@ export default function AiSessionLogsPage() {
{row.usage && (
-
- {t("tokenUsage")} -
{t("promptTokens")} diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index a4d1dec37..32e4f4b86 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -247,6 +247,7 @@ export default function GeneralPage() { // 106 - Valid email // 107 - Valid SSO // 108 - Connected Client + // 109 - Valid Virtual API Key // 201 - Resource Not Found // 202 - Resource Blocked @@ -265,6 +266,7 @@ export default function GeneralPage() { 106: t("validEmail"), 107: t("validSSO"), 108: t("connectedClient"), + 109: t("validVirtualAPIKey"), 201: t("resourceNotFound"), 202: t("resourceBlocked"), 203: t("droppedByRule"), diff --git a/src/components/AiUsageAnalyticsData.tsx b/src/components/AiUsageAnalyticsData.tsx index a5d3db2b8..3cb35198e 100644 --- a/src/components/AiUsageAnalyticsData.tsx +++ b/src/components/AiUsageAnalyticsData.tsx @@ -161,120 +161,107 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) { return (
- -
- + + - + -
- - setFilter("providerId", v) - } - /> - setFilter("model", v)} - /> - - setFilter("resourceId", v) - } - /> - setFilter("roleId", v)} - /> - setFilter("userId", v)} - /> - - setFilter("virtualApiKeyId", v) - } - /> + setFilter("providerId", v)} + /> + setFilter("model", v)} + /> + setFilter("resourceId", v)} + /> + setFilter("roleId", v)} + /> + setFilter("userId", v)} + /> + setFilter("virtualApiKeyId", v)} + /> - {!isEmptySearchParams && ( - - )} -
-
-
+ {!isEmptySearchParams && ( -
+ )} + +
@@ -301,7 +288,7 @@ type FilterSelectProps = { function FilterSelect(props: FilterSelectProps) { return ( -
+
- - - - )} - /> - - { + const previousType = + field.value; field.onChange( value ); @@ -293,6 +277,30 @@ export default function CreateAiProviderPage() { value !== "custom" ) { + const currentName = + form.getValues( + "name" + ); + const previousLabel = + t( + aiProviderTypeLabelMap[ + previousType + ] + ); + if ( + !currentName.trim() || + currentName === + previousLabel + ) { + form.setValue( + "name", + t( + aiProviderTypeLabelMap[ + value + ] + ) + ); + } form.setValue( "routingMode", "url" @@ -309,6 +317,27 @@ export default function CreateAiProviderPage() { /> + + ( + + + {t("name")} + + + + + + + )} + /> + + aiProviderTypeValues.map((type) => ({ type, - title: t(typeLabelMap[type]), + title: t(aiProviderTypeLabelMap[type]), description: t(typeDescriptionMap[type]) })), [t] From e100645a00e48a339a2c9cd9dfd31da8b8e1589c Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 13 Aug 2026 09:37:56 -0400 Subject: [PATCH 329/494] Use the right subnet --- server/routers/aiGateway/pipeline.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 3cc94e30e..50d5e98eb 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -123,7 +123,12 @@ async function findClientByIp(ip: string): Promise { const [client] = await db .select({ clientId: clients.clientId, userId: clients.userId }) .from(clients) - .where(eq(clients.exitNodeSubnet, `${ip}/32`)) + .where( + eq( + clients.exitNodeSubnet, + `${ip}/${config.getRawConfig().gerbil.site_block_size}` + ) + ) .limit(1); const result: CachedClient = client || null; @@ -323,6 +328,8 @@ async function resolveRequestUser( return { user: null, virtualApiKeyId: null }; } + logger.debug(`AI gateway request from IP ${ip} (site-resource)`); + const exitNodeRanges = await getExitNodeRanges(); const inExitNodeRange = exitNodeRanges.some((range) => isIpInCidr(ip, range) From c35ac493ed6bf98596ece8172f71b6b2071ddaf8 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 13 Aug 2026 09:38:22 -0400 Subject: [PATCH 330/494] Rebalance the data in the expand column --- src/app/[orgId]/settings/logs/ai/page.tsx | 30 +++-------------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/src/app/[orgId]/settings/logs/ai/page.tsx b/src/app/[orgId]/settings/logs/ai/page.tsx index 8c077929b..512278a6c 100644 --- a/src/app/[orgId]/settings/logs/ai/page.tsx +++ b/src/app/[orgId]/settings/logs/ai/page.tsx @@ -551,27 +551,9 @@ export default function AiSessionLogsPage() {

- {t("virtualApiKey")} -

- {row.virtualApiKeyId ? ( - <> - {row.virtualApiKeyName ?? - t("aiUsageUnnamedVirtualApiKey")} - {row.virtualApiKeyLastChars && ( - <> - {" "} - ( - {formatVirtualApiKeyPreview( - row.virtualApiKeyId, - row.virtualApiKeyLastChars - )} - ) - - )} - - ) : ( - t("noVirtualApiKey") - )} + {t("totalTokens")} +

+ {row.usage.totalTokens.toLocaleString()}

@@ -608,12 +590,6 @@ export default function AiSessionLogsPage() { {row.usage.reasoningTokens.toLocaleString()}

-
- {t("totalTokens")} -

- {row.usage.totalTokens.toLocaleString()} -

-
)} From 9a551c91e53c6e18e87c9a0f88ccaa75b0fb6fc4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 13 Aug 2026 10:07:01 -0400 Subject: [PATCH 331/494] always require upstream url --- messages/en-US.json | 2 +- .../[providerId]/network/page.tsx | 35 ++++--------------- .../settings/ai-providers/create/page.tsx | 23 +++--------- src/lib/aiProviderDefaults.ts | 9 ++--- src/lib/aiProviderFormSchema.ts | 11 ------ 5 files changed, 13 insertions(+), 67 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 660ffa5c9..e7b7f3c12 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1777,7 +1777,7 @@ "aiProviderTypeMicrosoftFoundryDescription": "Microsoft Foundry; upstream URL required", "aiProviderTypeOpenRouterDescription": "OpenRouter API", "aiProviderTypeVercelAiGatewayDescription": "Vercel AI Gateway", - "aiProviderTypeCustomDescription": "Bring your own OpenAI-compatible endpoint or route via Pangolin targets", + "aiProviderTypeCustomDescription": "Bring your own endpoint or route via site targets", "aiProviderUpstreamUrl": "Upstream URL", "aiProviderUpstreamUrlDescription": "Base URL for the provider API", "aiProviderUpstreamUrlOptionalDescription": "Leave blank to use the default upstream URL for this provider", diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx index e685bc560..fafbdf49f 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx @@ -41,7 +41,6 @@ import { createAiProviderFormSchema, showsUpstreamUrlField, toAiProviderNetworkPayload, - upstreamUrlRequired, type AiProviderFormValues } from "@app/lib/aiProviderFormSchema"; import { aiProviderQueries } from "@app/lib/queries"; @@ -91,7 +90,6 @@ export default function AiProviderNetworkPage() { const providerType = form.watch("type"); const routingMode = form.watch("routingMode"); const showUpstream = showsUpstreamUrlField(providerType, routingMode); - const requireUpstream = upstreamUrlRequired(providerType, routingMode); const showRoutingMode = providerType === "custom"; const isTargetModeSelected = routingMode === "target"; const isTargetModeSaved = @@ -218,22 +216,9 @@ export default function AiProviderNetworkPage() { field.value ?? "url" } - onChange={( - value - ) => { - field.onChange( - value - ); - if ( - value === - "target" - ) { - form.setValue( - "upstreamUrl", - "" - ); - } - }} + onChange={ + field.onChange + } /> @@ -284,14 +269,10 @@ export default function AiProviderNetworkPage() { {t( "aiProviderUpstreamUrl" )} - {requireUpstream - ? "" - : " (optional)"} - {requireUpstream - ? t( - "aiProviderUpstreamUrlDescription" - ) - : t( - "aiProviderUpstreamUrlOptionalDescription" - )} + {t( + "aiProviderUpstreamUrlDescription" + )} diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 6b47146a5..c9125659d 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -49,7 +49,6 @@ import { emptyUpstreamForType, showsUpstreamUrlField, toAiProviderCreatePayload, - upstreamUrlRequired, type AiProviderFormValues } from "@app/lib/aiProviderFormSchema"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -95,7 +94,6 @@ export default function CreateAiProviderPage() { const authType = form.watch("authType"); const showUpstream = showsUpstreamUrlField(providerType, routingMode); - const requireUpstream = upstreamUrlRequired(providerType, routingMode); const showRoutingMode = providerType === "custom"; const showTargets = providerType === "custom" && routingMode === "target"; const showApiKey = authTypeRequiresApiKey(authType ?? "bearer"); @@ -436,14 +434,9 @@ export default function CreateAiProviderPage() { value ); if ( - value === + value !== "target" ) { - form.setValue( - "upstreamUrl", - "" - ); - } else { targetsRef.current = []; } @@ -498,14 +491,10 @@ export default function CreateAiProviderPage() { {t( "aiProviderUpstreamUrl" )} - {requireUpstream - ? "" - : " (optional)"} - {requireUpstream - ? t( - "aiProviderUpstreamUrlDescription" - ) - : t( - "aiProviderUpstreamUrlOptionalDescription" - )} + {t( + "aiProviderUpstreamUrlDescription" + )} diff --git a/src/lib/aiProviderDefaults.ts b/src/lib/aiProviderDefaults.ts index da4c43739..96fbb5e18 100644 --- a/src/lib/aiProviderDefaults.ts +++ b/src/lib/aiProviderDefaults.ts @@ -85,13 +85,8 @@ export function providerRequiresUpstreamUrl( type: AiProviderType, routingMode: AiProviderRoutingMode = "url" ): boolean { - if (routingMode === "target") { - return false; - } - if (type === "custom") { - return true; - } - return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null; + const mode = type === "custom" ? routingMode : "url"; + return mode !== "target"; } export function defaultsForProviderType( diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index e033c0185..a425fb0e3 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -148,17 +148,6 @@ export function emptyUpstreamForType(type: AiProviderType): string { export function showsUpstreamUrlField( type: AiProviderType, routingMode: "url" | "target" | undefined -): boolean { - const mode = type === "custom" ? (routingMode ?? "url") : "url"; - if (mode === "target") { - return false; - } - return true; -} - -export function upstreamUrlRequired( - type: AiProviderType, - routingMode: "url" | "target" | undefined ): boolean { const mode = type === "custom" ? (routingMode ?? "url") : "url"; return providerRequiresUpstreamUrl(type, mode); From 77e3422e88c38baa17db22b8dd00cf6aed6b19fe Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 13 Aug 2026 10:13:32 -0400 Subject: [PATCH 332/494] change provider descriptions and dont set default url for bedrock --- messages/en-US.json | 14 +++++++------- src/lib/aiProviderDefaults.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e7b7f3c12..25a94eb32 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1769,14 +1769,14 @@ "aiProviderTypeOpenRouter": "OpenRouter", "aiProviderTypeVercelAiGateway": "Vercel AI Gateway", "aiProviderTypeCustom": "Custom", - "aiProviderTypeOpenaiDescription": "OpenAI API with default upstream URL", - "aiProviderTypeAnthropicDescription": "Anthropic API with default upstream URL", - "aiProviderTypeGoogleGeminiDescription": "Google Gemini generateContent API", - "aiProviderTypeVertexAiDescription": "Google Vertex AI; upstream URL required", - "aiProviderTypeBedrockDescription": "Amazon Bedrock Runtime", - "aiProviderTypeMicrosoftFoundryDescription": "Microsoft Foundry; upstream URL required", + "aiProviderTypeOpenaiDescription": "OpenAI API", + "aiProviderTypeAnthropicDescription": "Anthropic API", + "aiProviderTypeGoogleGeminiDescription": "Google Gemini API", + "aiProviderTypeVertexAiDescription": "Google Vertex AI API", + "aiProviderTypeBedrockDescription": "Amazon Bedrock Runtime API", + "aiProviderTypeMicrosoftFoundryDescription": "Microsoft Foundry API", "aiProviderTypeOpenRouterDescription": "OpenRouter API", - "aiProviderTypeVercelAiGatewayDescription": "Vercel AI Gateway", + "aiProviderTypeVercelAiGatewayDescription": "Vercel AI Gateway API", "aiProviderTypeCustomDescription": "Bring your own endpoint or route via site targets", "aiProviderUpstreamUrl": "Upstream URL", "aiProviderUpstreamUrlDescription": "Base URL for the provider API", diff --git a/src/lib/aiProviderDefaults.ts b/src/lib/aiProviderDefaults.ts index 96fbb5e18..31499a19d 100644 --- a/src/lib/aiProviderDefaults.ts +++ b/src/lib/aiProviderDefaults.ts @@ -56,7 +56,7 @@ export const AI_PROVIDER_DEFAULTS: Record< capabilities: ["google_generate_content", "google_raw_predict"] }, bedrock: { - upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + upstreamUrl: null, authType: "bearer", capabilities: ["bedrock_converse"] }, From a2f5d830d60a422faace722c57f9b2de32badba6 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 13 Aug 2026 10:20:23 -0400 Subject: [PATCH 333/494] Remove not null constraint where setting null --- server/db/pg/schema/schema.ts | 14 ++++++++------ server/db/sqlite/schema/schema.ts | 14 ++++++++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 8efebc619..50fa3753b 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1826,9 +1826,10 @@ export const aiUsageRecords = pgTable( orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), - providerId: integer("providerId") - .notNull() - .references(() => aiProviders.providerId, { onDelete: "set null" }), + providerId: integer("providerId").references( + () => aiProviders.providerId, + { onDelete: "set null" } + ), resourceId: integer("resourceId").references( () => resources.resourceId, { onDelete: "set null" } @@ -1943,9 +1944,10 @@ export const aiSessionLog = pgTable( orgId: varchar("orgId").references(() => orgs.orgId, { onDelete: "cascade" }), - providerId: integer("providerId") - .notNull() - .references(() => aiProviders.providerId, { onDelete: "set null" }), + providerId: integer("providerId").references( + () => aiProviders.providerId, + { onDelete: "set null" } + ), capability: varchar("capability").notNull(), resourceId: integer("resourceId").references( () => resources.resourceId, diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index e46aef8d2..628563ee3 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1814,9 +1814,10 @@ export const aiUsageRecords = sqliteTable( orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), - providerId: integer("providerId") - .notNull() - .references(() => aiProviders.providerId, { onDelete: "set null" }), + providerId: integer("providerId").references( + () => aiProviders.providerId, + { onDelete: "set null" } + ), resourceId: integer("resourceId").references( () => resources.resourceId, { onDelete: "set null" } @@ -1933,9 +1934,10 @@ export const aiSessionLog = sqliteTable( orgId: text("orgId").references(() => orgs.orgId, { onDelete: "cascade" }), - providerId: integer("providerId") - .notNull() - .references(() => aiProviders.providerId, { onDelete: "set null" }), + providerId: integer("providerId").references( + () => aiProviders.providerId, + { onDelete: "set null" } + ), capability: text("capability").notNull(), resourceId: integer("resourceId").references( () => resources.resourceId, From e92bb9043b7f1fd424f5f5c5ddbe0b48bfd7557f Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 13 Aug 2026 11:04:11 -0400 Subject: [PATCH 334/494] Fix issues with possible null providerId --- server/routers/auditLogs/queryAiSessionLog.ts | 13 +++++++-- .../auditLogs/queryAiUsageProviders.ts | 27 ++++++++++++++----- server/routers/auditLogs/types.ts | 2 +- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/server/routers/auditLogs/queryAiSessionLog.ts b/server/routers/auditLogs/queryAiSessionLog.ts index 437d74550..ac2d99b65 100644 --- a/server/routers/auditLogs/queryAiSessionLog.ts +++ b/server/routers/auditLogs/queryAiSessionLog.ts @@ -208,7 +208,14 @@ async function enrichWithDetails( type: aiProviders.type }) .from(aiProviders) - .where(inArray(aiProviders.providerId, providerIds)); + .where( + inArray( + aiProviders.providerId, + providerIds.filter( + (id): id is number => id !== null && id !== undefined + ) + ) + ); for (const p of providerDetails) { providerMap.set(p.providerId, { name: p.name, type: p.type }); @@ -336,7 +343,9 @@ async function enrichWithDetails( } return logs.map((log) => { - const provider = providerMap.get(log.providerId); + const provider = log.providerId + ? providerMap.get(log.providerId) + : null; let resourceId = log.resourceId; let resourceName: string | null = null; diff --git a/server/routers/auditLogs/queryAiUsageProviders.ts b/server/routers/auditLogs/queryAiUsageProviders.ts index 77df6330d..8536ad79f 100644 --- a/server/routers/auditLogs/queryAiUsageProviders.ts +++ b/server/routers/auditLogs/queryAiUsageProviders.ts @@ -93,9 +93,17 @@ async function query(data: Q) { const nameMap = new Map(); if (providerIds.length > 0) { const providerDetails = await db - .select({ providerId: aiProviders.providerId, name: aiProviders.name }) + .select({ + providerId: aiProviders.providerId, + name: aiProviders.name + }) .from(aiProviders) - .where(inArray(aiProviders.providerId, providerIds)); + .where( + inArray( + aiProviders.providerId, + providerIds.filter((id): id is number => id !== null) + ) + ); for (const p of providerDetails) { nameMap.set(p.providerId, p.name); } @@ -103,7 +111,7 @@ async function query(data: Q) { const topProviders = topProvidersRaw.map((r) => ({ providerId: r.providerId, - name: nameMap.get(r.providerId) ?? null, + name: r.providerId ? (nameMap.get(r.providerId) ?? null) : null, requests: r.requests, totalTokens: r.totalTokens, costUsd: r.costUsd @@ -119,7 +127,8 @@ async function query(data: Q) { registry.registerPath({ method: "get", path: "/org/{orgId}/logs/ai/usage/providers", - description: "Query the AI usage analytics provider breakdown for an organization", + description: + "Query the AI usage analytics provider breakdown for an organization", tags: [OpenAPITags.Logs], request: { query: aiUsageAnalyticsFiltersQuery, @@ -154,14 +163,20 @@ export async function queryAiUsageProviders( const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query); if (!parsedQuery.success) { return next( - createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error)) + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) ); } const parsedParams = aiUsageAnalyticsParams.safeParse(req.params); if (!parsedParams.success) { return next( - createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error)) + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) ); } diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index dc8d4015f..21663f6ce 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -99,7 +99,7 @@ export type QueryAiSessionLogResponse = { id: number; sessionId: string; orgId: string | null; - providerId: number; + providerId: number | null; providerName: string | null; providerType: string | null; capability: string; From 1143404a65832ea5b84dbdabb056adbac89fab56 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 13 Aug 2026 19:15:23 +0200 Subject: [PATCH 335/494] =?UTF-8?q?=F0=9F=92=84alert=20rule=20popover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alert-rule-editor/AlertRuleFields.tsx | 67 +++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 7ae6d0a04..8780ea102 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -45,7 +45,14 @@ import { import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; -import { Bell, ChevronsUpDown, Globe, Plus, Trash2 } from "lucide-react"; +import { + Bell, + ChevronRightIcon, + ChevronsUpDown, + Globe, + Plus, + Trash2 +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useMemo, useRef, useState } from "react"; import type { Control, UseFormReturn } from "react-hook-form"; @@ -95,6 +102,7 @@ export function AddActionPanel({ const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id); const [selected, setSelected] = useState("notify"); + const [isPopoverOpen, setPopoverOpen] = useState(false); const isPremiumSelected = selected !== null && EXTERNAL_IDS.includes(selected as any); @@ -131,27 +139,48 @@ export function AddActionPanel({ if (!isBuiltInSelected) return; onAdd(selected as AlertRuleFormAction["type"]); setSelected(null); + setPopoverOpen(false); }; return ( -
- setSelected(v)} - /> - {isPremiumSelected && } - {!isPremiumSelected && ( - - )} +
+

Add new action

+ + + + + + setSelected(v)} + /> + + {isPremiumSelected && } + {!isPremiumSelected && ( + + )} + + + {/* */}
); } From 2878d5690cf10046960cc30b714b0572762de6d8 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 13 Aug 2026 19:19:55 +0200 Subject: [PATCH 336/494] =?UTF-8?q?=F0=9F=92=AC=20update=20texts=20for=20h?= =?UTF-8?q?eading=20&=20trigger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 ++ .../alert-rule-editor/AlertRuleFields.tsx | 14 +++----------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 05b40d27d..7b0f0b4b3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1807,6 +1807,8 @@ "alertingHealthChecksBannerTitle": "Monitor Health & Resources", "alertingHealthChecksBannerDescription": "Health checks are HTTP or TCP monitors you define once. You can then use them as sources in alert rules so you get notified when a target becomes healthy or unhealthy. Health checks on resources also appear here.", "alertingTestRule": "Test Alert Rule", + "alertingAddActionHeading": "Add New Action", + "alertingSelectActionType": "Choose an action type", "alertingNoActionsTitle": "No actions configured", "alertingNoActionsSaveDescription": "Add at least one action so this rule can notify someone when it fires.", "alertingNoActionsTestDescription": "Add at least one action before you can test this rule.", diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 8780ea102..53fa9cdc1 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -144,18 +144,11 @@ export function AddActionPanel({ return (
-

Add new action

+

{t("alertingAddActionHeading")}

- @@ -180,7 +173,6 @@ export function AddActionPanel({ )} - {/* */}
); } From 9bb413bb1e39cd8e9b9ed37e9827145e3aaa5bf0 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 13 Aug 2026 19:23:39 +0200 Subject: [PATCH 337/494] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/alert-rule-editor/AlertRuleFields.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 53fa9cdc1..147369a9e 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -160,8 +160,9 @@ export function AddActionPanel({ onChange={(v) => setSelected(v)} /> - {isPremiumSelected && } - {!isPremiumSelected && ( + {isPremiumSelected ? ( + + ) : ( +
+ ) : null} + {hasOverflow && listExpanded ? ( +
+ +
) : null}
- {isCollapsed ? ( -
- -
- ) : null} - {hasOverflow && listExpanded ? ( -
- -
- ) : null} -
+ )}
( - [] - ); + const [pendingBudgetRows, setPendingBudgetRows] = useState([]); const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false); const [savingBudgets, setSavingBudgets] = useState(false); @@ -929,9 +923,7 @@ function EditModelCredenza({ items={[ { title: t("general"), href: "#" }, { - title: t( - "aiProviderModelsBudgetTab" - ), + title: t("aiProviderModelsBudgetTab"), href: "#" } ]} @@ -970,9 +962,7 @@ function EditModelCredenza({

) : (

- {t( - "aiProviderModelsBudgetUnsaved" - )} + {t("aiProviderModelsBudgetUnsaved")}

)}
diff --git a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx index 51b718e88..f6d18b7cf 100644 --- a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx +++ b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx @@ -573,7 +573,6 @@ export function PolicyAuthStackSectionEdit({ className="text-primary hover:underline" > {inferenceResourceUrl} - ) } diff --git a/src/components/resource-target-address-item.tsx b/src/components/resource-target-address-item.tsx index 58ef3b33b..79f72369c 100644 --- a/src/components/resource-target-address-item.tsx +++ b/src/components/resource-target-address-item.tsx @@ -142,6 +142,8 @@ export function ResourceTargetAddressItem({ isHttp, allowedMethods = ["http", "https", "h2c"] }: ResourceTargetAddressItemProps) { + "use no memo"; + return (
From 9b10292e025a4d00c68b062799f4c9d7b065328d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 13 Aug 2026 15:08:10 -0400 Subject: [PATCH 341/494] add model picker to create provider wizard --- messages/en-US.json | 1 + server/lib/aiModelCatalog.ts | 30 +++++ server/routers/aiProvider/index.ts | 1 + .../routers/aiProvider/listCatalogModels.ts | 35 +----- .../aiProvider/listCatalogModelsByType.ts | 81 +++++++++++++ server/routers/external.ts | 7 ++ .../ai-providers/[providerId]/models/page.tsx | 60 ++-------- .../settings/ai-providers/create/page.tsx | 107 ++++++++++++++++++ src/components/AiProviderModelsLists.tsx | 80 +++++++++++++ src/lib/queries.ts | 20 ++++ 10 files changed, 341 insertions(+), 81 deletions(-) create mode 100644 server/routers/aiProvider/listCatalogModelsByType.ts create mode 100644 src/components/AiProviderModelsLists.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 137440d42..a3bfe73b8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1864,6 +1864,7 @@ "aiProviderErrorNoUpdate": "AI provider is not available to update", "aiProviderModels": "Models", "aiProviderModelsDescription": "Define allow and block lists for this provider. Requests must match an allow entry and must not match a block entry.", + "aiProviderCreateModelsDescription": "Choose which models this provider can serve. An empty allow list denies all traffic. You can add or change models later.", "aiProviderModelsPlaceholder": "Search models or type a custom key", "aiProviderModelsAllow": "Allow List", "aiProviderModelsAllowDescription": "Models that may be used through this provider. Empty means deny all.", diff --git a/server/lib/aiModelCatalog.ts b/server/lib/aiModelCatalog.ts index a74378e6b..5f2f7fb94 100644 --- a/server/lib/aiModelCatalog.ts +++ b/server/lib/aiModelCatalog.ts @@ -284,6 +284,36 @@ export class AiModelCatalog { export const aiModelCatalog = new AiModelCatalog(); +export function listCatalogModelsForType( + type: AiProviderType, + query?: string +): { model: string }[] { + const catalogProvider = getCatalogProviderForType(type); + + let models = catalogProvider + ? aiModelCatalog.list(catalogProvider).map((entry) => ({ + model: entry.model + })) + : []; + + if (query) { + const q = query.toLowerCase(); + models = models.filter((m) => m.model.toLowerCase().includes(q)); + } + + const seen = new Set(); + models = models.filter((m) => { + if (seen.has(m.model)) { + return false; + } + seen.add(m.model); + return true; + }); + + models.sort((a, b) => a.model.localeCompare(b.model)); + return models; +} + /** * Loads the AI model pricing catalog into memory and schedules periodic * background refreshes. Call once at server startup. diff --git a/server/routers/aiProvider/index.ts b/server/routers/aiProvider/index.ts index 835991fbd..32378d3d9 100644 --- a/server/routers/aiProvider/index.ts +++ b/server/routers/aiProvider/index.ts @@ -6,6 +6,7 @@ export * from "./deleteAiProvider"; export * from "./createAiModel"; export * from "./listAiModels"; export * from "./listCatalogModels"; +export * from "./listCatalogModelsByType"; export * from "./getAiModel"; export * from "./updateAiModel"; export * from "./deleteAiModel"; diff --git a/server/routers/aiProvider/listCatalogModels.ts b/server/routers/aiProvider/listCatalogModels.ts index 9a3241276..5320ada8a 100644 --- a/server/routers/aiProvider/listCatalogModels.ts +++ b/server/routers/aiProvider/listCatalogModels.ts @@ -8,10 +8,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { eq } from "drizzle-orm"; -import { - aiModelCatalog, - getCatalogProviderForType -} from "@server/lib/aiModelCatalog"; +import { listCatalogModelsForType } from "@server/lib/aiModelCatalog"; import type { AiProviderType } from "@server/lib/aiProviderDefaults"; import type { ListCatalogModelsResponse } from "@server/routers/aiProvider/types"; @@ -86,35 +83,11 @@ export async function listCatalogModels( ); } - const catalogProvider = getCatalogProviderForType( - provider.type as AiProviderType + const models = listCatalogModelsForType( + provider.type as AiProviderType, + parsedQuery.data.query ); - let models = catalogProvider - ? aiModelCatalog.list(catalogProvider).map((entry) => ({ - model: entry.model - })) - : []; - - const { query } = parsedQuery.data; - if (query) { - const q = query.toLowerCase(); - models = models.filter((m) => m.model.toLowerCase().includes(q)); - } - - // Deduplicate model keys (catalog may have duplicates after provider - // normalization, e.g. bedrock + bedrock_converse). - const seen = new Set(); - models = models.filter((m) => { - if (seen.has(m.model)) { - return false; - } - seen.add(m.model); - return true; - }); - - models.sort((a, b) => a.model.localeCompare(b.model)); - return response(res, { data: { models }, success: true, diff --git a/server/routers/aiProvider/listCatalogModelsByType.ts b/server/routers/aiProvider/listCatalogModelsByType.ts new file mode 100644 index 000000000..4fbca54e9 --- /dev/null +++ b/server/routers/aiProvider/listCatalogModelsByType.ts @@ -0,0 +1,81 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { listCatalogModelsForType } from "@server/lib/aiModelCatalog"; +import type { ListCatalogModelsResponse } from "@server/routers/aiProvider/types"; +import { aiProviderTypeSchema } from "@server/routers/aiProvider/validation"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const querySchema = z.strictObject({ + type: aiProviderTypeSchema, + query: z.string().optional() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/ai-catalog-models", + description: + "List known catalog models for an AI provider type. Used for model key suggestions before a provider exists.", + tags: [OpenAPITags.AiModel], + request: { + params: paramsSchema, + query: querySchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listCatalogModelsByType( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { type, query } = parsedQuery.data; + const models = listCatalogModelsForType(type, query); + + return response(res, { + data: { models }, + success: true, + error: false, + message: "Catalog models retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index c391665b9..17cf0d921 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1645,6 +1645,13 @@ authenticated.get( aiProvider.listCatalogModels ); +authenticated.get( + "/org/:orgId/ai-catalog-models", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listAiModels), + aiProvider.listCatalogModelsByType +); + authenticated.get( "/ai-model/:modelId", verifyAiModelAccess, diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx index f8ace5cb2..b85c0076c 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx @@ -11,12 +11,11 @@ import { SettingsSectionTitle } from "@app/components/Settings"; import { - AiProviderModelListEditor, type AiProviderModelListItem, type ModelListType } from "@app/components/AiProviderModelListEditor"; +import { AiProviderModelsLists } from "@app/components/AiProviderModelsLists"; import { Button } from "@app/components/ui/button"; -import { Label } from "@app/components/ui/label"; import { useAiProviderContext } from "@app/hooks/useAiProviderContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; @@ -65,15 +64,6 @@ export default function AiProviderModelsPage() { [catalogQuery.data] ); - const allowExcludeKeys = useMemo( - () => new Set(blockItems.map((item) => item.modelKey)), - [blockItems] - ); - const blockExcludeKeys = useMemo( - () => new Set(allowItems.map((item) => item.modelKey)), - [allowItems] - ); - useEffect(() => { if (!modelsQuery.data) return; setAllowItems( @@ -232,45 +222,15 @@ export default function AiProviderModelsPage() { -
- - -

- {t("aiProviderModelsAllowDescription")} -

-
- -
- - -

- {t("aiProviderModelsBlockDescription")} -

-
+
diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 722874028..7684b2d48 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -21,6 +21,11 @@ import { import HeaderTitle from "@app/components/SettingsSectionTitle"; import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect"; import { AiProviderCapabilitiesSelect } from "@app/components/AiProviderCapabilitiesSelect"; +import { + type AiProviderModelListItem, + type ModelListType +} from "@app/components/AiProviderModelListEditor"; +import { AiProviderModelsLists } from "@app/components/AiProviderModelsLists"; import { AiProviderTypeSelect, aiProviderTypeLabelMap @@ -53,7 +58,9 @@ import { } from "@app/lib/aiProviderFormSchema"; import { zodResolver } from "@hookform/resolvers/zod"; import { authTypeRequiresApiKey } from "@app/lib/aiProviderDefaults"; +import { aiProviderQueries } from "@app/lib/queries"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; +import { useQuery } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; @@ -69,6 +76,8 @@ export default function CreateAiProviderPage() { const t = useTranslations(); const [loading, setLoading] = useState(false); const [headersValid, setHeadersValid] = useState(true); + const [allowItems, setAllowItems] = useState([]); + const [blockItems, setBlockItems] = useState([]); const targetsRef = useRef([]); const formSchema = useMemo(() => createAiProviderCreateFormSchema(t), [t]); @@ -98,6 +107,17 @@ export default function CreateAiProviderPage() { const showTargets = providerType === "custom" && routingMode === "target"; const showApiKey = authTypeRequiresApiKey(authType ?? "bearer"); + const catalogQuery = useQuery( + aiProviderQueries.catalogModelsByType({ + orgId, + type: providerType + }) + ); + const catalogModels = useMemo( + () => (catalogQuery.data ?? []).map((entry) => entry.model), + [catalogQuery.data] + ); + async function createTargets( providerId: number, localTargets: LocalTarget[] @@ -135,6 +155,19 @@ export default function CreateAiProviderPage() { } } + async function createModels( + providerId: number, + items: { modelKey: string; listType: ModelListType }[] + ) { + for (const item of items) { + await api.put(`/ai-provider/${providerId}/model`, { + modelKey: item.modelKey, + name: item.modelKey, + listType: item.listType + }); + } + } + async function onSubmit(values: AiProviderFormValues) { const targets = targetsRef.current; @@ -157,6 +190,31 @@ export default function CreateAiProviderPage() { } } + const nextAllow = new Set( + allowItems.map((item) => item.modelKey.trim()).filter(Boolean) + ); + const nextBlock = new Set( + blockItems.map((item) => item.modelKey.trim()).filter(Boolean) + ); + const overlap = [...nextAllow].filter((key) => nextBlock.has(key)); + if (overlap.length > 0) { + toast({ + variant: "destructive", + title: t("aiProviderErrorCreate"), + description: t("aiProviderModelsOverlapError", { + keys: overlap.join(", ") + }) + }); + return; + } + + const modelItems = [...allowItems, ...blockItems] + .map((item) => ({ + modelKey: item.modelKey.trim(), + listType: item.listType + })) + .filter((item) => item.modelKey); + setLoading(true); try { const res = await api.put< @@ -184,6 +242,25 @@ export default function CreateAiProviderPage() { } } + if (modelItems.length > 0) { + try { + await createModels(providerId, modelItems); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiProviderErrorCreate"), + description: formatAxiosError( + e, + t("aiProviderErrorCreate") + ) + }); + router.push( + `/${orgId}/settings/ai-providers/${providerId}/models` + ); + return; + } + } + toast({ title: t("success"), description: t("aiProviderCreated") @@ -253,6 +330,12 @@ export default function CreateAiProviderPage() { field.onChange( value ); + setAllowItems( + [] + ); + setBlockItems( + [] + ); form.setValue( "upstreamUrl", emptyUpstreamForType( @@ -689,6 +772,30 @@ export default function CreateAiProviderPage() { + + + + + {t("aiProviderModels")} + + + {t("aiProviderCreateModelsDescription")} + + + + + + + + +
diff --git a/src/components/AiProviderModelsLists.tsx b/src/components/AiProviderModelsLists.tsx new file mode 100644 index 000000000..8ed7f6fef --- /dev/null +++ b/src/components/AiProviderModelsLists.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { + AiProviderModelListEditor, + type AiProviderModelListItem +} from "@app/components/AiProviderModelListEditor"; +import { Label } from "@app/components/ui/label"; +import { useTranslations } from "next-intl"; +import { useMemo } from "react"; + +export type AiProviderModelsListsProps = { + orgId: string; + allowItems: AiProviderModelListItem[]; + onAllowChange: (items: AiProviderModelListItem[]) => void; + blockItems: AiProviderModelListItem[]; + onBlockChange: (items: AiProviderModelListItem[]) => void; + catalogModels: string[]; + disabled?: boolean; +}; + +export function AiProviderModelsLists({ + orgId, + allowItems, + onAllowChange, + blockItems, + onBlockChange, + catalogModels, + disabled +}: AiProviderModelsListsProps) { + const t = useTranslations(); + + const allowExcludeKeys = useMemo( + () => new Set(blockItems.map((item) => item.modelKey)), + [blockItems] + ); + const blockExcludeKeys = useMemo( + () => new Set(allowItems.map((item) => item.modelKey)), + [allowItems] + ); + + return ( +
+
+ + +

+ {t("aiProviderModelsAllowDescription")} +

+
+ +
+ + +

+ {t("aiProviderModelsBlockDescription")} +

+
+
+ ); +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 56a41cee8..c58c6c567 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -75,6 +75,7 @@ import type { ListAiProvidersResponse, ListCatalogModelsResponse } from "@server/routers/aiProvider/types"; +import type { AiProviderType } from "@app/lib/aiProviderDefaults"; import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types"; import { getAiBudgetScopeListPath, @@ -1495,6 +1496,25 @@ export const aiProviderQueries = { return res.data.data.models; } }), + catalogModelsByType: ({ + orgId, + type + }: { + orgId: string; + type: AiProviderType; + }) => + queryOptions({ + queryKey: ["AI_PROVIDERS", orgId, "CATALOG_MODELS", type] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/ai-catalog-models`, { + params: { type }, + signal + }); + return res.data.data.models; + } + }), orgProviders: ({ orgId, query }: { orgId: string; query?: string }) => queryOptions({ queryKey: ["AI_PROVIDERS", orgId, "LIST", query ?? ""] as const, From 4a9b0117a2b34528dfb3f937cd3cda60da93ddc5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 13 Aug 2026 15:16:39 -0400 Subject: [PATCH 342/494] copy key without clicking reveal first --- src/components/CopyTextBox.tsx | 34 ++++-- src/components/CopyToClipboard.tsx | 37 ++++-- src/components/UserVirtualApiKeys.tsx | 89 +++------------ .../LauncherInferenceApiKeysSection.tsx | 76 ++----------- src/hooks/useMyVirtualApiKeySecret.ts | 107 ++++++++++++++++++ 5 files changed, 183 insertions(+), 160 deletions(-) create mode 100644 src/hooks/useMyVirtualApiKeySecret.ts diff --git a/src/components/CopyTextBox.tsx b/src/components/CopyTextBox.tsx index e516bf0d6..5f12a7f39 100644 --- a/src/components/CopyTextBox.tsx +++ b/src/components/CopyTextBox.tsx @@ -8,29 +8,40 @@ import { useTranslations } from "next-intl"; type CopyTextBoxProps = { text?: string; displayText?: string; + getCopyText?: () => Promise; wrapText?: boolean; outline?: boolean; + centered?: boolean; }; export default function CopyTextBox({ text = "", displayText, + getCopyText, wrapText = false, - outline = true + outline = true, + centered = false }: CopyTextBoxProps) { const [isCopied, setIsCopied] = useState(false); + const [isCopying, setIsCopying] = useState(false); const textRef = useRef(null); const t = useTranslations(); const copyToClipboard = async () => { - if (textRef.current) { - try { - await navigator.clipboard.writeText(text); - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); - } catch (err) { - console.error(t("copyTextFailed"), err); - } + if (!textRef.current || isCopying) { + return; + } + + setIsCopying(true); + try { + const value = getCopyText ? await getCopyText() : text; + await navigator.clipboard.writeText(value); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } catch (err) { + console.error(t("copyTextFailed"), err); + } finally { + setIsCopying(false); } }; @@ -40,7 +51,9 @@ export default function CopyTextBox({ >
                 {isCopied ? (
diff --git a/src/components/CopyToClipboard.tsx b/src/components/CopyToClipboard.tsx
index 147972e78..344a2996a 100644
--- a/src/components/CopyToClipboard.tsx
+++ b/src/components/CopyToClipboard.tsx
@@ -1,5 +1,5 @@
 import { cn } from "@app/lib/cn";
-import { Check, Copy } from "lucide-react";
+import { Check, Copy, Loader2 } from "lucide-react";
 import Link from "next/link";
 import { useState } from "react";
 import { useTranslations } from "next-intl";
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
 type CopyToClipboardProps = {
     text: string;
     displayText?: string;
+    getCopyText?: () => Promise;
     isLink?: boolean;
     className?: string;
 };
@@ -14,18 +15,31 @@ type CopyToClipboardProps = {
 const CopyToClipboard = ({
     text,
     displayText,
+    getCopyText,
     isLink,
     className
 }: CopyToClipboardProps) => {
     const [copied, setCopied] = useState(false);
+    const [copying, setCopying] = useState(false);
 
-    const handleCopy = () => {
-        navigator.clipboard.writeText(text);
-        setCopied(true);
+    const handleCopy = async () => {
+        if (copying) {
+            return;
+        }
 
-        setTimeout(() => {
-            setCopied(false);
-        }, 2000);
+        setCopying(true);
+        try {
+            const value = getCopyText ? await getCopyText() : text;
+            await navigator.clipboard.writeText(value);
+            setCopied(true);
+            setTimeout(() => {
+                setCopied(false);
+            }, 2000);
+        } catch {
+            // Fetch errors are toasted by the caller; clipboard failures stay silent.
+        } finally {
+            setCopying(false);
+        }
     };
 
     const displayValue = displayText ?? text;
@@ -38,11 +52,14 @@ const CopyToClipboard = ({
                 type="button"
                 className="h-4 w-4 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
                 onClick={handleCopy}
+                disabled={copying}
             >
-                {!copied ? (
-                    
-                ) : (
+                {copying ? (
+                    
+                ) : copied ? (
                     
+                ) : (
+                    
                 )}
                 {t("copyText")}
             
diff --git a/src/components/UserVirtualApiKeys.tsx b/src/components/UserVirtualApiKeys.tsx
index 578f007b7..1ab2ae92d 100644
--- a/src/components/UserVirtualApiKeys.tsx
+++ b/src/components/UserVirtualApiKeys.tsx
@@ -1,8 +1,6 @@
 "use client";
 
-import { useState } from "react";
 import { useTranslations } from "next-intl";
-import { AxiosResponse } from "axios";
 import moment from "moment";
 import { Button } from "@app/components/ui/button";
 import CopyTextBox from "@app/components/CopyTextBox";
@@ -17,73 +15,18 @@ import {
     SettingsSectionHeader,
     SettingsSectionTitle as SectionTitle
 } from "@app/components/Settings";
-import { createApiClient, formatAxiosError } from "@app/lib/api";
-import { useEnvContext } from "@app/hooks/useEnvContext";
-import { toast } from "@app/hooks/useToast";
+import { useMyVirtualApiKeySecret } from "@app/hooks/useMyVirtualApiKeySecret";
 import type {
-    GetMyVirtualApiKeyResponse,
     ListMyVirtualApiKeysResponse,
     VirtualApiKeyWithResources
 } from "@server/routers/virtualApiKey/types";
-import {
-    formatVirtualApiKeyCredential,
-    formatVirtualApiKeyPreview
-} from "@app/lib/virtualApiKeyFormat";
+import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
 
 type UserVirtualApiKeysProps = {
     orgId: string;
     initialData: ListMyVirtualApiKeysResponse;
 };
 
-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(
-                        formatVirtualApiKeyCredential(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 OwnedKeySecret({
     orgId,
     virtualApiKeyId,
@@ -95,11 +38,9 @@ function OwnedKeySecret({
 }) {
     const t = useTranslations();
     const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
-    const { credential, loading, revealSecret } = useRevealSecret(
-        orgId,
-        virtualApiKeyId
-    );
-    const displayValue = credential ?? preview;
+    const { credential, revealed, loading, getCopyText, revealSecret } =
+        useMyVirtualApiKeySecret(orgId, virtualApiKeyId);
+    const displayValue = revealed && credential ? credential : preview;
 
     return (
         
@@ -107,9 +48,10 @@ function OwnedKeySecret({
- {!credential ? ( + {!revealed ? ( + + + + + + {emptyMessage} + + {options.map((option) => ( + { + onChange(option.value); + setOpen(false); + }} + > + +
+ + {option.title} + + + {option.description} + +
+
+ ))} +
+
+
+
+ +
+ ); +} diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index bf25fcfce..957d759eb 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -516,7 +516,9 @@ export default function DomainPicker({ return ( - {t("domainPickerNoDomainsAvailableTitle")} + + {t("domainPickerNoDomainsAvailableTitle")} +

{t("domainPickerNoDomainsAvailableDescription")}

+
+ ); + })} +
)} + {errorMessage} {rows.length > 0 && addRowButton}
); @@ -474,12 +519,16 @@ export function BudgetsEditor({ const body = ( <> - + + + + + diff --git a/src/components/CreateVirtualApiKeyForm.tsx b/src/components/CreateVirtualApiKeyForm.tsx index c443ad4b9..4a97d358a 100644 --- a/src/components/CreateVirtualApiKeyForm.tsx +++ b/src/components/CreateVirtualApiKeyForm.tsx @@ -99,9 +99,7 @@ export default function CreateVirtualApiKeyForm({ const [selectedResources, setSelectedResources] = useState< SelectedResource[] >([]); - const [pendingBudgetRows, setPendingBudgetRows] = useState( - [] - ); + const [pendingBudgetRows, setPendingBudgetRows] = useState([]); const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false); const formSchema = z.object({ @@ -204,10 +202,7 @@ export default function CreateVirtualApiKeyForm({ toast({ variant: "destructive", title: t("aiBudgetErrorSave"), - description: formatAxiosError( - e, - t("aiBudgetErrorSave") - ) + description: formatAxiosError(e, t("aiBudgetErrorSave")) }); } } @@ -295,120 +290,47 @@ export default function CreateVirtualApiKeyForm({ } ]} > -
- ( - - - {t("virtualApiKeysName")} - - - - - - - )} - /> - - ( - - - {t( - "virtualApiKeysDescriptionOptional" - )} - - - - - - - )} - /> - -
- - {t( - "virtualApiKeysAssociateUserOptional" - )} - - - - - - - - - -

- {t( - "virtualApiKeysAssociateUserDescription" - )} -

-
- -
-
- { - setAllResources( - val as boolean - ); - if (val) { - setSelectedResources( - [] - ); - } - }} - className="mt-0.5" +
+ ( + + + {t( + "virtualApiKeysName" + )} + + + + + + + )} + /> + + ( + + + {t( + "virtualApiKeysDescriptionOptional" + )} + + + + + + + )} /> -
- -

- {t( - "virtualApiKeysAllResourcesDescription" - )} -

-
-
- {!allResources && (
{t( - "virtualApiKeysSelectResources" + "virtualApiKeysAssociateUserOptional" )} @@ -418,69 +340,155 @@ export default function CreateVirtualApiKeyForm({ role="combobox" className={cn( "w-full justify-between", - selectedResources.length === - 0 && + !selectedUser && "text-muted-foreground" )} > - - {formatMultiResourcesSelectorLabel( - selectedResources, - t, - "virtualApiKeysSelectResourcesPlaceholder" - )} - + {selectedUser?.text + ? selectedUser.text + : t( + "userSelect" + )} - - + - 0 - } - onClear={() => - setSelectedResources( - [] - ) + onSelectUser={ + setSelectedUser } /> - +

{t( - "virtualApiKeysSelectResourcesDescription" + "virtualApiKeysAssociateUserDescription" )} - +

- )} -
-
-
-

- {t( - "virtualApiKeysInferenceBudgetDescription" - )} -

- -
+
+
+ { + setAllResources( + val as boolean + ); + if (val) { + setSelectedResources( + [] + ); + } + }} + className="mt-0.5" + /> +
+ +

+ {t( + "virtualApiKeysAllResourcesDescription" + )} +

+
+
+ + {!allResources && ( +
+ + {t( + "virtualApiKeysSelectResources" + )} + + + + + + + + 0 + } + onClear={() => + setSelectedResources( + [] + ) + } + /> + + + + {t( + "virtualApiKeysSelectResourcesDescription" + )} + +
+ )} +
+
+ +
+

+ {t( + "virtualApiKeysInferenceBudgetDescription" + )} +

+ +
diff --git a/src/components/EditVirtualApiKeyForm.tsx b/src/components/EditVirtualApiKeyForm.tsx index 1d3e78dbd..b8fbcac98 100644 --- a/src/components/EditVirtualApiKeyForm.tsx +++ b/src/components/EditVirtualApiKeyForm.tsx @@ -112,9 +112,7 @@ export default function EditVirtualApiKeyForm({ >([]); const [credential, setCredential] = useState(null); const [credentialLoading, setCredentialLoading] = useState(false); - const [pendingBudgetRows, setPendingBudgetRows] = useState( - [] - ); + const [pendingBudgetRows, setPendingBudgetRows] = useState([]); const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false); const budgetScope = { @@ -350,9 +348,7 @@ export default function EditVirtualApiKeyForm({
@@ -369,99 +365,11 @@ export default function EditVirtualApiKeyForm({ } ]} > -
-
- - - - - - - - - -

- {t( - "virtualApiKeysAssociateUserDescription" - )} -

-
- -
- ( - -
- - { - field.onChange( - val as boolean - ); - if (val) { - setSelectedResources( - [] - ); - } - }} - className="mt-0.5" - /> - -
- -

- {t( - "virtualApiKeysAllResourcesDescription" - )} -

-
-
- -
- )} - /> - - {!allResources && ( +
@@ -471,66 +379,163 @@ export default function EditVirtualApiKeyForm({ role="combobox" className={cn( "w-full justify-between", - selectedResources.length === - 0 && + !selectedUser && "text-muted-foreground" )} > - - {formatMultiResourcesSelectorLabel( - selectedResources, - t, - "virtualApiKeysSelectResourcesPlaceholder" - )} - + {selectedUser?.text + ? selectedUser.text + : t("userSelect")} - - + - 0 - } - onClear={() => - setSelectedResources( - [] - ) + onSelectUser={ + setSelectedUser } /> - +

{t( - "virtualApiKeysSelectResourcesRequired" + "virtualApiKeysAssociateUserDescription" )} - +

- )} -
-
-
-

- {t( - "virtualApiKeysInferenceBudgetDescription" - )} -

- -
+
+ ( + +
+ + { + field.onChange( + val as boolean + ); + if ( + val + ) { + setSelectedResources( + [] + ); + } + }} + className="mt-0.5" + /> + +
+ +

+ {t( + "virtualApiKeysAllResourcesDescription" + )} +

+
+
+ +
+ )} + /> + + {!allResources && ( +
+ + + + + + + + 0 + } + onClear={() => + setSelectedResources( + [] + ) + } + /> + + + + {t( + "virtualApiKeysSelectResourcesRequired" + )} + +
+ )} +
+
+ +
+

+ {t( + "virtualApiKeysInferenceBudgetDescription" + )} +

+ +
diff --git a/src/components/RoleForm.tsx b/src/components/RoleForm.tsx index 4e8f457cb..186da2a00 100644 --- a/src/components/RoleForm.tsx +++ b/src/components/RoleForm.tsx @@ -219,9 +219,7 @@ export function RoleForm({ useState(null); const [dragOverField, setDragOverField] = useState(null); - const [pendingBudgetRows, setPendingBudgetRows] = useState( - [] - ); + const [pendingBudgetRows, setPendingBudgetRows] = useState([]); const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false); const budgetsQuery = useQuery({ @@ -702,7 +700,6 @@ export function RoleForm({
)} - {/* Inference Budget tab */}

{t("accessRoleInferenceBudgetDescription")} @@ -711,8 +708,7 @@ export function RoleForm({ rows={pendingBudgetRows} onChange={setPendingBudgetRows} disabled={ - variant === "edit" && - budgetsQuery.isLoading + variant === "edit" && budgetsQuery.isLoading } attemptedSave={attemptedBudgetsSave} /> From ee75a09f8cea537e80201ca301cf1df9630c2f10 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 14 Aug 2026 10:33:44 -0400 Subject: [PATCH 354/494] improve sign in url style --- messages/en-US.json | 4 +-- .../resource-policy/PolicyAuthSsoSection.tsx | 34 ++++++++++++++++--- .../PolicyAuthStackSectionEdit.tsx | 34 ++----------------- 3 files changed, 33 insertions(+), 39 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 363a2a99d..c3a6a235c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -878,9 +878,9 @@ "policyAuthMethodOff": "Off", "policyAuthSsoTitle": "Platform SSO", "policyAuthSsoDescription": "Require sign-in through your organization's identity provider", - "policyAuthInferenceSsoDescription": "Selected users and roles can authenticate to the gateway using their identity API key.", + "policyAuthInferenceIdentityKeySignInUrl": "Sign-in URL", "policyAuthInferenceIdentityKeyHelpNoUrl": "Every user already has an identity API key, so you only need to create virtual API keys for non-user clients or shared access. Users can retrieve their key by signing in with their identity provider at this resource's URL, where it will be shown after login.", - "policyAuthInferenceIdentityKeyHelp": "Every user already has an identity API key, so you only need to create virtual API keys for non-user clients or shared access. Users can retrieve their key by signing in with their identity provider at , where it will be shown after login.", + "policyAuthInferenceIdentityKeyHelp": "Every user already has an identity API key, so you only need to create virtual API keys for non-user clients or shared access. Users retrieve their key here after signing in with their identity provider.", "policyAuthSsoSummary": "{idp} · {users} users, {roles} roles", "policyAuthSsoDefaultIdp": "Default provider", "policyAuthAddDefaultIdentityProvider": "Add Default Identity Provider", diff --git a/src/components/resource-policy/PolicyAuthSsoSection.tsx b/src/components/resource-policy/PolicyAuthSsoSection.tsx index 3cc2e54c4..c8ce238bf 100644 --- a/src/components/resource-policy/PolicyAuthSsoSection.tsx +++ b/src/components/resource-policy/PolicyAuthSsoSection.tsx @@ -1,5 +1,6 @@ "use client"; +import CopyToClipboard from "@app/components/CopyToClipboard"; import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; import { Button } from "@app/components/ui/button"; @@ -32,6 +33,7 @@ export type PolicyAuthSsoSectionProps = { ssoLocked?: boolean; title?: string; description?: string; + identityKeyUrl?: string | null; }; export function PolicyAuthSsoSection({ @@ -46,7 +48,8 @@ export function PolicyAuthSsoSection({ idpDisabled, ssoLocked, title, - description + description, + identityKeyUrl }: PolicyAuthSsoSectionProps) { const t = useTranslations(); const [showIdpSelect, setShowIdpSelect] = useState(skipToIdpId != null); @@ -79,11 +82,32 @@ export function PolicyAuthSsoSection({ {ssoActive && ( <> - {ssoLocked && ssoDescription && ( + {ssoLocked && identityKeyUrl !== undefined && ( -

- {ssoDescription} -

+ {identityKeyUrl ? ( + + + {t( + "policyAuthInferenceIdentityKeySignInUrl" + )} + + + + {t( + "policyAuthInferenceIdentityKeyHelp" + )} + + + ) : ( +

+ {t( + "policyAuthInferenceIdentityKeyHelpNoUrl" + )} +

+ )} )} diff --git a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx index f6d18b7cf..4a4734200 100644 --- a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx +++ b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx @@ -15,7 +15,6 @@ import { } from "@app/components/roles-selector"; import { UsersSelector } from "@app/components/users-selector"; import { Button } from "@app/components/ui/button"; -import { Alert, AlertDescription } from "@app/components/ui/alert"; import { Form, FormField } from "@app/components/ui/form"; import { toast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -29,7 +28,6 @@ import type { GetResourcePolicyResponse } from "@server/routers/policy"; import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; -import { ExternalLink, InfoIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { toUnicode } from "punycode"; @@ -555,34 +553,6 @@ export function PolicyAuthStackSectionEdit({ {isResourceOverlay && ( )} - {isInferenceResource && ( - - - - {inferenceResourceUrl - ? t.rich( - "policyAuthInferenceIdentityKeyHelp", - { - resourceLink: () => ( - - {inferenceResourceUrl} - - ) - } - ) - : t( - "policyAuthInferenceIdentityKeyHelpNoUrl" - )} - - - )} Date: Fri, 14 Aug 2026 09:30:44 -0400 Subject: [PATCH 355/494] add niceId to provider --- server/db/names.ts | 27 +++++ server/db/pg/schema/schema.ts | 97 +++++++++--------- server/db/sqlite/schema/schema.ts | 99 ++++++++++--------- server/routers/aiProvider/createAiProvider.ts | 4 + server/routers/aiProvider/updateAiProvider.ts | 37 ++++++- 5 files changed, 171 insertions(+), 93 deletions(-) diff --git a/server/db/names.ts b/server/db/names.ts index ebe38573d..37e851379 100644 --- a/server/db/names.ts +++ b/server/db/names.ts @@ -1,6 +1,7 @@ import { join } from "path"; import { readFileSync } from "fs"; import { + aiProviders, clients, db, resourcePolicies, @@ -113,6 +114,32 @@ export async function getUniqueResourceName(orgId: string): Promise { } } +export async function getUniqueProviderName(orgId: string): Promise { + let loops = 0; + while (true) { + if (loops > 100) { + throw new Error("Could not generate a unique name"); + } + + const name = generateName(); + + const aiProviderCount = await db + .select({ + niceId: aiProviders.niceId, + orgId: aiProviders.orgId + }) + .from(aiProviders) + .where( + and(eq(aiProviders.niceId, name), eq(aiProviders.orgId, orgId)) + ); + + if (aiProviderCount.length === 0) { + return name; + } + loops++; + } +} + export async function getUniqueResourcePolicyName( orgId: string ): Promise { diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 50fa3753b..432afbbc1 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1684,52 +1684,57 @@ export const statusHistory = pgTable( ] ); -export const aiProviders = pgTable("aiProviders", { - providerId: serial("providerId").primaryKey(), - orgId: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), - name: varchar("name").notNull(), - type: varchar("type") - .$type< - | "openai" - | "anthropic" - | "googleGemini" - | "vertexAi" - | "bedrock" - | "microsoftFoundry" - | "openRouter" - | "vercelAiGateway" - | "custom" - >() - .notNull(), - upstreamUrl: text("upstreamUrl"), - apiKey: text("apiKey"), - apiKeyLastChars: varchar("apiKeyLastChars"), - authType: varchar("authType") - .$type< - | "bearer" - | "x-api-key" - | "x-goog-api-key" - | "hec" - | "cf-aig-authorization" - | "none" - | "passthrough" - >() - .notNull(), - routingMode: varchar("routingMode") - .$type<"url" | "target">() - .notNull() - .default("url"), - capabilities: text("capabilities").notNull().default("[]"), - headers: text("headers"), // JSON array of { name, value } - skipTlsVerification: boolean("skipTlsVerification") - .notNull() - .default(false), - enabled: boolean("enabled").notNull().default(true), - createdAt: bigint("createdAt", { mode: "number" }).notNull(), - updatedAt: bigint("updatedAt", { mode: "number" }).notNull() -}); +export const aiProviders = pgTable( + "aiProviders", + { + providerId: serial("providerId").primaryKey(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + name: varchar("name").notNull(), + niceId: varchar("niceId").notNull(), + type: varchar("type") + .$type< + | "openai" + | "anthropic" + | "googleGemini" + | "vertexAi" + | "bedrock" + | "microsoftFoundry" + | "openRouter" + | "vercelAiGateway" + | "custom" + >() + .notNull(), + upstreamUrl: text("upstreamUrl"), + apiKey: text("apiKey"), + apiKeyLastChars: varchar("apiKeyLastChars"), + authType: varchar("authType") + .$type< + | "bearer" + | "x-api-key" + | "x-goog-api-key" + | "hec" + | "cf-aig-authorization" + | "none" + | "passthrough" + >() + .notNull(), + routingMode: varchar("routingMode") + .$type<"url" | "target">() + .notNull() + .default("url"), + capabilities: text("capabilities").notNull().default("[]"), + headers: text("headers"), // JSON array of { name, value } + skipTlsVerification: boolean("skipTlsVerification") + .notNull() + .default(false), + enabled: boolean("enabled").notNull().default(true), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull() + }, + (t) => [index("idx_aiProviders_orgId_niceId").on(t.orgId, t.niceId)] +); export const aiModels = pgTable( "aiModels", diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 628563ee3..c70424152 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1668,52 +1668,59 @@ export const statusHistory = sqliteTable( ] ); -export const aiProviders = sqliteTable("aiProviders", { - providerId: integer("providerId").primaryKey({ autoIncrement: true }), - orgId: text("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), - name: text("name").notNull(), - type: text("type") - .$type< - | "openai" - | "anthropic" - | "googleGemini" - | "vertexAi" - | "bedrock" - | "microsoftFoundry" - | "openRouter" - | "vercelAiGateway" - | "custom" - >() - .notNull(), - upstreamUrl: text("upstreamUrl"), - apiKey: text("apiKey"), - apiKeyLastChars: text("apiKeyLastChars"), - authType: text("authType") - .$type< - | "bearer" - | "x-api-key" - | "x-goog-api-key" - | "hec" - | "cf-aig-authorization" - | "none" - | "passthrough" - >() - .notNull(), - routingMode: text("routingMode") - .$type<"url" | "target">() - .notNull() - .default("url"), - capabilities: text("capabilities").notNull().default("[]"), - headers: text("headers"), // JSON array of { name, value } - skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" }) - .notNull() - .default(false), - enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), - createdAt: integer("createdAt").notNull(), - updatedAt: integer("updatedAt").notNull() -}); +export const aiProviders = sqliteTable( + "aiProviders", + { + providerId: integer("providerId").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + name: text("name").notNull(), + niceId: text("niceId").notNull(), + type: text("type") + .$type< + | "openai" + | "anthropic" + | "googleGemini" + | "vertexAi" + | "bedrock" + | "microsoftFoundry" + | "openRouter" + | "vercelAiGateway" + | "custom" + >() + .notNull(), + upstreamUrl: text("upstreamUrl"), + apiKey: text("apiKey"), + apiKeyLastChars: text("apiKeyLastChars"), + authType: text("authType") + .$type< + | "bearer" + | "x-api-key" + | "x-goog-api-key" + | "hec" + | "cf-aig-authorization" + | "none" + | "passthrough" + >() + .notNull(), + routingMode: text("routingMode") + .$type<"url" | "target">() + .notNull() + .default("url"), + capabilities: text("capabilities").notNull().default("[]"), + headers: text("headers"), // JSON array of { name, value } + skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" }) + .notNull() + .default(false), + enabled: integer("enabled", { mode: "boolean" }) + .notNull() + .default(true), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt").notNull() + }, + (t) => [index("idx_aiProviders_orgId_niceId").on(t.orgId, t.niceId)] +); export const aiModels = sqliteTable( "aiModels", diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts index f115d674a..8a6ad9e4d 100644 --- a/server/routers/aiProvider/createAiProvider.ts +++ b/server/routers/aiProvider/createAiProvider.ts @@ -25,6 +25,7 @@ import { refineProviderUpstreamFields } from "@server/routers/aiProvider/validation"; import { serializeCapabilities } from "@server/lib/aiCapabilities"; +import { getUniqueProviderName, getUniqueResourceName } from "@server/db/names"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -133,11 +134,14 @@ export async function createAiProvider( ); } + const niceId = await getUniqueProviderName(orgId); + const [provider] = await db .insert(aiProviders) .values({ orgId, name, + niceId, type, upstreamUrl: resolved.upstreamUrl, apiKey: encryptedApiKey, diff --git a/server/routers/aiProvider/updateAiProvider.ts b/server/routers/aiProvider/updateAiProvider.ts index 1f22dc6ef..19555dda9 100644 --- a/server/routers/aiProvider/updateAiProvider.ts +++ b/server/routers/aiProvider/updateAiProvider.ts @@ -7,7 +7,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { eq } from "drizzle-orm"; +import { eq, ne, and } from "drizzle-orm"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; @@ -37,6 +37,15 @@ const paramsSchema = z.strictObject({ const bodySchema = z.strictObject({ name: z.string().nonempty().optional(), + niceId: z + .string() + .min(1) + .max(255) + .regex( + /^[a-zA-Z0-9-]+$/, + "niceId can only contain letters, numbers, and dashes" + ) + .optional(), upstreamUrl: z.url().optional().nullable(), apiKey: z.string().optional(), authType: aiAuthTypeSchema.optional(), @@ -170,6 +179,9 @@ export async function updateAiProvider( if (body.name !== undefined) { updateData.name = body.name; } + if (body.niceId !== undefined) { + updateData.niceId = body.niceId; + } if (body.skipTlsVerification !== undefined) { updateData.skipTlsVerification = body.skipTlsVerification; } @@ -199,6 +211,29 @@ export async function updateAiProvider( updateData.headers = serializeAiProviderHeaders(body.headers, key); } + if (updateData.niceId) { + const [existingAiProvider] = await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.niceId, updateData.niceId), + eq(aiProviders.orgId, existing.orgId), + ne(aiProviders.providerId, existing.providerId) // exclude the current provider from the search + ) + ) + .limit(1); + + if (existingAiProvider) { + return next( + createHttpError( + HttpCode.CONFLICT, + `A resource with niceId "${updateData.niceId}" already exists` + ) + ); + } + } + const [provider] = await db .update(aiProviders) .set(updateData) From 5d37f44241e38ae765fd04ce8ec76bce735a7b15 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 09:43:03 -0400 Subject: [PATCH 356/494] Add niceid to ui and api for providers --- .../verifyApiKeyAiProviderAccess.ts | 46 +++++++---- server/middlewares/verifyAiProviderAccess.ts | 46 +++++++---- server/routers/aiProvider/getAiProvider.ts | 78 +++++++++++++++---- server/routers/external.ts | 7 ++ server/routers/integration.ts | 7 ++ .../authentication/page.tsx | 0 .../budget/page.tsx | 0 .../[niceId]/configuration/page.tsx | 12 +++ .../general/page.tsx | 35 +++++++++ .../{[providerId] => [niceId]}/layout.tsx | 16 ++-- .../models/page.tsx | 0 .../network/page.tsx | 0 .../settings/ai-providers/[niceId]/page.tsx | 10 +++ .../[providerId]/configuration/page.tsx | 12 --- .../ai-providers/[providerId]/page.tsx | 10 --- .../settings/ai-providers/create/page.tsx | 7 +- .../[orgId]/settings/ai-providers/page.tsx | 1 + src/components/AiProvidersTable.tsx | 7 +- 18 files changed, 219 insertions(+), 75 deletions(-) rename src/app/[orgId]/settings/ai-providers/{[providerId] => [niceId]}/authentication/page.tsx (100%) rename src/app/[orgId]/settings/ai-providers/{[providerId] => [niceId]}/budget/page.tsx (100%) create mode 100644 src/app/[orgId]/settings/ai-providers/[niceId]/configuration/page.tsx rename src/app/[orgId]/settings/ai-providers/{[providerId] => [niceId]}/general/page.tsx (85%) rename src/app/[orgId]/settings/ai-providers/{[providerId] => [niceId]}/layout.tsx (83%) rename src/app/[orgId]/settings/ai-providers/{[providerId] => [niceId]}/models/page.tsx (100%) rename src/app/[orgId]/settings/ai-providers/{[providerId] => [niceId]}/network/page.tsx (100%) create mode 100644 src/app/[orgId]/settings/ai-providers/[niceId]/page.tsx delete mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx delete mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx diff --git a/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts b/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts index 1668b5727..91cfa47e4 100644 --- a/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts +++ b/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { aiProviders, apiKeyOrg, db } from "@server/db"; +import { AiProvider, aiProviders, apiKeyOrg, db } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -13,7 +13,8 @@ export async function verifyApiKeyAiProviderAccess( try { const apiKey = req.apiKey; const providerIdRaw = getFirstString(req.params.providerId); - const providerId = Number.parseInt(providerIdRaw ?? "", 10); + const niceId = getFirstString(req.params.niceId); + const orgIdParam = getFirstString(req.params.orgId); if (!apiKey) { return next( @@ -21,23 +22,42 @@ export async function verifyApiKeyAiProviderAccess( ); } - if (Number.isNaN(providerId)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") - ); - } + let provider: AiProvider | undefined; - const [provider] = await db - .select() - .from(aiProviders) - .where(eq(aiProviders.providerId, providerId)) - .limit(1); + if (niceId && orgIdParam) { + const [providerRes] = await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.niceId, niceId), + eq(aiProviders.orgId, orgIdParam) + ) + ) + .limit(1); + provider = providerRes; + } else { + const providerId = Number.parseInt(providerIdRaw ?? "", 10); + + if (Number.isNaN(providerId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") + ); + } + + const [providerRes] = await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, providerId)) + .limit(1); + provider = providerRes; + } if (!provider) { return next( createHttpError( HttpCode.NOT_FOUND, - `AI provider with ID ${providerId} not found` + `AI provider with ID ${providerIdRaw || niceId} not found` ) ); } diff --git a/server/middlewares/verifyAiProviderAccess.ts b/server/middlewares/verifyAiProviderAccess.ts index 22edd6f86..057497cc3 100644 --- a/server/middlewares/verifyAiProviderAccess.ts +++ b/server/middlewares/verifyAiProviderAccess.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { aiProviders, db, userOrgs } from "@server/db"; +import { AiProvider, aiProviders, db, userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -15,7 +15,8 @@ export async function verifyAiProviderAccess( try { const userId = req.user!.userId; const providerIdRaw = getFirstString(req.params.providerId); - const providerId = Number.parseInt(providerIdRaw ?? "", 10); + const niceId = getFirstString(req.params.niceId); + const orgIdParam = getFirstString(req.params.orgId); if (!userId) { return next( @@ -23,23 +24,42 @@ export async function verifyAiProviderAccess( ); } - if (Number.isNaN(providerId)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") - ); - } + let provider: AiProvider | undefined; - const [provider] = await db - .select() - .from(aiProviders) - .where(eq(aiProviders.providerId, providerId)) - .limit(1); + if (niceId && orgIdParam) { + const [providerRes] = await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.niceId, niceId), + eq(aiProviders.orgId, orgIdParam) + ) + ) + .limit(1); + provider = providerRes; + } else { + const providerId = Number.parseInt(providerIdRaw ?? "", 10); + + if (Number.isNaN(providerId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") + ); + } + + const [providerRes] = await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, providerId)) + .limit(1); + provider = providerRes; + } if (!provider) { return next( createHttpError( HttpCode.NOT_FOUND, - `AI provider with ID ${providerId} not found` + `AI provider with ID ${providerIdRaw || niceId} not found` ) ); } diff --git a/server/routers/aiProvider/getAiProvider.ts b/server/routers/aiProvider/getAiProvider.ts index 28a0152e1..53a3e099a 100644 --- a/server/routers/aiProvider/getAiProvider.ts +++ b/server/routers/aiProvider/getAiProvider.ts @@ -2,26 +2,78 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { aiProviders, db } from "@server/db"; import response from "@server/lib/response"; +import stoi from "@server/lib/stoi"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import type { GetAiProviderResponse } from "@server/routers/aiProvider/types"; import { toPublicAiProvider } from "@server/routers/aiProvider/types"; const paramsSchema = z.strictObject({ - providerId: z.coerce.number().int().positive() + providerId: z + .string() + .optional() + .transform(stoi) + .pipe(z.int().positive().optional()) + .optional(), + niceId: z.string().optional(), + orgId: z.string().optional() }); +async function query(providerId?: number, niceId?: string, orgId?: string) { + if (providerId) { + const [res] = await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, providerId)) + .limit(1); + return res; + } else if (niceId && orgId) { + const [res] = await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.niceId, niceId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + return res; + } +} + registry.registerPath({ method: "get", path: "/ai-provider/{providerId}", description: "Get an AI provider by ID.", tags: [OpenAPITags.AiProvider], request: { - params: paramsSchema + params: z.object({ + providerId: z.string() + }) + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/ai-provider/{niceId}", + description: + "Get an AI provider by orgId and niceId. NiceId is a readable ID for the provider and unique on a per org basis.", + tags: [OpenAPITags.AiProvider], + request: { + params: z.object({ + orgId: z.string(), + niceId: z.string() + }) }, responses: { 200: { @@ -46,22 +98,22 @@ export async function getAiProvider( ); } - const { providerId } = parsedParams.data; + const { providerId, niceId, orgId } = parsedParams.data; - const [provider] = - req.aiProvider && req.aiProvider.providerId === providerId - ? [req.aiProvider] - : await db - .select() - .from(aiProviders) - .where(eq(aiProviders.providerId, providerId)) - .limit(1); + const provider = + req.aiProvider && + (req.aiProvider.providerId === providerId || + (niceId && + req.aiProvider.niceId === niceId && + req.aiProvider.orgId === orgId)) + ? req.aiProvider + : await query(providerId, niceId, orgId); if (!provider) { return next( createHttpError( HttpCode.NOT_FOUND, - `AI provider with ID ${providerId} not found` + `AI provider with ID ${providerId || niceId} not found` ) ); } diff --git a/server/routers/external.ts b/server/routers/external.ts index 17cf0d921..e182d2b44 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1589,6 +1589,13 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getAiProvider), aiProvider.getAiProvider ); +authenticated.get( + "/org/:orgId/ai-provider/:niceId", + verifyOrgAccess, + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.getAiProvider), + aiProvider.getAiProvider +); authenticated.put( "/ai-provider/:providerId/target", diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 5dc9315e9..f609443d4 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -1624,6 +1624,13 @@ authenticated.get( verifyApiKeyHasAction(ActionsEnum.getAiProvider), aiProvider.getAiProvider ); +authenticated.get( + "/org/:orgId/ai-provider/:niceId", + verifyApiKeyOrgAccess, + verifyApiKeyAiProviderAccess, + verifyApiKeyHasAction(ActionsEnum.getAiProvider), + aiProvider.getAiProvider +); authenticated.put( "/ai-provider/:providerId/target", diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/authentication/page.tsx similarity index 100% rename from src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx rename to src/app/[orgId]/settings/ai-providers/[niceId]/authentication/page.tsx diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/budget/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/budget/page.tsx similarity index 100% rename from src/app/[orgId]/settings/ai-providers/[providerId]/budget/page.tsx rename to src/app/[orgId]/settings/ai-providers/[niceId]/budget/page.tsx diff --git a/src/app/[orgId]/settings/ai-providers/[niceId]/configuration/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/configuration/page.tsx new file mode 100644 index 000000000..ee3f676ae --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[niceId]/configuration/page.tsx @@ -0,0 +1,12 @@ +import { redirect } from "next/navigation"; + +type Props = { + params: Promise<{ orgId: string; niceId: string }>; +}; + +export default async function AiProviderConfigurationRedirect({ + params +}: Props) { + const { orgId, niceId } = await params; + redirect(`/${orgId}/settings/ai-providers/${niceId}/network`); +} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/general/page.tsx similarity index 85% rename from src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx rename to src/app/[orgId]/settings/ai-providers/[niceId]/general/page.tsx index 9e41ff9c3..b65b1984a 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[niceId]/general/page.tsx @@ -55,6 +55,7 @@ export default function AiProviderGeneralPage() { .string() .trim() .min(1, { message: t("nameRequired") }), + niceId: z.string().min(1).max(255).optional(), enabled: z.boolean(), capabilities: z.array(z.enum(AI_CAPABILITIES)).optional() }) @@ -76,6 +77,7 @@ export default function AiProviderGeneralPage() { resolver: zodResolver(generalSchema), defaultValues: { name: provider.name, + niceId: provider.niceId, enabled: provider.enabled, capabilities: provider.capabilities ?? [] } @@ -86,10 +88,12 @@ export default function AiProviderGeneralPage() { try { const body: { name: string; + niceId?: string; enabled: boolean; capabilities?: AiCapability[]; } = { name: values.name.trim(), + niceId: values.niceId, enabled: values.enabled, capabilities: values.capabilities ?? [] }; @@ -101,6 +105,7 @@ export default function AiProviderGeneralPage() { updateProvider(updated); form.reset({ name: updated.name, + niceId: updated.niceId, enabled: updated.enabled, capabilities: updated.capabilities ?? [] }); @@ -108,6 +113,13 @@ export default function AiProviderGeneralPage() { title: t("success"), description: t("aiProviderUpdated") }); + + if (values.niceId && values.niceId !== provider.niceId) { + router.replace( + `/${provider.orgId}/settings/ai-providers/${values.niceId}/general` + ); + } + router.refresh(); } catch (e) { toast({ @@ -190,6 +202,29 @@ export default function AiProviderGeneralPage() { /> + + ( + + + {t("identifier")} + + + + + + + )} + /> + + ; + params: Promise<{ orgId: string; niceId: string }>; }; export default async function AiProviderLayout({ children, params }: Props) { - const { orgId, providerId } = await params; + const { orgId, niceId } = await params; const t = await getTranslations(); let provider = null; try { const res = await internal.get>( - `/ai-provider/${providerId}`, + `/org/${orgId}/ai-provider/${niceId}`, await authCookieHeader() ); provider = res.data.data.provider; @@ -63,23 +63,23 @@ export default async function AiProviderLayout({ children, params }: Props) { const navItems = [ { title: t("general"), - href: "/{orgId}/settings/ai-providers/{providerId}/general" + href: "/{orgId}/settings/ai-providers/{niceId}/general" }, { title: t("aiProviderNetworkSettings"), - href: "/{orgId}/settings/ai-providers/{providerId}/network" + href: "/{orgId}/settings/ai-providers/{niceId}/network" }, { title: t("aiProviderModels"), - href: "/{orgId}/settings/ai-providers/{providerId}/models" + href: "/{orgId}/settings/ai-providers/{niceId}/models" }, { title: t("aiProviderAuthSettings"), - href: "/{orgId}/settings/ai-providers/{providerId}/authentication" + href: "/{orgId}/settings/ai-providers/{niceId}/authentication" }, { title: t("aiProviderBudgetSettings"), - href: "/{orgId}/settings/ai-providers/{providerId}/budget" + href: "/{orgId}/settings/ai-providers/{niceId}/budget" } ]; diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/models/page.tsx similarity index 100% rename from src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx rename to src/app/[orgId]/settings/ai-providers/[niceId]/models/page.tsx diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/network/page.tsx similarity index 100% rename from src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx rename to src/app/[orgId]/settings/ai-providers/[niceId]/network/page.tsx diff --git a/src/app/[orgId]/settings/ai-providers/[niceId]/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/page.tsx new file mode 100644 index 000000000..664127d17 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[niceId]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +type Props = { + params: Promise<{ orgId: string; niceId: string }>; +}; + +export default async function AiProviderPage({ params }: Props) { + const { orgId, niceId } = await params; + redirect(`/${orgId}/settings/ai-providers/${niceId}/general`); +} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx deleted file mode 100644 index bf003b96e..000000000 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { redirect } from "next/navigation"; - -type Props = { - params: Promise<{ orgId: string; providerId: string }>; -}; - -export default async function AiProviderConfigurationRedirect({ - params -}: Props) { - const { orgId, providerId } = await params; - redirect(`/${orgId}/settings/ai-providers/${providerId}/network`); -} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx deleted file mode 100644 index 2daca1227..000000000 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { redirect } from "next/navigation"; - -type Props = { - params: Promise<{ orgId: string; providerId: string }>; -}; - -export default async function AiProviderPage({ params }: Props) { - const { orgId, providerId } = await params; - redirect(`/${orgId}/settings/ai-providers/${providerId}/general`); -} diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 7684b2d48..29f005e8a 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -222,6 +222,7 @@ export default function CreateAiProviderPage() { >(`/org/${orgId}/ai-provider`, toAiProviderCreatePayload(values)); const providerId = res.data.data.provider.providerId; + const niceId = res.data.data.provider.niceId; if (showTargets && targets.length > 0) { try { @@ -236,7 +237,7 @@ export default function CreateAiProviderPage() { ) }); router.push( - `/${orgId}/settings/ai-providers/${providerId}/network` + `/${orgId}/settings/ai-providers/${niceId}/network` ); return; } @@ -255,7 +256,7 @@ export default function CreateAiProviderPage() { ) }); router.push( - `/${orgId}/settings/ai-providers/${providerId}/models` + `/${orgId}/settings/ai-providers/${niceId}/models` ); return; } @@ -266,7 +267,7 @@ export default function CreateAiProviderPage() { description: t("aiProviderCreated") }); - router.push(`/${orgId}/settings/ai-providers/${providerId}`); + router.push(`/${orgId}/settings/ai-providers/${niceId}`); } catch (e) { toast({ variant: "destructive", diff --git a/src/app/[orgId]/settings/ai-providers/page.tsx b/src/app/[orgId]/settings/ai-providers/page.tsx index 9858641e2..024170590 100644 --- a/src/app/[orgId]/settings/ai-providers/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/page.tsx @@ -53,6 +53,7 @@ export default async function AiProvidersPage({ params, searchParams }: Props) { orgId={orgId} providers={providers.map((provider) => ({ providerId: provider.providerId, + niceId: provider.niceId, name: provider.name, type: provider.type, routingMode: provider.routingMode, diff --git a/src/components/AiProvidersTable.tsx b/src/components/AiProvidersTable.tsx index ba7fbb00c..82bf23cfe 100644 --- a/src/components/AiProvidersTable.tsx +++ b/src/components/AiProvidersTable.tsx @@ -27,6 +27,7 @@ import { useDebouncedCallback } from "use-debounce"; export type AiProviderRow = { providerId: number; + niceId: string; name: string; type: string; routingMode: string; @@ -176,7 +177,7 @@ export default function AiProvidersTable({ header: () => {t("name")}, cell: ({ row }) => ( {row.original.name} @@ -238,7 +239,7 @@ export default function AiProvidersTable({ {t("edit")} @@ -256,7 +257,7 @@ export default function AiProvidersTable({ + )} + - )} - - +
@@ -287,29 +292,93 @@ type FilterSelectProps = { }; function FilterSelect(props: FilterSelectProps) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const selected = props.options.find( + (option) => option.value === props.value + ); + return ( -
+
- + + + + + + + + + + {t("aiUsageFilterNotFound")} + + + { + props.onValueChange(undefined); + setOpen(false); + }} + > + + {props.placeholder} + + {props.options.map((option) => ( + { + props.onValueChange( + option.value === props.value + ? undefined + : option.value + ); + setOpen(false); + }} + > + + + {option.label} + + + ))} + + + + +
); } From c568251d8d75d9e46722afe09d54cfb3a5d315b7 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 14 Aug 2026 11:38:11 -0400 Subject: [PATCH 360/494] improve model picker --- messages/en-US.json | 2 - .../ai-providers/[niceId]/models/page.tsx | 30 ++- .../settings/ai-providers/create/page.tsx | 25 ++- src/components/AiProviderModelListEditor.tsx | 183 ++++++------------ 4 files changed, 95 insertions(+), 145 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 950952e91..52458fcbe 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1926,8 +1926,6 @@ "aiProviderModelsEditTitle": "Edit Model", "aiProviderModelsEditDescription": "Update the model key or configure its budget.", "aiProviderModelsBudgetTab": "Budget", - "aiProviderModelsBudgetDescription": "Configure how this model restricts usage based on spending or token limits", - "aiProviderModelsBudgetUnsaved": "Save this model first to configure its budget.", "aiProviderModelsKeyLabel": "Model Key", "aiProviderModelsKeyRequired": "Enter a model key", "aiProviderModelsKeyDuplicate": "This model key is already on a list", diff --git a/src/app/[orgId]/settings/ai-providers/[niceId]/models/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/models/page.tsx index b85c0076c..db882fac0 100644 --- a/src/app/[orgId]/settings/ai-providers/[niceId]/models/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[niceId]/models/page.tsx @@ -11,6 +11,7 @@ import { SettingsSectionTitle } from "@app/components/Settings"; import { + persistPendingModelBudgets, type AiProviderModelListItem, type ModelListType } from "@app/components/AiProviderModelListEditor"; @@ -21,7 +22,9 @@ 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 type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useEffect, useMemo, useState } from "react"; @@ -113,8 +116,7 @@ export default function AiProviderModelsPage() { return; } - const toCreate: { modelKey: string; listType: ModelListType }[] = - []; + const toCreate: AiProviderModelListItem[] = []; const toUpdate: { modelId: number; modelKey: string; @@ -159,7 +161,7 @@ export default function AiProviderModelsPage() { continue; } - toCreate.push({ modelKey, listType }); + toCreate.push(item); } const toDelete = existing @@ -167,13 +169,21 @@ export default function AiProviderModelsPage() { .map((model) => model.modelId); await Promise.all([ - ...toCreate.map(({ modelKey, listType }) => - api.put(`/ai-provider/${provider.providerId}/model`, { - modelKey, - name: modelKey, - listType - }) - ), + ...toCreate.map(async (item) => { + const res = await api.put< + AxiosResponse + >(`/ai-provider/${provider.providerId}/model`, { + modelKey: item.modelKey, + name: item.modelKey, + listType: item.listType + }); + await persistPendingModelBudgets({ + api, + orgId: provider.orgId, + modelId: res.data.data.model.modelId, + pendingBudgets: item.pendingBudgets + }); + }), ...toUpdate.map(({ modelId, modelKey, listType }) => api.post(`/ai-model/${modelId}`, { modelKey, diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 29f005e8a..5872594ef 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -22,8 +22,8 @@ import HeaderTitle from "@app/components/SettingsSectionTitle"; import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect"; import { AiProviderCapabilitiesSelect } from "@app/components/AiProviderCapabilitiesSelect"; import { - type AiProviderModelListItem, - type ModelListType + persistPendingModelBudgets, + type AiProviderModelListItem } from "@app/components/AiProviderModelListEditor"; import { AiProviderModelsLists } from "@app/components/AiProviderModelsLists"; import { @@ -59,7 +59,10 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { authTypeRequiresApiKey } from "@app/lib/aiProviderDefaults"; import { aiProviderQueries } from "@app/lib/queries"; -import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; +import type { + CreateOrEditAiModelResponse, + CreateOrEditAiProviderResponse +} from "@server/routers/aiProvider/types"; import { useQuery } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; @@ -157,14 +160,22 @@ export default function CreateAiProviderPage() { async function createModels( providerId: number, - items: { modelKey: string; listType: ModelListType }[] + items: AiProviderModelListItem[] ) { for (const item of items) { - await api.put(`/ai-provider/${providerId}/model`, { + const res = await api.put< + AxiosResponse + >(`/ai-provider/${providerId}/model`, { modelKey: item.modelKey, name: item.modelKey, listType: item.listType }); + await persistPendingModelBudgets({ + api, + orgId, + modelId: res.data.data.model.modelId, + pendingBudgets: item.pendingBudgets + }); } } @@ -210,8 +221,8 @@ export default function CreateAiProviderPage() { const modelItems = [...allowItems, ...blockItems] .map((item) => ({ - modelKey: item.modelKey.trim(), - listType: item.listType + ...item, + modelKey: item.modelKey.trim() })) .filter((item) => item.modelKey); diff --git a/src/components/AiProviderModelListEditor.tsx b/src/components/AiProviderModelListEditor.tsx index 4da6fb10c..032db8f2b 100644 --- a/src/components/AiProviderModelListEditor.tsx +++ b/src/components/AiProviderModelListEditor.tsx @@ -34,12 +34,6 @@ import { PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger -} from "@app/components/ui/tooltip"; import { cn } from "@app/lib/cn"; import { isModelKeyPattern } from "@server/lib/aiModelKeyMatch"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; @@ -56,16 +50,8 @@ import { createApiClient, formatAxiosError } from "@app/lib/api"; import { aiBudgetQueries } from "@app/lib/queries"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; -import { - Asterisk, - BookMarked, - Check, - Globe, - Pencil, - Plus, - Wallet, - XIcon -} from "lucide-react"; +import type { AxiosInstance } from "axios"; +import { Globe, Plus, XIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -78,6 +64,7 @@ export type ModelSource = "catalog" | "custom" | "pattern" | "all"; export const ALL_MODELS_KEY = "*"; const COLLAPSED_ROWS = 5; +const GRID_COLUMNS = 2; export type AiProviderModelListItem = { clientId: string; @@ -85,8 +72,32 @@ export type AiProviderModelListItem = { modelKey: string; listType: ModelListType; hasBudget?: boolean; + pendingBudgets?: BudgetRow[]; }; +export async function persistPendingModelBudgets({ + api, + orgId, + modelId, + pendingBudgets +}: { + api: AxiosInstance; + orgId: string; + modelId: number; + pendingBudgets?: BudgetRow[]; +}): Promise { + if (!pendingBudgets || pendingBudgets.length === 0) { + return; + } + await saveBudgetRows({ + api, + orgId, + scope: { type: "model", id: modelId }, + existingBudgets: [], + rows: pendingBudgets + }); +} + export type AiProviderModelListEditorProps = { orgId: string; listType: ModelListType; @@ -138,27 +149,6 @@ function parseBulkKeys(raw: string): string[] { return keys; } -function useModelGridColumns(): number { - const [columns, setColumns] = useState(1); - - useEffect(() => { - const sm = window.matchMedia("(min-width: 640px)"); - const xl = window.matchMedia("(min-width: 1280px)"); - const update = () => { - setColumns(xl.matches ? 3 : sm.matches ? 2 : 1); - }; - update(); - sm.addEventListener("change", update); - xl.addEventListener("change", update); - return () => { - sm.removeEventListener("change", update); - xl.removeEventListener("change", update); - }; - }, []); - - return columns; -} - export function AiProviderModelListEditor({ orgId, listType, @@ -178,8 +168,7 @@ export function AiProviderModelListEditor({ const [listExpanded, setListExpanded] = useState(false); const [clipHeight, setClipHeight] = useState(null); const gridRef = useRef(null); - const columns = useModelGridColumns(); - const collapsedLimit = columns * COLLAPSED_ROWS; + const collapsedLimit = GRID_COLUMNS * COLLAPSED_ROWS; const hasOverflow = items.length > collapsedLimit; const isCollapsed = hasOverflow && !listExpanded; @@ -358,7 +347,7 @@ export function AiProviderModelListEditor({
{source === "all" ? ( - <> - - {t("aiProviderModelsAllLabel")} - - + + {t("aiProviderModelsAllLabel")} + ) : ( {item.modelKey} )}
- -
- - - e.stopPropagation()} - > - - - - {sourceLabel} - - {item.hasBudget ? ( - - - e.stopPropagation()} - > - - - - - {t("aiProviderModelsBudgetConfigured")} - - - ) : null} -
-
- {budgetScope ? ( - <> -

- {t( - "aiProviderModelsBudgetDescription" - )} -

- - - ) : ( -

- {t("aiProviderModelsBudgetUnsaved")} -

- )} +
From 4989d1e31a249d28816e3e0d9b8683edf3ff647f Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 11:31:56 -0400 Subject: [PATCH 361/494] Allow overlapping domains on public inference resources --- server/lib/blueprints/publicResources.ts | 11 +++++++++++ server/routers/resource/createResource.ts | 17 ++++++++++++++--- server/routers/resource/updateResource.ts | 15 ++++++++++++++- .../siteResource/listAllSiteResourcesByOrg.ts | 4 ++-- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 9c9242984..806d97e9c 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -306,6 +306,7 @@ export async function updatePublicResources( existingResource.resourceId, resourceData["full-domain"]!, orgId, + resourceData.mode === "inference", trx ); @@ -1098,6 +1099,7 @@ export async function updatePublicResources( undefined, resourceData["full-domain"]!, orgId, + resourceData.mode === "inference", trx ); @@ -2113,6 +2115,7 @@ export async function getDomain( resourceId: number | undefined, fullDomain: string, orgId: string, + isInference: boolean, trx: Transaction ) { const [fullDomainExists] = await trx @@ -2122,6 +2125,14 @@ export async function getDomain( and( eq(resources.fullDomain, fullDomain), eq(resources.orgId, orgId), + // Inference resources route through the central AI gateway + // rather than normal target-based proxying, so they're + // allowed to share a full-domain with a non-inference + // resource (and vice versa) - only conflicts within the + // same routing category are rejected. + isInference + ? eq(resources.mode, "inference") + : ne(resources.mode, "inference"), resourceId ? ne(resources.resourceId, resourceId) : isNotNull(resources.resourceId) diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 45e66c2c3..897d32f6a 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -18,7 +18,7 @@ import { import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { eq, and } from "drizzle-orm"; +import { eq, and, ne } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas"; @@ -484,11 +484,22 @@ async function createHttpResource( logger.debug(`Full domain: ${fullDomain}`); - // make sure the full domain is unique + // make sure the full domain is unique. Inference resources are routed + // through the central AI gateway rather than normal target-based + // proxying, so they're allowed to share a full-domain with a + // non-inference resource (and vice versa) - only conflicts within the + // same routing category are rejected. const existingResource = await db .select() .from(resources) - .where(eq(resources.fullDomain, fullDomain)); + .where( + and( + eq(resources.fullDomain, fullDomain), + effectiveMode === "inference" + ? eq(resources.mode, "inference") + : ne(resources.mode, "inference") + ) + ); if (existingResource.length > 0) { return next( diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 0d6f8f8aa..3ed26c9cb 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -596,10 +596,23 @@ async function updateHttpResource( logger.debug(`Full domain: ${fullDomain}`); if (fullDomain) { + // Inference resources route through the central AI gateway + // rather than normal target-based proxying, so they're allowed + // to share a full-domain with a non-inference resource (and + // vice versa) - only conflicts within the same routing category + // are rejected. mode isn't updatable here, so `resource.mode` + // reflects the resource's actual (unchanging) routing category. const [existingDomain] = await db .select() .from(resources) - .where(eq(resources.fullDomain, fullDomain)); + .where( + and( + eq(resources.fullDomain, fullDomain), + resource.mode === "inference" + ? eq(resources.mode, "inference") + : ne(resources.mode, "inference") + ) + ); if ( existingDomain && diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 50bbca6b4..7eb54c829 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -55,12 +55,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.strictObject({ }), query: z.string().optional(), mode: z - .enum(["host", "cidr", "http"]) + .enum(["host", "cidr", "http", "ssh", "inference"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["host", "cidr", "http"], + enum: ["host", "cidr", "http", "ssh", "inference"], description: "Filter site resources by mode" }), sort_by: z From c8f170d19771932f3eddb3438a7feea133d7def7 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 11:42:33 -0400 Subject: [PATCH 362/494] Allow budgets to be set on the resources --- server/lib/blueprints/aiBudgets.ts | 89 +++++++++++++++++++++++ server/lib/blueprints/privateResources.ts | 17 +++++ server/lib/blueprints/publicResources.ts | 17 +++++ server/lib/blueprints/types.ts | 39 +++++++++- 4 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 server/lib/blueprints/aiBudgets.ts diff --git a/server/lib/blueprints/aiBudgets.ts b/server/lib/blueprints/aiBudgets.ts new file mode 100644 index 000000000..0ae2fbdd4 --- /dev/null +++ b/server/lib/blueprints/aiBudgets.ts @@ -0,0 +1,89 @@ +import { eq } from "drizzle-orm"; +import { aiBudgets, Transaction } from "@server/db"; + +export type BlueprintAiBudgetInput = { + amount: number; + unit: "usd" | "tokens"; + period: + | "monthly" + | "yearly" + | "lifetime" + | "daily" + | "hourly" + | "weekly"; + enforcement: "hard" | "soft"; + enabled: boolean; +}; + +type SyncAiBudgetsInput = { + orgId: string; + trx: Transaction; + budgets: BlueprintAiBudgetInput[]; +} & ( + | { scope: "public"; resourceId: number } + | { scope: "site"; siteResourceId: number } +); + +/** + * Fully declarative: makes the resource's/site resource's AI budgets match + * exactly what the blueprint declares (omitted unit/period budgets are removed). + */ +export async function syncAiBudgets(input: SyncAiBudgetsInput): Promise { + const { orgId, trx, budgets } = input; + + const existing = await trx + .select() + .from(aiBudgets) + .where( + input.scope === "public" + ? eq(aiBudgets.resourceId, input.resourceId) + : eq(aiBudgets.siteResourceId, input.siteResourceId) + ); + + const existingByKey = new Map( + existing.map((b) => [`${b.unit}::${b.period}`, b]) + ); + + const seenKeys = new Set(); + const now = Date.now(); + + for (const budget of budgets) { + const key = `${budget.unit}::${budget.period}`; + seenKeys.add(key); + const existingBudget = existingByKey.get(key); + + if (existingBudget) { + await trx + .update(aiBudgets) + .set({ + amount: budget.amount, + enforcement: budget.enforcement, + enabled: budget.enabled, + updatedAt: now + }) + .where(eq(aiBudgets.budgetId, existingBudget.budgetId)); + } else { + await trx.insert(aiBudgets).values({ + orgId, + resourceId: input.scope === "public" ? input.resourceId : null, + siteResourceId: + input.scope === "site" ? input.siteResourceId : null, + amount: budget.amount, + unit: budget.unit, + period: budget.period, + enforcement: budget.enforcement, + enabled: budget.enabled, + createdAt: now, + updatedAt: now + }); + } + } + + for (const [key, existingBudget] of existingByKey) { + if (!seenKeys.has(key)) { + await trx + .delete(aiBudgets) + .where(eq(aiBudgets.budgetId, existingBudget.budgetId)); + } + } +} diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 070ac68b4..bcbee7ee6 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -30,6 +30,7 @@ import { build } from "@server/build"; import { LimitId } from "../billing"; import { usageService } from "../billing/usageService"; import { syncInferenceAiConfig } from "./aiProviders"; +import { syncAiBudgets } from "./aiBudgets"; async function getDomainForSiteResource( siteResourceId: number | undefined, @@ -367,6 +368,14 @@ export async function updatePrivateResources( })) }); + await syncAiBudgets({ + orgId, + trx, + scope: "site", + siteResourceId, + budgets: resourceData["ai-budget"] + }); + await trx .delete(clientSiteResources) .where(eq(clientSiteResources.siteResourceId, siteResourceId)); @@ -668,6 +677,14 @@ export async function updatePrivateResources( })) }); + await syncAiBudgets({ + orgId, + trx, + scope: "site", + siteResourceId, + budgets: resourceData["ai-budget"] + }); + const [adminRole] = await trx .select() .from(roles) diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 806d97e9c..4adc981ce 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -57,6 +57,7 @@ import next from "next"; import { LimitId } from "../billing"; import { usageService } from "../billing/usageService"; import { syncInferenceAiConfig } from "./aiProviders"; +import { syncAiBudgets } from "./aiBudgets"; export type PublicResourcesResults = { proxyResource: Resource; @@ -696,6 +697,14 @@ export async function updatePublicResources( }) ) }); + + await syncAiBudgets({ + orgId, + trx, + scope: "public", + resourceId: existingResource.resourceId, + budgets: resourceData["ai-budget"] || [] + }); } const existingResourceTargets = await trx @@ -1258,6 +1267,14 @@ export async function updatePublicResources( })) }); + await syncAiBudgets({ + orgId, + trx, + scope: "public", + resourceId: newResource.resourceId, + budgets: resourceData["ai-budget"] || [] + }); + await trx.insert(roleResources).values({ roleId: adminRole.roleId, resourceId: newResource.resourceId diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 099656ddc..d9e9415d0 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -5,6 +5,11 @@ import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema"; import { isValidRegionId } from "@server/db/regions"; import { wildcardSubdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; +import { + aiBudgetEnforcementSchema, + aiBudgetPeriodSchema, + aiBudgetUnitSchema +} from "@server/routers/aiBudget/validation"; const maxmindDbPath = config.getRawConfig().server.maxmind_db_path; const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path; @@ -211,6 +216,33 @@ export const AiProviderAttachmentSchema = z } ); +export const AiBudgetSchema = z.object({ + amount: z.number().positive(), + unit: aiBudgetUnitSchema, + period: aiBudgetPeriodSchema.optional().default("monthly"), + enforcement: aiBudgetEnforcementSchema.optional().default("hard"), + enabled: z.boolean().optional().default(true) +}); + +const aiBudgetArraySchema = z.array(AiBudgetSchema).refine( + (budgets) => { + const keys = budgets.map((b) => `${b.unit}::${b.period}`); + return keys.length === new Set(keys).size; + }, + { + message: + "'ai-budget' entries must not overlap: only one budget per unit/period combination is allowed" + } +); + +// No default here: an object with only 'targets' set must remain +// recognized as a targets-only resource by isTargetsOnlyResource(). +export const AiBudgetListSchema = aiBudgetArraySchema.optional(); + +export const AiBudgetListSchemaWithDefault = aiBudgetArraySchema + .optional() + .default([]); + export const AuthDaemonSchema = z .object({ pam: z.enum(["passthrough", "push"]).optional().default("passthrough"), @@ -257,7 +289,8 @@ export const PublicResourceSchema = z "proxy-protocol": z.boolean().optional(), "proxy-protocol-version": z.int().min(1).optional(), labels: z.array(z.string().min(1)).optional(), - "ai-providers": z.array(AiProviderAttachmentSchema).optional() + "ai-providers": z.array(AiProviderAttachmentSchema).optional(), + "ai-budget": AiBudgetListSchema }) .refine( (resource) => { @@ -565,7 +598,8 @@ export const PrivateResourceSchema = z machines: z.array(z.string()).optional().default([]), labels: z.array(z.string().min(1)).optional().default([]), "auth-daemon": AuthDaemonSchema.optional(), - "ai-providers": z.array(AiProviderAttachmentSchema).optional().default([]) + "ai-providers": z.array(AiProviderAttachmentSchema).optional().default([]), + "ai-budget": AiBudgetListSchemaWithDefault }) .refine( (data) => { @@ -921,3 +955,4 @@ export type Target = z.infer; export type Resource = z.infer; export type Config = z.infer; export type BlueprintResourcePolicy = z.infer; +export type BlueprintAiBudget = z.infer; From 7375b9efae5ed5caf3a4a7abcecdbe8009c6dd82 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 14 Aug 2026 11:38:11 -0400 Subject: [PATCH 363/494] improve model picker --- src/app/[orgId]/settings/layout.tsx | 3 +- src/app/navigation.tsx | 22 ++++ src/components/CreateVirtualApiKeyForm.tsx | 5 - src/components/EditVirtualApiKeyForm.tsx | 5 - src/components/LayoutMobileMenu.tsx | 66 +---------- src/components/LayoutSidebar.tsx | 121 +-------------------- src/components/SidebarNav.tsx | 20 +++- 7 files changed, 45 insertions(+), 197 deletions(-) diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index cac644be3..598df4f74 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -80,7 +80,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { orgId={params.orgId} orgs={orgs} navItems={orgNavSections(env, { - isPrimaryOrg: primaryOrg + isPrimaryOrg: primaryOrg, + isServerAdmin: user.serverAdmin })} commandNavItems={commandBarNavSections(env, { isPrimaryOrg: primaryOrg diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 9bd932f22..2f00744c0 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -47,6 +47,7 @@ export type SidebarNavSection = { export type OrgNavSectionsOptions = { isPrimaryOrg?: boolean; + isServerAdmin?: boolean; }; // Merged from 'user-management-and-resources' branch @@ -67,6 +68,27 @@ export const orgNavSections = ( env?: Env, options?: OrgNavSectionsOptions ): SidebarNavSection[] => [ + { + heading: "sidebarOverview", + items: [ + { + title: "resourceSidebarLauncherTitle", + href: "/{orgId}", + icon: , + exact: true + }, + ...(options?.isServerAdmin + ? [ + { + title: "serverAdmin", + href: "/admin", + icon: , + exact: true + } + ] + : []) + ] + }, { heading: "network", items: [ diff --git a/src/components/CreateVirtualApiKeyForm.tsx b/src/components/CreateVirtualApiKeyForm.tsx index 4a97d358a..5c2e75d0f 100644 --- a/src/components/CreateVirtualApiKeyForm.tsx +++ b/src/components/CreateVirtualApiKeyForm.tsx @@ -476,11 +476,6 @@ export default function CreateVirtualApiKeyForm({
-

- {t( - "virtualApiKeysInferenceBudgetDescription" - )} -

-

- {t( - "virtualApiKeysInferenceBudgetDescription" - )} -

org.orgId === orgId); - const isSettingsPage = Boolean( - orgId && pathname?.includes(`/${orgId}/settings`) - ); - const canViewResourceLauncher = Boolean( - currentOrg?.isAdmin || currentOrg?.isOwner - ); const mobileNavLinkClassName = cn( "flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5" @@ -183,58 +171,6 @@ export function LayoutMobileMenu({
- {!isAdminPage && - isSettingsPage && - canViewResourceLauncher && - orgId && ( -
- - setIsMobileMenuOpen( - false - ) - } - > - - - - - {t( - "resourceSidebarLauncherTitle" - )} - - -
- )} - {!isAdminPage && - user.serverAdmin && ( -
- - setIsMobileMenuOpen( - false - ) - } - > - - - - - {t( - "serverAdmin" - )} - - -
- )} diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index df1eb45d3..bf5f7bb4c 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -18,13 +18,7 @@ import { approvalQueries } from "@app/lib/queries"; import { build } from "@server/build"; import { useQuery } from "@tanstack/react-query"; import { ListUserOrgsResponse } from "@server/routers/org"; -import { - ArrowRight, - ExternalLink, - LayoutGrid, - PanelRightOpen, - Server -} from "lucide-react"; +import { ExternalLink, PanelRightOpen } from "lucide-react"; import { useTranslations } from "next-intl"; import dynamic from "next/dynamic"; import Link from "next/link"; @@ -136,13 +130,6 @@ export function LayoutSidebar({ const showTrial = build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial; - const isSettingsPage = Boolean( - orgId && pathname?.includes(`/${orgId}/settings`) - ); - const canViewResourceLauncher = Boolean( - currentOrg?.isAdmin || currentOrg?.isOwner - ); - return (
- {!isAdminPage && - isSettingsPage && - canViewResourceLauncher && - orgId && ( -
- {isSidebarCollapsed ? ( - - - - - - - - - - -

- {t( - "resourceSidebarLauncherTitle" - )} -

-
-
-
- ) : ( - - - - - - {t("resourceSidebarLauncherTitle")} - - - )} -
- )} - {!isAdminPage && user.serverAdmin && ( -
- {isSidebarCollapsed ? ( - - - - - - - - - - -

{t("serverAdmin")}

-
-
-
- ) : ( - - - - - - {t("serverAdmin")} - - - )} -
- )} {canShowProductUpdates ? ( diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index eb8386ffc..258d43a56 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -34,6 +34,7 @@ export type SidebarNavItem = { showEE?: boolean; isBeta?: boolean; items?: SidebarNavItem[]; + exact?: boolean; }; export type SidebarNavSection = { @@ -49,7 +50,14 @@ export interface SidebarNavProps extends React.HTMLAttributes { notificationCounts?: Record; } -function isPathActive(pathname: string, href: string): boolean { +function isPathActive( + pathname: string, + href: string, + exact?: boolean +): boolean { + if (exact) { + return pathname === href; + } return pathname === href || pathname.startsWith(`${href}/`); } @@ -289,7 +297,11 @@ function CollapsedNavItemWithPopover({ childItem.href ); const childIsActive = childHydratedHref - ? isPathActive(pathname, childHydratedHref) + ? isPathActive( + pathname, + childHydratedHref, + childItem.exact + ) : false; const childIsEE = build === "enterprise" && @@ -396,7 +408,7 @@ export function SidebarNav({ function isItemOrChildActive(item: SidebarNavItem): boolean { const hydratedHref = hydrateHref(item.href); - if (hydratedHref && isPathActive(pathname, hydratedHref)) { + if (hydratedHref && isPathActive(pathname, hydratedHref, item.exact)) { return true; } if (item.items) { @@ -412,7 +424,7 @@ export function SidebarNav({ const hydratedHref = hydrateHref(item.href); const hasNestedItems = item.items && item.items.length > 0; const isActive = hydratedHref - ? isPathActive(pathname, hydratedHref) + ? isPathActive(pathname, hydratedHref, item.exact) : false; const isChildActive = hasNestedItems ? isItemOrChildActive(item) From b34a8d116b6cafd8d61224756ccbc45def733b61 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 14 Aug 2026 12:18:55 -0400 Subject: [PATCH 364/494] improve org selector --- src/components/OrgPicker.tsx | 137 ++++++++++++ src/components/OrgSelector.tsx | 203 ++++-------------- .../resource-launcher/LauncherOrgSelector.tsx | 140 ++---------- 3 files changed, 193 insertions(+), 287 deletions(-) create mode 100644 src/components/OrgPicker.tsx diff --git a/src/components/OrgPicker.tsx b/src/components/OrgPicker.tsx new file mode 100644 index 000000000..3113198fc --- /dev/null +++ b/src/components/OrgPicker.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { cn } from "@app/lib/cn"; +import { build } from "@server/build"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { CheckIcon, Plus } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { usePathname, useRouter } from "next/navigation"; +import { useMemo, useState, type ReactNode } from "react"; + +export type OrgPickerProps = { + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + contentClassName?: string; + sideOffset?: number; + children: ReactNode; +}; + +export function OrgPicker({ + orgId, + orgs, + contentClassName, + sideOffset = 0, + children +}: OrgPickerProps) { + const [open, setOpen] = useState(false); + const router = useRouter(); + const pathname = usePathname(); + const t = useTranslations(); + const { env } = useEnvContext(); + const { user } = useUserContext(); + + let canCreateOrg = !env.flags.disableUserCreateOrg || user.serverAdmin; + if (build === "saas" && user.type !== "internal") { + canCreateOrg = false; + } + + const sortedOrgs = useMemo(() => { + if (!orgs?.length) { + return orgs ?? []; + } + return [...orgs].sort((a, b) => { + const aPrimary = Boolean(a.isPrimaryOrg); + const bPrimary = Boolean(b.isPrimaryOrg); + if (aPrimary && !bPrimary) return -1; + if (!aPrimary && bPrimary) return 1; + return 0; + }); + }, [orgs]); + + function selectOrg(nextOrgId: string) { + setOpen(false); + const newPath = pathname.includes("/settings/") + ? pathname.replace(/^\/[^/]+/, `/${nextOrgId}`) + : `/${nextOrgId}`; + router.push(newPath); + } + + return ( + + {children} + + + + + {t("orgNotFound2")} + + {sortedOrgs.map((org) => ( + selectOrg(org.orgId)} + > + +
+ + {org.name} + + + {org.orgId} + {org.isPrimaryOrg + ? ` · ${t("primary")}` + : ""} + +
+
+ ))} +
+
+
+ {canCreateOrg && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index 79f2fad7d..9742fdb90 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -1,199 +1,76 @@ "use client"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; +import { OrgPicker } from "@app/components/OrgPicker"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; -import { Badge } from "@app/components/ui/badge"; -import { useEnvContext } from "@app/hooks/useEnvContext"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; -import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react"; -import { Button } from "@app/components/ui/button"; -import { usePathname, useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; -import { useUserContext } from "@app/hooks/useUserContext"; +import { Building2, ChevronsUpDown } from "lucide-react"; import { useTranslations } from "next-intl"; -import { build } from "@server/build"; -interface OrgSelectorProps { +type OrgSelectorProps = { orgId?: string; orgs?: ListUserOrgsResponse["orgs"]; isCollapsed?: boolean; -} +}; export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorProps) { - const { user } = useUserContext(); - const [open, setOpen] = useState(false); - const router = useRouter(); - const pathname = usePathname(); - const { env } = useEnvContext(); const t = useTranslations(); - const selectedOrg = orgs?.find((org) => org.orgId === orgId); - let canCreateOrg = !env.flags.disableUserCreateOrg || user.serverAdmin; - if (build === "saas" && user.type !== "internal") { - canCreateOrg = false; - } - - const sortedOrgs = useMemo(() => { - if (!orgs?.length) return orgs ?? []; - return [...orgs].sort((a, b) => { - const aPrimary = Boolean(a.isPrimaryOrg); - const bPrimary = Boolean(b.isPrimaryOrg); - if (aPrimary && !bPrimary) return -1; - if (!aPrimary && bPrimary) return 1; - return 0; - }); - }, [orgs]); - - const orgSelectorContent = ( - - -
- {isCollapsed ? ( - - ) : ( -
-
-
- - {t("org")} - - - {selectedOrg?.name || t("noneSelected")} - -
-
- -
- )} -
-
- +
- - - - -
- {t("orgNotFound2")} + {isCollapsed ? ( + + ) : ( +
+
+
+ + {t("org")} + + + {selectedOrg?.name || t("noneSelected")} +
- - - {sortedOrgs.map((org) => ( - { - setOpen(false); - const newPath = pathname.includes( - "/settings/" - ) - ? pathname.replace( - /^\/[^/]+/, - `/${org.orgId}` - ) - : `/${org.orgId}`; - router.push(newPath); - }} - className="mx-1 rounded-md py-1.5 h-auto min-h-0" - > -
- -
-
- - {org.name} - -
- - {org.orgId} - - {org.isPrimaryOrg && ( - - {t("primary")} - - )} -
-
- -
- ))} -
- - - {canCreateOrg && ( -
- +
+
)} - - +
+ ); if (isCollapsed) { return ( - - {orgSelectorContent} - + {picker}

@@ -209,5 +86,5 @@ export function OrgSelector({ ); } - return orgSelectorContent; + return picker; } diff --git a/src/components/resource-launcher/LauncherOrgSelector.tsx b/src/components/resource-launcher/LauncherOrgSelector.tsx index 04773d2de..80510d77e 100644 --- a/src/components/resource-launcher/LauncherOrgSelector.tsx +++ b/src/components/resource-launcher/LauncherOrgSelector.tsx @@ -1,28 +1,10 @@ "use client"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { cn } from "@app/lib/cn"; -import { ListUserOrgsResponse } from "@server/routers/org"; -import { Check, ChevronDown, Plus } from "lucide-react"; -import { usePathname, useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; -import { useTranslations } from "next-intl"; +import { OrgPicker } from "@app/components/OrgPicker"; import { Button } from "@app/components/ui/button"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { build } from "@server/build"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { ChevronDown } from "lucide-react"; +import { useTranslations } from "next-intl"; type LauncherOrgSelectorProps = { orgId?: string; @@ -30,111 +12,21 @@ type LauncherOrgSelectorProps = { }; export function LauncherOrgSelector({ orgId, orgs }: LauncherOrgSelectorProps) { - const [open, setOpen] = useState(false); - const router = useRouter(); - const pathname = usePathname(); const t = useTranslations(); - const { env } = useEnvContext(); - const { user } = useUserContext(); - const selectedOrg = orgs?.find((org) => org.orgId === orgId); - let canCreateOrg = !env.flags.disableUserCreateOrg || user.serverAdmin; - if (build === "saas" && user.type !== "internal") { - canCreateOrg = false; - } - - const sortedOrgs = useMemo(() => { - if (!orgs?.length) { - return orgs ?? []; - } - return [...orgs].sort((a, b) => { - const aPrimary = Boolean(a.isPrimaryOrg); - const bPrimary = Boolean(b.isPrimaryOrg); - if (aPrimary && !bPrimary) { - return -1; - } - if (!aPrimary && bPrimary) { - return 1; - } - return 0; - }); - }, [orgs]); - return ( - - - - - - - - - {t("orgNotFound2")} - - {sortedOrgs.map((org) => ( - { - setOpen(false); - const newPath = pathname.includes( - "/settings/" - ) - ? pathname.replace( - /^\/[^/]+/, - `/${org.orgId}` - ) - : `/${org.orgId}`; - router.push(newPath); - }} - > -

- - {org.name} - - - {org.orgId} - -
- - - ))} - - - - {canCreateOrg && ( -
- -
- )} - - + + + ); } From 234819f8f22ccb7ecd5df39ecd0d5a2e0e02d0fb Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 11:53:10 -0400 Subject: [PATCH 365/494] Dont load private resource inference without a exit node loaded --- server/lib/traefik/getTraefikConfig.ts | 172 +++++++------- .../private/lib/traefik/getTraefikConfig.ts | 212 +++++++++--------- 2 files changed, 196 insertions(+), 188 deletions(-) diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index d0d553910..da9fb0810 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -767,15 +767,14 @@ export async function getTraefikConfig( if (!config_output.http.middlewares) { config_output.http.middlewares = {}; } - config_output.http.middlewares[aiGatewayTrustMiddlewareNameResource] = - { - headers: { - customRequestHeaders: { - [AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(), - [AI_GATEWAY_RESOURCE_TYPE_HEADER]: "resource" - } + config_output.http.middlewares[aiGatewayTrustMiddlewareNameResource] = { + headers: { + customRequestHeaders: { + [AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(), + [AI_GATEWAY_RESOURCE_TYPE_HEADER]: "resource" } - }; + } + }; config_output.http.middlewares[ aiGatewayTrustMiddlewareNameSiteResource ] = { @@ -798,15 +797,14 @@ export async function getTraefikConfig( const enableAiGatewayClientIpHeader = config.getRawConfig().server.enable_ai_gateway_client_ip_header; if (enableAiGatewayClientIpHeader) { - config_output.http.middlewares[aiGatewayClientIpMiddlewareName] = - { - plugin: { - badger: { - disableForwardAuth: true, - realIpHeader: AI_GATEWAY_CLIENT_IP_HEADER - } + config_output.http.middlewares[aiGatewayClientIpMiddlewareName] = { + plugin: { + badger: { + disableForwardAuth: true, + realIpHeader: AI_GATEWAY_CLIENT_IP_HEADER } - }; + } + }; } // Public inference resources: same TLS/cert-resolver handling as @@ -935,90 +933,94 @@ export async function getTraefikConfig( ) ); - for (const sr of siteResourcesInference) { - if (!sr.enabled || !sr.fullDomain) continue; + if (exitNode) { + for (const sr of siteResourcesInference) { + if (!sr.enabled || !sr.fullDomain) continue; - if (!config_output.http.routers) config_output.http.routers = {}; - if (!config_output.http.services) config_output.http.services = {}; + if (!config_output.http.routers) + config_output.http.routers = {}; + if (!config_output.http.services) + config_output.http.services = {}; - const fullDomain = sr.fullDomain; - const srKey = `inference-sr${sr.siteResourceId}`; - const routerName = `${srKey}-router`; - const serviceName = `${srKey}-service`; - const rule = `Host(\`${fullDomain}\`) && ClientIP(${exitNode.address})`; // restrict to coming from the exit node ip range that the client is connected to + const fullDomain = sr.fullDomain; + const srKey = `inference-sr${sr.siteResourceId}`; + const routerName = `${srKey}-router`; + const serviceName = `${srKey}-service`; + const rule = `Host(\`${fullDomain}\`) && ClientIP(${exitNode.address})`; // restrict to coming from the exit node ip range that the client is connected to - const domainParts = fullDomain.split("."); - const wildCard = - domainParts.length <= 2 - ? `*.${domainParts.join(".")}` - : `*.${domainParts.slice(1).join(".")}`; + const domainParts = fullDomain.split("."); + const wildCard = + domainParts.length <= 2 + ? `*.${domainParts.join(".")}` + : `*.${domainParts.slice(1).join(".")}`; - const globalDefaultResolver = - config.getRawConfig().traefik.cert_resolver; - const globalDefaultPreferWildcard = - config.getRawConfig().traefik.prefer_wildcard_cert; + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; - const tls = { - certResolver: globalDefaultResolver, - ...(globalDefaultPreferWildcard - ? { domains: [{ main: wildCard }] } - : {}) - }; + const tls = { + certResolver: globalDefaultResolver, + ...(globalDefaultPreferWildcard + ? { domains: [{ main: wildCard }] } + : {}) + }; - const srHeadersMiddlewareName = `${srKey}-headers-middleware`; - if (!config_output.http.middlewares) { - config_output.http.middlewares = {}; - } - config_output.http.middlewares[srHeadersMiddlewareName] = { - headers: { - customRequestHeaders: { - ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": fullDomain - } + const srHeadersMiddlewareName = `${srKey}-headers-middleware`; + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; } - }; + config_output.http.middlewares[srHeadersMiddlewareName] = { + headers: { + customRequestHeaders: { + ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), + "p-host": fullDomain + } + } + }; - const additionalMiddlewares = - config.getRawConfig().traefik.additional_middlewares || []; - const routerMiddlewares = [ - ...(enableAiGatewayClientIpHeader - ? [aiGatewayClientIpMiddlewareName] - : []), - aiGatewayTrustMiddlewareNameSiteResource, - srHeadersMiddlewareName, - ...additionalMiddlewares - ]; + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; + const routerMiddlewares = [ + ...(enableAiGatewayClientIpHeader + ? [aiGatewayClientIpMiddlewareName] + : []), + aiGatewayTrustMiddlewareNameSiteResource, + srHeadersMiddlewareName, + ...additionalMiddlewares + ]; - if (sr.ssl) { - config_output.http.routers[routerName + "-redirect"] = { + if (sr.ssl) { + config_output.http.routers[routerName + "-redirect"] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [redirectHttpsMiddlewareName], + service: serviceName, + rule, + priority: 200 // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first. + }; + } + + config_output.http.routers[routerName] = { entryPoints: [ - config.getRawConfig().traefik.http_entrypoint + sr.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint ], - middlewares: [redirectHttpsMiddlewareName], + middlewares: routerMiddlewares, service: serviceName, rule, - priority: 200 // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first. + priority: 200, // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first. + ...(sr.ssl ? { tls } : {}) + }; + + config_output.http.services[serviceName] = { + loadBalancer: { + servers: [{ url: aiGatewayUrl }] + } }; } - - config_output.http.routers[routerName] = { - entryPoints: [ - sr.ssl - ? config.getRawConfig().traefik.https_entrypoint - : config.getRawConfig().traefik.http_entrypoint - ], - middlewares: routerMiddlewares, - service: serviceName, - rule, - priority: 200, // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first. - ...(sr.ssl ? { tls } : {}) - }; - - config_output.http.services[serviceName] = { - loadBalancer: { - servers: [{ url: aiGatewayUrl }] - } - }; } } diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index f90305bd1..cc841fc1e 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -1581,15 +1581,14 @@ export async function getTraefikConfig( "ai-gateway-trust-headers-resource"; const aiGatewayTrustMiddlewareNameSiteResource = "ai-gateway-trust-headers-site-resource"; - config_output.http.middlewares[aiGatewayTrustMiddlewareNameResource] = - { - headers: { - customRequestHeaders: { - [AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(), - [AI_GATEWAY_RESOURCE_TYPE_HEADER]: "resource" - } + config_output.http.middlewares[aiGatewayTrustMiddlewareNameResource] = { + headers: { + customRequestHeaders: { + [AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(), + [AI_GATEWAY_RESOURCE_TYPE_HEADER]: "resource" } - }; + } + }; config_output.http.middlewares[ aiGatewayTrustMiddlewareNameSiteResource ] = { @@ -1612,15 +1611,14 @@ export async function getTraefikConfig( const enableAiGatewayClientIpHeader = config.getRawConfig().server.enable_ai_gateway_client_ip_header; if (enableAiGatewayClientIpHeader) { - config_output.http.middlewares[aiGatewayClientIpMiddlewareName] = - { - plugin: { - badger: { - disableForwardAuth: true, - realIpHeader: AI_GATEWAY_CLIENT_IP_HEADER - } + config_output.http.middlewares[aiGatewayClientIpMiddlewareName] = { + plugin: { + badger: { + disableForwardAuth: true, + realIpHeader: AI_GATEWAY_CLIENT_IP_HEADER } - }; + } + }; } // Public inference resources: same TLS/cert-resolver handling as @@ -1742,108 +1740,116 @@ export async function getTraefikConfig( }; } - // Private (siteResource) inference resources: routed by their alias - // instead of a public fullDomain, and deliberately WITHOUT the - // badger middleware - no per-user auth/policy stack exists for - // siteResources today (see plan doc), so gating here is - // reachability-only for now. - for (const sr of siteResourcesInference) { - if (!sr.enabled || !sr.fullDomain) continue; + if (exitNode) { + // Private (siteResource) inference resources: routed by their alias + // instead of a public fullDomain, and deliberately WITHOUT the + // badger middleware - no per-user auth/policy stack exists for + // siteResources today (see plan doc), so gating here is + // reachability-only for now. + for (const sr of siteResourcesInference) { + if (!sr.enabled || !sr.fullDomain) continue; - if (!config_output.http.routers) config_output.http.routers = {}; - if (!config_output.http.services) config_output.http.services = {}; + if (!config_output.http.routers) + config_output.http.routers = {}; + if (!config_output.http.services) + config_output.http.services = {}; - const fullDomain = sr.fullDomain; - const srKey = `inference-sr${sr.siteResourceId}`; - const routerName = `${srKey}-router`; - const serviceName = `${srKey}-service`; - const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to + const fullDomain = sr.fullDomain; + const srKey = `inference-sr${sr.siteResourceId}`; + const routerName = `${srKey}-router`; + const serviceName = `${srKey}-service`; + const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to - let tls: any = {}; - if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { - const domainParts = fullDomain.split("."); - const wildCard = - domainParts.length <= 2 - ? `*.${domainParts.join(".")}` - : `*.${domainParts.slice(1).join(".")}`; + let tls: any = {}; + if ( + !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns + ) { + const domainParts = fullDomain.split("."); + const wildCard = + domainParts.length <= 2 + ? `*.${domainParts.join(".")}` + : `*.${domainParts.slice(1).join(".")}`; - const globalDefaultResolver = - config.getRawConfig().traefik.cert_resolver; - const globalDefaultPreferWildcard = - config.getRawConfig().traefik.prefer_wildcard_cert; + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; - tls = { - certResolver: globalDefaultResolver, - ...(globalDefaultPreferWildcard - ? { domains: [{ main: wildCard }] } - : {}) - }; - } else { - const matchingCert = validCerts.find( - (cert) => cert.queriedDomain === fullDomain - ); - if (!matchingCert) { - logger.debug( - `No matching certificate found for inference siteResource fullDomain: ${fullDomain}` + tls = { + certResolver: globalDefaultResolver, + ...(globalDefaultPreferWildcard + ? { domains: [{ main: wildCard }] } + : {}) + }; + } else { + const matchingCert = validCerts.find( + (cert) => cert.queriedDomain === fullDomain ); - continue; - } - } - - const additionalMiddlewares = - config.getRawConfig().traefik.additional_middlewares || []; - const routerMiddlewares: string[] = [ - ...(enableAiGatewayClientIpHeader - ? [aiGatewayClientIpMiddlewareName] - : []), - aiGatewayTrustMiddlewareNameSiteResource - ]; - - if (aiGatewayOverride) { - const srHeadersMiddlewareName = `${srKey}-headers-middleware`; - config_output.http.middlewares[srHeadersMiddlewareName] = { - headers: { - customRequestHeaders: { - ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": fullDomain - } + if (!matchingCert) { + logger.debug( + `No matching certificate found for inference siteResource fullDomain: ${fullDomain}` + ); + continue; } - }; - routerMiddlewares.push(srHeadersMiddlewareName); - } + } - routerMiddlewares.push(...additionalMiddlewares); + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; + const routerMiddlewares: string[] = [ + ...(enableAiGatewayClientIpHeader + ? [aiGatewayClientIpMiddlewareName] + : []), + aiGatewayTrustMiddlewareNameSiteResource + ]; - if (sr.ssl) { - config_output.http.routers[routerName + "-redirect"] = { + if (aiGatewayOverride) { + const srHeadersMiddlewareName = `${srKey}-headers-middleware`; + config_output.http.middlewares[srHeadersMiddlewareName] = { + headers: { + customRequestHeaders: { + ...(aiGatewayHost + ? { Host: aiGatewayHost } + : {}), + "p-host": fullDomain + } + } + }; + routerMiddlewares.push(srHeadersMiddlewareName); + } + + routerMiddlewares.push(...additionalMiddlewares); + + if (sr.ssl) { + config_output.http.routers[routerName + "-redirect"] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [redirectHttpsMiddlewareName], + service: serviceName, + rule, + priority: 200 // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first. + }; + } + + config_output.http.routers[routerName] = { entryPoints: [ - config.getRawConfig().traefik.http_entrypoint + sr.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint ], - middlewares: [redirectHttpsMiddlewareName], + middlewares: routerMiddlewares, service: serviceName, rule, - priority: 200 // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first. + priority: 200, // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first. + ...(sr.ssl ? { tls } : {}) + }; + + config_output.http.services[serviceName] = { + loadBalancer: { + servers: [{ url: aiGatewayUrl }] + } }; } - - config_output.http.routers[routerName] = { - entryPoints: [ - sr.ssl - ? config.getRawConfig().traefik.https_entrypoint - : config.getRawConfig().traefik.http_entrypoint - ], - middlewares: routerMiddlewares, - service: serviceName, - rule, - priority: 200, // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first. - ...(sr.ssl ? { tls } : {}) - }; - - config_output.http.services[serviceName] = { - loadBalancer: { - servers: [{ url: aiGatewayUrl }] - } - }; } } From 954dc8d1d9da5ba90d3422de412ed463e4db7265 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 12:04:04 -0400 Subject: [PATCH 366/494] Handle overlapping fullDomain properly --- server/lib/blueprints/privateResources.ts | 4 +- server/lib/blueprints/publicResources.ts | 72 +++++++++++++------ .../siteResource/createSiteResource.ts | 7 +- .../siteResource/updateSiteResource.ts | 7 +- 4 files changed, 59 insertions(+), 31 deletions(-) diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index bcbee7ee6..97903e722 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -48,7 +48,9 @@ async function getDomainForSiteResource( eq(siteResources.orgId, orgId), // exclude looking at the ones on exit nodes if this is an inference resource, // and vice versa, so inference and non-inference resources can share a full-domain - ne(siteResources.requiresExitNodeConnection, !isInference), + isInference + ? ne(siteResources.mode, "inference") + : eq(siteResources.mode, "inference"), siteResourceId ? ne(siteResources.siteResourceId, siteResourceId) : isNotNull(siteResources.siteResourceId) diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 4adc981ce..b495b6e5e 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -289,7 +289,9 @@ export async function updatePublicResources( if (existingResource) { let domain; if ( - ["http", "ssh", "rdp", "vnc", "inference"].includes(resourceData.mode || "") + ["http", "ssh", "rdp", "vnc", "inference"].includes( + resourceData.mode || "" + ) ) { if (resourceData["full-domain"]?.startsWith("*.")) { const isLicensed = await isLicensedOrSubscribed( @@ -374,14 +376,22 @@ export async function updatePublicResources( name: resourceData.name || "Unnamed Resource", mode: resourceData.mode, - proxyPort: ["http", "ssh", "rdp", "vnc", "inference"].includes( - resourceData.mode || "" - ) + proxyPort: [ + "http", + "ssh", + "rdp", + "vnc", + "inference" + ].includes(resourceData.mode || "") ? null : resourceData["proxy-port"], - fullDomain: ["http", "ssh", "rdp", "vnc", "inference"].includes( - resourceData.mode || "" - ) + fullDomain: [ + "http", + "ssh", + "rdp", + "vnc", + "inference" + ].includes(resourceData.mode || "") ? resourceData["full-domain"] : null, subdomain: domain ? domain.subdomain : null, @@ -571,14 +581,22 @@ export async function updatePublicResources( .set({ name: resourceData.name || "Unnamed Resource", mode: resourceData.mode, - proxyPort: ["http", "ssh", "rdp", "vnc", "inference"].includes( - resourceData.mode || "" - ) + proxyPort: [ + "http", + "ssh", + "rdp", + "vnc", + "inference" + ].includes(resourceData.mode || "") ? null : resourceData["proxy-port"], - fullDomain: ["http", "ssh", "rdp", "vnc", "inference"].includes( - resourceData.mode || "" - ) + fullDomain: [ + "http", + "ssh", + "rdp", + "vnc", + "inference" + ].includes(resourceData.mode || "") ? resourceData["full-domain"] : null, subdomain: domain ? domain.subdomain : null, @@ -1090,7 +1108,9 @@ export async function updatePublicResources( let domain; if ( - ["http", "ssh", "rdp", "vnc", "inference"].includes(resourceData.mode || "") + ["http", "ssh", "rdp", "vnc", "inference"].includes( + resourceData.mode || "" + ) ) { if (resourceData["full-domain"]?.startsWith("*.")) { const isLicensed = await isLicensedOrSubscribed( @@ -1188,14 +1208,22 @@ export async function updatePublicResources( status: resourceStatusFromSite, name: resourceData.name || "Unnamed Resource", mode: resourceData.mode, - proxyPort: ["http", "ssh", "rdp", "vnc", "inference"].includes( - resourceData.mode || "" - ) + proxyPort: [ + "http", + "ssh", + "rdp", + "vnc", + "inference" + ].includes(resourceData.mode || "") ? null : resourceData["proxy-port"], - fullDomain: ["http", "ssh", "rdp", "vnc", "inference"].includes( - resourceData.mode || "" - ) + fullDomain: [ + "http", + "ssh", + "rdp", + "vnc", + "inference" + ].includes(resourceData.mode || "") ? resourceData["full-domain"] : null, subdomain: domain ? domain.subdomain : null, @@ -2148,8 +2176,8 @@ export async function getDomain( // resource (and vice versa) - only conflicts within the // same routing category are rejected. isInference - ? eq(resources.mode, "inference") - : ne(resources.mode, "inference"), + ? ne(resources.mode, "inference") + : eq(resources.mode, "inference"), resourceId ? ne(resources.resourceId, resourceId) : isNotNull(resources.resourceId) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 2038f9ce7..5887aae52 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -518,10 +518,9 @@ export async function createSiteResource( .where( and( eq(siteResources.fullDomain, fullDomain), - ne( - siteResources.requiresExitNodeConnection, - mode == "inference" - ) + mode == "inference" + ? ne(siteResources.mode, "inference") + : eq(siteResources.mode, "inference") ) ); // exclude looking at the ones on exit nodes if this is an inference resource diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 3ec8f7f65..e0740e8c7 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -484,10 +484,9 @@ export async function updateSiteResource( .where( and( eq(siteResources.fullDomain, fullDomain), - ne( - siteResources.requiresExitNodeConnection, - mode == "inference" - ) + mode == "inference" + ? ne(siteResources.mode, "inference") + : eq(siteResources.mode, "inference") ) ); // exclude looking at the ones on exit nodes if this is an inference resource From 193f6da8d1fa1bdcd8496a97f26aa6bf437e6873 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 12:23:07 -0400 Subject: [PATCH 367/494] Handle the list of models without allow/block --- server/lib/blueprints/aiProviders.ts | 28 +++++++++-------------- server/lib/blueprints/privateResources.ts | 10 ++------ server/lib/blueprints/publicResources.ts | 10 ++------ server/lib/blueprints/types.ts | 18 ++++++++------- 4 files changed, 25 insertions(+), 41 deletions(-) diff --git a/server/lib/blueprints/aiProviders.ts b/server/lib/blueprints/aiProviders.ts index 5dbf18bf0..14e9914a3 100644 --- a/server/lib/blueprints/aiProviders.ts +++ b/server/lib/blueprints/aiProviders.ts @@ -26,7 +26,7 @@ export type BlueprintAiProviderInput = { provider: string; accessMode: AccessMode; enabled: boolean; - models: BlueprintAiModelInput[]; + models: string[]; }; async function resolveProviderNiceIds( @@ -99,12 +99,12 @@ async function resolveModelKeys( for (const provider of providers) { const providerId = providerIdByNiceId.get(provider.provider)!; for (const m of provider.models) { - const modelId = byProviderAndKey.get(`${providerId}::${m.model}`); + const modelId = byProviderAndKey.get(`${providerId}::${m}`); if (modelId === undefined) { - missing.push(`${provider.provider}/${m.model}`); + missing.push(`${provider.provider}/${m}`); continue; } - modelIdByEntryKey.set(`${provider.provider}::${m.model}`, modelId); + modelIdByEntryKey.set(`${provider.provider}::${m}`, modelId); } } @@ -117,7 +117,7 @@ async function resolveModelKeys( async function validateModelEntries(input: { orgId: string; - entries: { modelId: number; listType: ModelListType }[]; + entries: { modelId: number }[]; selectProviderIds: number[]; trx: Transaction; }): Promise { @@ -157,13 +157,10 @@ async function validateModelEntries(input: { `Model ${entry.modelId} does not exist or does not belong to a select-mode attached provider` ); } - if (catalog.listType !== entry.listType) { - throw new Error( - `Model ${entry.modelId} must use list-type "${catalog.listType}" to match the provider catalog entry` - ); - } if (!catalog.enabled) { - throw new Error(`Model ${entry.modelId} is disabled on its provider`); + throw new Error( + `Model ${entry.modelId} is disabled on its provider` + ); } } } @@ -238,8 +235,7 @@ export async function syncInferenceAiConfig( const modelEntries = input.providers.flatMap((p) => p.models.map((m) => ({ - modelId: modelIdByEntryKey.get(`${p.provider}::${m.model}`)!, - listType: m.listType + modelId: modelIdByEntryKey.get(`${p.provider}::${m}`)! })) ); @@ -262,8 +258,7 @@ export async function syncInferenceAiConfig( await trx.insert(resourceAiModels).values( modelEntries.map((m) => ({ resourceId: input.resourceId, - modelId: m.modelId, - listType: m.listType + modelId: m.modelId })) ); } @@ -277,8 +272,7 @@ export async function syncInferenceAiConfig( await trx.insert(siteResourceAiModels).values( modelEntries.map((m) => ({ siteResourceId: input.siteResourceId, - modelId: m.modelId, - listType: m.listType + modelId: m.modelId })) ); } diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 97903e722..40d058634 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -363,10 +363,7 @@ export async function updatePrivateResources( provider: p.provider, accessMode: p["access-mode"], enabled: p.enabled, - models: p.models.map((m) => ({ - model: m.model, - listType: m["list-type"] - })) + models: p.models })) }); @@ -672,10 +669,7 @@ export async function updatePrivateResources( provider: p.provider, accessMode: p["access-mode"], enabled: p.enabled, - models: p.models.map((m) => ({ - model: m.model, - listType: m["list-type"] - })) + models: p.models })) }); diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index b495b6e5e..2f4d9773d 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -708,10 +708,7 @@ export async function updatePublicResources( provider: p.provider, accessMode: p["access-mode"], enabled: p.enabled, - models: p.models.map((m) => ({ - model: m.model, - listType: m["list-type"] - })) + models: p.models }) ) }); @@ -1288,10 +1285,7 @@ export async function updatePublicResources( provider: p.provider, accessMode: p["access-mode"], enabled: p.enabled, - models: p.models.map((m) => ({ - model: m.model, - listType: m["list-type"] - })) + models: p.models })) }); diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index d9e9415d0..74c5d5ca2 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -188,11 +188,6 @@ export const HeaderSchema = z.object({ value: z.string().min(1) }); -export const AiProviderModelEntrySchema = z.object({ - model: z.string().min(1), - "list-type": z.enum(["allow", "block"]) -}); - export const AiProviderAttachmentSchema = z .object({ provider: z.string().min(1), @@ -201,7 +196,7 @@ export const AiProviderAttachmentSchema = z .optional() .default("inherit"), enabled: z.boolean().optional().default(true), - models: z.array(AiProviderModelEntrySchema).optional().default([]) + models: z.array(z.string()).optional().default([]) }) .refine( (provider) => { @@ -598,7 +593,10 @@ export const PrivateResourceSchema = z machines: z.array(z.string()).optional().default([]), labels: z.array(z.string().min(1)).optional().default([]), "auth-daemon": AuthDaemonSchema.optional(), - "ai-providers": z.array(AiProviderAttachmentSchema).optional().default([]), + "ai-providers": z + .array(AiProviderAttachmentSchema) + .optional() + .default([]), "ai-budget": AiBudgetListSchemaWithDefault }) .refine( @@ -608,7 +606,11 @@ export const PrivateResourceSchema = z data.mode === "ssh" && (data["auth-daemon"] === undefined || data["auth-daemon"].mode === "native"); - if (data.mode !== "inference" && !isNativeSSH && !data.destination) { + if ( + data.mode !== "inference" && + !isNativeSSH && + !data.destination + ) { return false; } return true; From d7bff686c0a9b7f2b25d3b3e96a4c8c2853f29a4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 14 Aug 2026 12:23:50 -0400 Subject: [PATCH 368/494] add help banner to providers and virtual api keys --- messages/en-US.json | 5 +++ .../[orgId]/settings/ai-providers/page.tsx | 3 ++ .../settings/virtual-api-keys/page.tsx | 3 ++ src/components/AiProvidersBanner.tsx | 21 +++++++++ src/components/VirtualApiKeysBanner.tsx | 45 +++++++++++++++++++ 5 files changed, 77 insertions(+) create mode 100644 src/components/AiProvidersBanner.tsx create mode 100644 src/components/VirtualApiKeysBanner.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 52458fcbe..b58ebfd0c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1678,6 +1678,9 @@ "commandVirtualApiKeys": "Virtual API Keys", "virtualApiKeysTitle": "Manage Virtual API Keys", "virtualApiKeysDescription": "Create and manage manual API keys for AI Gateway access to public AI gateways", + "virtualApiKeysBannerTitle": "Identity Keys for Every User", + "virtualApiKeysBannerDescription": "Every user already has an identity key available at {keysUrl}. You can also manually generate keys here that grant direct access to public AI gateways.", + "virtualApiKeysBannerButtonText": "View Identity Keys", "virtualApiKeys": "Virtual API Keys", "virtualApiKeysSearch": "Search keys...", "virtualApiKeysCreate": "Create Virtual API Key", @@ -1742,6 +1745,8 @@ "myVirtualApiKeysViewSecretDescription": "This secret authenticates you to AI Gateway resources", "aiProvidersTitle": "AI Providers", "aiProvidersDescription": "Connect model providers for AI workloads in this organization", + "aiProvidersBannerTitle": "Connect Model Providers", + "aiProvidersBannerDescription": "Providers are the model backends Pangolin uses for AI workloads. Connect OpenAI, Anthropic, and other providers here, then attach them to public AI gateways so users can call models with identity-aware access, budgets, and logging.", "aiProvidersAdd": "Add Provider", "aiProvidersSearch": "Search providers...", "aiProvidersEmpty": "No AI providers yet", diff --git a/src/app/[orgId]/settings/ai-providers/page.tsx b/src/app/[orgId]/settings/ai-providers/page.tsx index 024170590..572ce90d4 100644 --- a/src/app/[orgId]/settings/ai-providers/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/page.tsx @@ -1,3 +1,4 @@ +import AiProvidersBanner from "@app/components/AiProvidersBanner"; import AiProvidersTable from "@app/components/AiProvidersTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; @@ -49,6 +50,8 @@ export default async function AiProvidersPage({ params, searchParams }: Props) { description={t("aiProvidersDescription")} /> + + ({ diff --git a/src/app/[orgId]/settings/virtual-api-keys/page.tsx b/src/app/[orgId]/settings/virtual-api-keys/page.tsx index ba8f4ea01..28a73253e 100644 --- a/src/app/[orgId]/settings/virtual-api-keys/page.tsx +++ b/src/app/[orgId]/settings/virtual-api-keys/page.tsx @@ -6,6 +6,7 @@ import { redirect } from "next/navigation"; import { cache } from "react"; import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; +import VirtualApiKeysBanner from "@app/components/VirtualApiKeysBanner"; import VirtualApiKeysTable, { type VirtualApiKeyRow } from "@app/components/VirtualApiKeysTable"; @@ -142,6 +143,8 @@ export default async function VirtualApiKeysPage( description={t("virtualApiKeysDescription")} /> + + { + const t = useTranslations(); + + return ( + } + description={t("aiProvidersBannerDescription")} + /> + ); +}; + +export default AiProvidersBanner; diff --git a/src/components/VirtualApiKeysBanner.tsx b/src/components/VirtualApiKeysBanner.tsx new file mode 100644 index 000000000..dd2aa1186 --- /dev/null +++ b/src/components/VirtualApiKeysBanner.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { ArrowRight, KeyRound } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import DismissableBanner from "./DismissableBanner"; + +type VirtualApiKeysBannerProps = { + orgId: string; +}; + +export const VirtualApiKeysBanner = ({ orgId }: VirtualApiKeysBannerProps) => { + const t = useTranslations(); + const { env } = useEnvContext(); + + const dashboardUrl = env.app.dashboardUrl?.replace(/\/$/, "") ?? ""; + const keysUrl = dashboardUrl + ? `${dashboardUrl}/${orgId}/keys` + : `/${orgId}/keys`; + + return ( + } + description={t("virtualApiKeysBannerDescription", { keysUrl })} + > + + + + + ); +}; + +export default VirtualApiKeysBanner; From ea740e12d18eaace1d70baff7ff20017aa391eca Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 14 Aug 2026 19:07:13 +0200 Subject: [PATCH 369/494] =?UTF-8?q?=F0=9F=92=84=20nice=20little=20animatio?= =?UTF-8?q?n=20on=20alert=20rule=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/alert-rule-editor/AlertRuleFields.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 147369a9e..155138866 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -60,6 +60,7 @@ import { useFormContext, useWatch } from "react-hook-form"; import { useDebounce } from "use-debounce"; import { RolesSelector } from "../roles-selector"; import { UsersSelector } from "../users-selector"; +import { cn } from "@app/lib/cn"; export function AddActionPanel({ onAdd @@ -149,7 +150,12 @@ export function AddActionPanel({ From 173d4a536fc71a7bcb6e59d12efa2719721fb827 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 14 Aug 2026 13:45:52 -0400 Subject: [PATCH 370/494] visual tweaks --- messages/en-US.json | 2 +- src/app/navigation.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index b58ebfd0c..e41d06f7b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1690,7 +1690,7 @@ "virtualApiKeysName": "Name", "virtualApiKeysDescriptionOptional": "Description (optional)", "virtualApiKeysAssociateUserOptional": "Associate User (optional)", - "virtualApiKeysAssociateUserDescription": "Associate this key with a user to track usage.", + "virtualApiKeysAssociateUserDescription": "Associate this key with a user to track usage. Once created, this key immediately grants access to the selected public AI gateways. You do not need to associate a user to manually to the resource. The key will also show up in the user's profile.", "virtualApiKeysAllResources": "All public AI gateways", "virtualApiKeysAllResourcesDescription": "Allow this key to access every public AI gateway in the organization", "virtualApiKeysSelectResources": "Public AI Gateways", diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 2f00744c0..724da9cc2 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -20,6 +20,8 @@ import { LayoutGrid, Link as LinkIcon, Logs, + MessageSquare, + MessagesSquare, MonitorUp, Plug, ReceiptText, @@ -233,7 +235,7 @@ export const orgNavSections = ( { title: "sidebarLogsAi", href: "/{orgId}/settings/logs/ai", - icon: + icon: }, { title: "sidebarLogsAiUsage", From 6d35b2088065ad19075069fc97fbad54c26356b1 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 14:14:56 -0400 Subject: [PATCH 371/494] Fix overlap check --- server/routers/resource/createResource.ts | 4 ++-- server/routers/resource/updateResource.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 897d32f6a..c0c7a7d5d 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -496,8 +496,8 @@ async function createHttpResource( and( eq(resources.fullDomain, fullDomain), effectiveMode === "inference" - ? eq(resources.mode, "inference") - : ne(resources.mode, "inference") + ? ne(resources.mode, "inference") + : eq(resources.mode, "inference") ) ); diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 3ed26c9cb..46e547b0b 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -609,8 +609,8 @@ async function updateHttpResource( and( eq(resources.fullDomain, fullDomain), resource.mode === "inference" - ? eq(resources.mode, "inference") - : ne(resources.mode, "inference") + ? ne(resources.mode, "inference") + : eq(resources.mode, "inference") ) ); From 3a18a3994f5e52e86ebebbe52bbbe11a3ef66da4 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 14:21:00 -0400 Subject: [PATCH 372/494] Fix private resource ssl when applying through blueprints --- server/lib/blueprints/privateResources.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 40d058634..9cec7b487 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -235,6 +235,13 @@ export async function updatePrivateResources( ? false : resourceData.enabled; + const resourceSsl = + resourceData.mode === "inference" || resourceData.mode === "http" + ? resourceData.ssl == undefined || resourceData.ssl == null + ? true + : resourceData.ssl + : resourceData.ssl; + if (existingResource) { let domainInfo: | { subdomain: string | null; domainId: string } @@ -286,7 +293,7 @@ export async function updatePrivateResources( .set({ name: resourceData.name || resourceNiceId, mode: resourceData.mode, - ssl: resourceData.ssl, + ssl: resourceSsl, scheme: resourceData.scheme, destination: resourceData.destination, destinationPort: resourceData["destination-port"], @@ -614,7 +621,7 @@ export async function updatePrivateResources( defaultNetworkId: network ? network.networkId : null, name: resourceData.name || resourceNiceId, mode: resourceData.mode, - ssl: resourceData.ssl, + ssl: resourceSsl, scheme: resourceData.scheme, destination: resourceData.destination, destinationPort: resourceData["destination-port"], From 32d76ee2accaf8e2e3b959fa2cf671f2fc0ed3cb Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 14:27:14 -0400 Subject: [PATCH 373/494] Resolve cert not generated without a provider --- .../private/lib/traefik/getTraefikConfig.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index cc841fc1e..bea78fa3e 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -41,10 +41,7 @@ import { siteNetworks, siteResources, Target, - targets, - aiProviders, - resourceAiProviders, - siteResourceAiProviders + targets } from "@server/db"; import { sanitize, @@ -438,21 +435,9 @@ export async function getTraefikConfig( preferWildcardCert: domains.preferWildcardCert }) .from(resources) - .innerJoin( - resourceAiProviders, - eq(resources.resourceId, resourceAiProviders.resourceId) - ) - .innerJoin( - aiProviders, - eq(resourceAiProviders.providerId, aiProviders.providerId) - ) .leftJoin(domains, eq(domains.domainId, resources.domainId)) .where( - and( - eq(resources.mode, "inference"), - eq(resources.enabled, true), - eq(aiProviders.enabled, true) - ) + and(eq(resources.mode, "inference"), eq(resources.enabled, true)) ); const siteResourcesInference = await db From 111d4b1b8c510842c77f42670d17edea9a652ea7 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 14:33:59 -0400 Subject: [PATCH 374/494] Make sure certs are created when creating and updating private resources --- server/routers/siteResource/createSiteResource.ts | 2 +- server/routers/siteResource/updateSiteResource.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 5887aae52..f3794d35a 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -769,7 +769,7 @@ export async function createSiteResource( if ( ssl && - mode === "http" && + (mode === "http" || mode == "inference") && domainId && fullDomain && build != "oss" diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index e0740e8c7..2e57b13a7 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -30,6 +30,8 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { clearSiteResourceAiConfig } from "@server/lib/aiInferenceResource"; +import { build } from "@server/build"; +import { createCertificate } from "../certificates/createCertificate"; const updateSiteResourceParamsSchema = z.strictObject({ siteResourceId: z.coerce.number().int().positive() @@ -735,6 +737,16 @@ export async function updateSiteResource( const finalUpdatedSiteResource = updatedSiteResource; + if ( + ssl && + (mode === "http" || mode == "inference") && + domainId && + fullDomain && + build != "oss" + ) { + await createCertificate(domainId, fullDomain, db); + } + rebuildClientAssociationsFromSiteResource(finalUpdatedSiteResource) .then(() => waitForSiteResourceRebuildIdle( From a3dfb30a428bf8d6825ddb7b4747d8d3490bc66d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 14 Aug 2026 16:51:10 -0400 Subject: [PATCH 375/494] send email upon generate virtual api key --- messages/en-US.json | 12 ++ package.json | 4 +- .../templates/VirtualApiKeyGenerated.tsx | 118 +++++++++++++ server/lib/sendVirtualApiKeyEmail.ts | 161 ++++++++++++++++++ .../virtualApiKey/createVirtualApiKey.ts | 41 ++++- .../virtualApiKey/updateVirtualApiKey.ts | 38 +++++ server/routers/virtualApiKey/validation.ts | 75 ++++++-- src/components/CreateVirtualApiKeyForm.tsx | 55 +++++- src/components/EditVirtualApiKeyForm.tsx | 51 +++++- src/components/VirtualApiKeyEmailSection.tsx | 151 ++++++++++++++++ 10 files changed, 689 insertions(+), 17 deletions(-) create mode 100644 server/emails/templates/VirtualApiKeyGenerated.tsx create mode 100644 server/lib/sendVirtualApiKeyEmail.ts create mode 100644 src/components/VirtualApiKeyEmailSection.tsx diff --git a/messages/en-US.json b/messages/en-US.json index e41d06f7b..f918a51c3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1725,6 +1725,18 @@ "virtualApiKeysFilterUnassigned": "Unassigned", "virtualApiKeysInferenceBudget": "Budget", "virtualApiKeysInferenceBudgetDescription": "Configure how this key restricts AI usage based on spending or token limits", + "virtualApiKeysEmailOnGenerate": "Email key upon generation", + "virtualApiKeysEmailThisKey": "Email this key", + "virtualApiKeysEmailOnGenerateDescription": "Send the key to the associated user and additional addresses after it is created", + "virtualApiKeysEmailThisKeyDescription": "Send the current key to the associated user and additional addresses", + "virtualApiKeysEmailSmtpRequired": "Email is not configured on this server", + "virtualApiKeysEmailSmtpRequiredDescription": "Configure SMTP to email virtual API keys.", + "virtualApiKeysEmailSendToUser": "Send to associated user", + "virtualApiKeysEmailSendToUserDescription": "Email the key to the associated user's account email", + "virtualApiKeysEmailSendToUserDisabled": "Associate a user to send the key to that user", + "virtualApiKeysEmailAdditional": "Additional emails", + "virtualApiKeysEmailAdditionalPlaceholder": "Add email and press Enter", + "virtualApiKeysEmailRecipientsRequired": "Select the associated user or add at least one email address", "myVirtualApiKeysTitle": "Your API Keys", "myVirtualApiKeysDescription": "View your identity key and any virtual API keys attributed to you in this organization", "myVirtualApiKeysResourceTitle": "Your API Keys for {resourceName}", diff --git a/package.json b/package.json index e94b2fda3..32c22d4fa 100644 --- a/package.json +++ b/package.json @@ -166,7 +166,7 @@ "@types/yargs": "17.0.35", "babel-plugin-react-compiler": "1.0.0", "drizzle-kit": "0.31.10", - "esbuild": "0.28.1", + "esbuild": "0.28.0", "esbuild-node-externals": "1.22.0", "eslint": "10.4.0", "eslint-config-next": "16.2.6", @@ -180,7 +180,7 @@ "typescript-eslint": "8.60.0" }, "overrides": { - "esbuild": "0.28.1", + "esbuild": "0.28.0", "dompurify": "3.4.0", "postcss": "8.5.15" } diff --git a/server/emails/templates/VirtualApiKeyGenerated.tsx b/server/emails/templates/VirtualApiKeyGenerated.tsx new file mode 100644 index 000000000..f156c1b6e --- /dev/null +++ b/server/emails/templates/VirtualApiKeyGenerated.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailInfoSection, + EmailLetterHead, + EmailSection, + EmailSignature, + EmailText +} from "./components/Email"; + +type VirtualApiKeyGeneratedProps = { + orgName: string; + keyName: string | null; + credential: string; + resourceUrls: string[]; + hasMoreResources: boolean; +}; + +export const VirtualApiKeyGenerated = ({ + orgName, + keyName, + credential, + resourceUrls, + hasMoreResources +}: VirtualApiKeyGeneratedProps) => { + const previewText = `A virtual API key for ${orgName} has been shared with you`; + + return ( + + + {previewText} + + + + + + Hi there, + + + A virtual API key for {orgName} has + been shared with you. Treat this key like a password + and do not share it. + + + + Your virtual API key: +
+
+ + {credential} + +
+
+
+ + + + {resourceUrls.length > 0 && ( + <> + + This key can be used to authenticate to the + following AI gateway resources: + +
+ {resourceUrls.map((url) => ( +

+ + {url} + +

+ ))} +
+ {hasMoreResources && ( + + Contact your administrator to get the + full list. + + )} + + )} + + + + +
+ +
+ + ); +}; + +export default VirtualApiKeyGenerated; diff --git a/server/lib/sendVirtualApiKeyEmail.ts b/server/lib/sendVirtualApiKeyEmail.ts new file mode 100644 index 000000000..48d7d0d6b --- /dev/null +++ b/server/lib/sendVirtualApiKeyEmail.ts @@ -0,0 +1,161 @@ +import { db, resources, users, virtualApiKeyResources } from "@server/db"; +import { and, asc, eq } from "drizzle-orm"; +import config from "@server/lib/config"; +import { sendEmail } from "@server/emails"; +import VirtualApiKeyGenerated from "@server/emails/templates/VirtualApiKeyGenerated"; +import { formatVirtualApiKeyCredential } from "@server/lib/virtualApiKey"; + +const EMAIL_GATEWAY_URL_LIMIT = 5; + +async function listVirtualApiKeyGatewayUrls(params: { + orgId: string; + allResources: boolean; + virtualApiKeyId: string; +}): Promise<{ urls: string[]; hasMore: boolean }> { + const rows = params.allResources + ? await db + .select({ + fullDomain: resources.fullDomain, + ssl: resources.ssl + }) + .from(resources) + .where( + and( + eq(resources.orgId, params.orgId), + eq(resources.mode, "inference") + ) + ) + .orderBy(asc(resources.name)) + .limit(EMAIL_GATEWAY_URL_LIMIT + 1) + : await db + .select({ + fullDomain: resources.fullDomain, + ssl: resources.ssl + }) + .from(virtualApiKeyResources) + .innerJoin( + resources, + eq(virtualApiKeyResources.resourceId, resources.resourceId) + ) + .where( + eq( + virtualApiKeyResources.virtualApiKeyId, + params.virtualApiKeyId + ) + ) + .orderBy(asc(resources.name)) + .limit(EMAIL_GATEWAY_URL_LIMIT + 1); + + const urls = rows + .map((row) => + row.fullDomain + ? `${row.ssl ? "https" : "http"}://${row.fullDomain}` + : null + ) + .filter((url): url is string => Boolean(url)); + + return { + urls: urls.slice(0, EMAIL_GATEWAY_URL_LIMIT), + hasMore: rows.length > EMAIL_GATEWAY_URL_LIMIT + }; +} + +export async function resolveVirtualApiKeyEmailRecipients(params: { + sendEmail: boolean; + sendToAttributedUser: boolean; + userId: string | null | undefined; + emails: string[]; +}): Promise< + { ok: true; recipients: string[] } | { ok: false; message: string } +> { + if (!params.sendEmail) { + return { ok: true, recipients: [] }; + } + + if (!config.getRawConfig().email) { + return { + ok: false, + message: "Email is not configured on this server" + }; + } + + const recipients = new Set( + params.emails.map((email) => email.trim().toLowerCase()).filter(Boolean) + ); + + if (params.sendToAttributedUser) { + if (!params.userId) { + return { + ok: false, + message: "Associate a user to email the key to that user" + }; + } + + const [user] = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.userId, params.userId)) + .limit(1); + + if (!user?.email) { + return { + ok: false, + message: "The associated user does not have an email address" + }; + } + + recipients.add(user.email.toLowerCase()); + } + + if (recipients.size === 0) { + return { + ok: false, + message: "Select at least one email recipient" + }; + } + + return { ok: true, recipients: [...recipients] }; +} + +export async function sendVirtualApiKeyEmails(params: { + recipients: string[]; + orgName: string; + orgId: string; + keyName: string | null; + virtualApiKeyId: string; + secret: string; + allResources: boolean; +}): Promise { + if (params.recipients.length === 0) { + return; + } + + const credential = formatVirtualApiKeyCredential( + params.virtualApiKeyId, + params.secret + ); + const { urls, hasMore } = await listVirtualApiKeyGatewayUrls({ + orgId: params.orgId, + allResources: params.allResources, + virtualApiKeyId: params.virtualApiKeyId + }); + const from = config.getNoReplyEmail(); + const subject = `Virtual API key for ${params.orgName}`; + + for (const to of params.recipients) { + await sendEmail( + VirtualApiKeyGenerated({ + orgName: params.orgName, + keyName: params.keyName, + credential, + resourceUrls: urls, + hasMoreResources: hasMore + }), + { + to, + from, + subject + } + ); + } +} diff --git a/server/routers/virtualApiKey/createVirtualApiKey.ts b/server/routers/virtualApiKey/createVirtualApiKey.ts index 5e49a5717..068ce13cc 100644 --- a/server/routers/virtualApiKey/createVirtualApiKey.ts +++ b/server/routers/virtualApiKey/createVirtualApiKey.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, userOrgs, virtualApiKeys } from "@server/db"; +import { db, orgs, userOrgs, virtualApiKeys } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -18,6 +18,10 @@ import { } from "@server/lib/virtualApiKey"; import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; import { createVirtualApiKeyBodySchema } from "@server/routers/virtualApiKey/validation"; +import { + resolveVirtualApiKeyEmailRecipients, + sendVirtualApiKeyEmails +} from "@server/lib/sendVirtualApiKeyEmail"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -78,7 +82,10 @@ export async function createVirtualApiKey( userId, allResources, resourceIds, - validForSeconds + validForSeconds, + sendEmail: doEmail, + sendToAttributedUser, + emails } = parsedBody.data; if (req.user && orgId && orgId !== req.userOrgId) { @@ -121,6 +128,18 @@ export async function createVirtualApiKey( ); } + const emailRecipients = await resolveVirtualApiKeyEmailRecipients({ + sendEmail: doEmail, + sendToAttributedUser, + userId, + emails + }); + if (!emailRecipients.ok) { + return next( + createHttpError(HttpCode.BAD_REQUEST, emailRecipients.message) + ); + } + const minted = mintVirtualApiKeySecret(); const expiresAt = validForSeconds ? createDate(new TimeSpan(validForSeconds, "s")).getTime() @@ -156,6 +175,24 @@ export async function createVirtualApiKey( return row; }); + if (emailRecipients.recipients.length > 0) { + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + await sendVirtualApiKeyEmails({ + recipients: emailRecipients.recipients, + orgName: org?.name || orgId, + orgId, + keyName: created.name, + virtualApiKeyId: created.virtualApiKeyId, + secret: minted.secret, + allResources: created.allResources + }); + } + return response(res, { data: { virtualApiKey: { diff --git a/server/routers/virtualApiKey/updateVirtualApiKey.ts b/server/routers/virtualApiKey/updateVirtualApiKey.ts index 42554803f..8307d4fd3 100644 --- a/server/routers/virtualApiKey/updateVirtualApiKey.ts +++ b/server/routers/virtualApiKey/updateVirtualApiKey.ts @@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, + orgs, userOrgs, virtualApiKeyResources, virtualApiKeys @@ -16,11 +17,16 @@ import { and, eq } from "drizzle-orm"; import { createDate, TimeSpan } from "oslo"; import { assertManualKeyResourcesInOrg, + decryptVirtualApiKeyToken, replaceVirtualApiKeyResources, toPublicVirtualApiKey } from "@server/lib/virtualApiKey"; import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; import { updateVirtualApiKeyBodySchema } from "@server/routers/virtualApiKey/validation"; +import { + resolveVirtualApiKeyEmailRecipients, + sendVirtualApiKeyEmails +} from "@server/lib/sendVirtualApiKeyEmail"; const paramsSchema = z.strictObject({ virtualApiKeyId: z.string().nonempty() @@ -146,6 +152,20 @@ export async function updateVirtualApiKey( } } + const nextUserId = + body.userId !== undefined ? body.userId : existing.userId; + const emailRecipients = await resolveVirtualApiKeyEmailRecipients({ + sendEmail: body.sendEmail, + sendToAttributedUser: body.sendToAttributedUser, + userId: nextUserId, + emails: body.emails + }); + if (!emailRecipients.ok) { + return next( + createHttpError(HttpCode.BAD_REQUEST, emailRecipients.message) + ); + } + const updates: Partial = {}; if (body.name !== undefined) { @@ -192,6 +212,24 @@ export async function updateVirtualApiKey( return row; }); + if (emailRecipients.recipients.length > 0) { + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, existing.orgId)) + .limit(1); + + await sendVirtualApiKeyEmails({ + recipients: emailRecipients.recipients, + orgName: org?.name || existing.orgId, + orgId: existing.orgId, + keyName: updated.name, + virtualApiKeyId: updated.virtualApiKeyId, + secret: decryptVirtualApiKeyToken(updated.token), + allResources: updated.allResources + }); + } + const resourceRows = await db .select({ resourceId: virtualApiKeyResources.resourceId }) .from(virtualApiKeyResources) diff --git a/server/routers/virtualApiKey/validation.ts b/server/routers/virtualApiKey/validation.ts index 42964b3ba..5e8d22e64 100644 --- a/server/routers/virtualApiKey/validation.ts +++ b/server/routers/virtualApiKey/validation.ts @@ -4,6 +4,43 @@ export const virtualApiKeyResourceIdsSchema = z .array(z.coerce.number().int().positive()) .optional(); +const virtualApiKeyEmailFieldsSchema = { + sendEmail: z.boolean().optional().default(false), + sendToAttributedUser: z.boolean().optional().default(false), + emails: z.array(z.email().toLowerCase()).max(20).optional().default([]) +}; + +function refineVirtualApiKeyEmailFields( + data: { + sendEmail: boolean; + sendToAttributedUser: boolean; + emails: string[]; + userId?: string | null; + }, + ctx: z.RefinementCtx +) { + if (!data.sendEmail) { + return; + } + + if (!data.sendToAttributedUser && data.emails.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Select the associated user or add at least one email address", + path: ["sendEmail"] + }); + } + + if (data.sendToAttributedUser && !data.userId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Associate a user to email the key to that user", + path: ["sendToAttributedUser"] + }); + } +} + export const createVirtualApiKeyBodySchema = z .strictObject({ name: z.string().nonempty(), @@ -11,7 +48,8 @@ export const createVirtualApiKeyBodySchema = z userId: z.string().optional().nullable(), allResources: z.boolean().optional().default(false), resourceIds: virtualApiKeyResourceIdsSchema, - validForSeconds: z.int().positive().optional() + validForSeconds: z.int().positive().optional(), + ...virtualApiKeyEmailFieldsSchema }) .refine( (data) => data.allResources || (data.resourceIds?.length ?? 0) > 0, @@ -20,13 +58,30 @@ export const createVirtualApiKeyBodySchema = z "Select at least one public inference resource, or enable all public inference resources", path: ["resourceIds"] } - ); + ) + .superRefine(refineVirtualApiKeyEmailFields); -export const updateVirtualApiKeyBodySchema = z.strictObject({ - name: z.string().nonempty().optional(), - description: z.string().optional().nullable(), - userId: z.string().optional().nullable(), - allResources: z.boolean().optional(), - resourceIds: virtualApiKeyResourceIdsSchema, - validForSeconds: z.int().positive().optional().nullable() -}); +export const updateVirtualApiKeyBodySchema = z + .strictObject({ + name: z.string().nonempty().optional(), + description: z.string().optional().nullable(), + userId: z.string().optional().nullable(), + allResources: z.boolean().optional(), + resourceIds: virtualApiKeyResourceIdsSchema, + validForSeconds: z.int().positive().optional().nullable(), + ...virtualApiKeyEmailFieldsSchema + }) + .superRefine((data, ctx) => { + if (!data.sendEmail) { + return; + } + + if (!data.sendToAttributedUser && data.emails.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Select the associated user or add at least one email address", + path: ["sendEmail"] + }); + } + }); diff --git a/src/components/CreateVirtualApiKeyForm.tsx b/src/components/CreateVirtualApiKeyForm.tsx index 5c2e75d0f..14bca22f5 100644 --- a/src/components/CreateVirtualApiKeyForm.tsx +++ b/src/components/CreateVirtualApiKeyForm.tsx @@ -54,6 +54,8 @@ import { getBudgetRowsErrors, type BudgetRow } from "@app/components/BudgetsEditor"; +import VirtualApiKeyEmailSection from "@app/components/VirtualApiKeyEmailSection"; +import type { Tag } from "@app/components/tags/tag-input"; export type CreatedVirtualApiKey = { virtualApiKeyId: string; @@ -101,6 +103,9 @@ export default function CreateVirtualApiKeyForm({ >([]); const [pendingBudgetRows, setPendingBudgetRows] = useState([]); const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false); + const [sendEmail, setSendEmail] = useState(false); + const [sendToAttributedUser, setSendToAttributedUser] = useState(false); + const [emailTags, setEmailTags] = useState([]); const formSchema = z.object({ name: z.string().min(1), @@ -123,6 +128,9 @@ export default function CreateVirtualApiKeyForm({ setSelectedResources([]); setPendingBudgetRows([]); setAttemptedBudgetsSave(false); + setSendEmail(false); + setSendToAttributedUser(false); + setEmailTags([]); form.reset(); } @@ -141,6 +149,20 @@ export default function CreateVirtualApiKeyForm({ return; } + if ( + env.email.emailEnabled && + sendEmail && + !sendToAttributedUser && + emailTags.length === 0 + ) { + toast({ + variant: "destructive", + title: t("virtualApiKeysEmailRecipientsRequired"), + description: t("virtualApiKeysEmailRecipientsRequired") + }); + return; + } + return onSubmit(values); } @@ -157,7 +179,16 @@ export default function CreateVirtualApiKeyForm({ allResources, resourceIds: allResources ? [] - : selectedResources.map((r) => r.resourceId) + : selectedResources.map((r) => r.resourceId), + sendEmail: env.email.emailEnabled && sendEmail, + sendToAttributedUser: + env.email.emailEnabled && + sendEmail && + sendToAttributedUser, + emails: + env.email.emailEnabled && sendEmail + ? emailTags.map((tag) => tag.text) + : [] } ) .catch((e) => { @@ -473,6 +504,26 @@ export default function CreateVirtualApiKeyForm({
)}
+ +
@@ -505,7 +556,7 @@ export default function CreateVirtualApiKeyForm({
)}
+ +
diff --git a/src/components/VirtualApiKeyEmailSection.tsx b/src/components/VirtualApiKeyEmailSection.tsx new file mode 100644 index 000000000..a00abd69e --- /dev/null +++ b/src/components/VirtualApiKeyEmailSection.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { FormLabel } from "@app/components/ui/form"; +import { TagInput, type Tag } from "@app/components/tags/tag-input"; +import { useTranslations } from "next-intl"; + +type VirtualApiKeyEmailSectionProps = { + emailEnabled: boolean; + mode: "create" | "edit"; + sendEmail: boolean; + onSendEmailChange: (value: boolean) => void; + sendToAttributedUser: boolean; + onSendToAttributedUserChange: (value: boolean) => void; + hasAssociatedUser: boolean; + emailTags: Tag[]; + onEmailTagsChange: (tags: Tag[]) => void; +}; + +export default function VirtualApiKeyEmailSection({ + emailEnabled, + mode, + sendEmail, + onSendEmailChange, + sendToAttributedUser, + onSendToAttributedUserChange, + hasAssociatedUser, + emailTags, + onEmailTagsChange +}: VirtualApiKeyEmailSectionProps) { + const t = useTranslations(); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + useEffect(() => { + if (!hasAssociatedUser && sendToAttributedUser) { + onSendToAttributedUserChange(false); + } + }, [hasAssociatedUser, sendToAttributedUser, onSendToAttributedUserChange]); + + const checkboxId = + mode === "create" + ? "virtual-api-key-send-email" + : "edit-virtual-api-key-send-email"; + const sendToUserId = + mode === "create" + ? "virtual-api-key-send-to-user" + : "edit-virtual-api-key-send-to-user"; + + return ( +
+
+ { + if (emailEnabled) { + onSendEmailChange(val === true); + } + }} + className="mt-0.5" + /> +
+ +

+ {emailEnabled + ? t( + mode === "create" + ? "virtualApiKeysEmailOnGenerateDescription" + : "virtualApiKeysEmailThisKeyDescription" + ) + : t("virtualApiKeysEmailSmtpRequiredDescription")} +

+
+
+ + {emailEnabled && sendEmail && ( +
+
+ + onSendToAttributedUserChange(val === true) + } + className="mt-0.5" + /> +
+ +

+ {hasAssociatedUser + ? t( + "virtualApiKeysEmailSendToUserDescription" + ) + : t( + "virtualApiKeysEmailSendToUserDisabled" + )} +

+
+
+ +
+ + {t("virtualApiKeysEmailAdditional")} + + { + const next = + typeof newTags === "function" + ? newTags(emailTags) + : newTags; + onEmailTagsChange(next as Tag[]); + }} + allowDuplicates={false} + sortTags + validateTag={(tag) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag) + } + delimiterList={[",", "Enter"]} + /> +
+
+ )} +
+ ); +} From 53b1d8a9f34b595508716ad533618150d085ba54 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 14 Aug 2026 17:37:17 -0400 Subject: [PATCH 376/494] option to send identity keys in email --- messages/en-US.json | 23 ++ .../templates/IdentityApiKeyGenerated.tsx | 78 ++++++ .../templates/VirtualApiKeyGenerated.tsx | 5 +- server/emails/templates/components/Email.tsx | 2 +- server/lib/sendVirtualApiKeyEmail.ts | 53 +++- server/routers/external.ts | 9 + server/routers/integration.ts | 9 + .../virtualApiKey/emailIdentityKeys.ts | 264 ++++++++++++++++++ server/routers/virtualApiKey/index.ts | 1 + server/routers/virtualApiKey/types.ts | 5 + .../virtual-api-keys/(list)/identity/page.tsx | 16 ++ .../virtual-api-keys/(list)/keys/page.tsx | 142 ++++++++++ .../virtual-api-keys/(list)/layout.tsx | 37 +++ .../settings/virtual-api-keys/page.tsx | 149 +--------- src/components/EmailIdentityKeysForm.tsx | 192 +++++++++++++ src/components/IdentityKeysSplash.tsx | 117 ++++++++ src/components/VirtualApiKeysBanner.tsx | 45 --- 17 files changed, 942 insertions(+), 205 deletions(-) create mode 100644 server/emails/templates/IdentityApiKeyGenerated.tsx create mode 100644 server/routers/virtualApiKey/emailIdentityKeys.ts create mode 100644 src/app/[orgId]/settings/virtual-api-keys/(list)/identity/page.tsx create mode 100644 src/app/[orgId]/settings/virtual-api-keys/(list)/keys/page.tsx create mode 100644 src/app/[orgId]/settings/virtual-api-keys/(list)/layout.tsx create mode 100644 src/components/EmailIdentityKeysForm.tsx create mode 100644 src/components/IdentityKeysSplash.tsx delete mode 100644 src/components/VirtualApiKeysBanner.tsx diff --git a/messages/en-US.json b/messages/en-US.json index f918a51c3..ea227ebe2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1678,6 +1678,29 @@ "commandVirtualApiKeys": "Virtual API Keys", "virtualApiKeysTitle": "Manage Virtual API Keys", "virtualApiKeysDescription": "Create and manage manual API keys for AI Gateway access to public AI gateways", + "virtualApiKeysTabIdentity": "Identity Keys", + "virtualApiKeysTabVirtual": "Virtual Keys", + "virtualApiKeysIdentitySplashTitle": "Identity Keys for Every User", + "virtualApiKeysIdentitySplashDescription": "Every user already has a Pangolin identity key for this organization. It is unique to their account and authenticates them to AI gateways they can access.", + "virtualApiKeysIdentitySplashExample": "Example", + "virtualApiKeysIdentitySplashRetrieveTitle": "How Users Get Their Key", + "virtualApiKeysIdentitySplashRetrieveResource": "Visit a public AI gateway URL in the browser and log in with their account.", + "virtualApiKeysIdentitySplashRetrievePage": "Or go to while signed in.", + "virtualApiKeysIdentitySplashManual": "You can create additional virtual API keys on the Virtual Keys tab. Those keys can be scoped to specific public AI gateways and optionally associated with a user. Creating a key immediately grants access to the selected public AI gateways.", + "virtualApiKeysIdentitySplashGoToVirtual": "Manually Create a Key", + "virtualApiKeysEmailIdentity": "Email Identity Keys", + "virtualApiKeysEmailIdentityDescription": "Send each selected user or role their Pangolin identity key.", + "virtualApiKeysEmailIdentitySendAll": "Send to all users", + "virtualApiKeysEmailIdentitySendAllDescription": "Email every organization member who has an account email.", + "virtualApiKeysEmailIdentitySelectUsers": "Users", + "virtualApiKeysEmailIdentitySelectRoles": "Roles", + "virtualApiKeysEmailIdentitySubmit": "Send Emails", + "virtualApiKeysEmailIdentitySuccess": "Identity keys emailed", + "virtualApiKeysEmailIdentitySuccessDescription": "Sent {sent} emails.", + "virtualApiKeysEmailIdentitySkipped": "{skipped} users were skipped because they do not have an email address.", + "virtualApiKeysEmailIdentityRecipientsRequired": "Select at least one user or role, or send to all users.", + "virtualApiKeysEmailIdentityError": "Error sending identity keys", + "virtualApiKeysEmailIdentityErrorDescription": "Failed to email identity keys", "virtualApiKeysBannerTitle": "Identity Keys for Every User", "virtualApiKeysBannerDescription": "Every user already has an identity key available at {keysUrl}. You can also manually generate keys here that grant direct access to public AI gateways.", "virtualApiKeysBannerButtonText": "View Identity Keys", diff --git a/server/emails/templates/IdentityApiKeyGenerated.tsx b/server/emails/templates/IdentityApiKeyGenerated.tsx new file mode 100644 index 000000000..be49bb8ff --- /dev/null +++ b/server/emails/templates/IdentityApiKeyGenerated.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailInfoSection, + EmailLetterHead, + EmailSection, + EmailSignature, + EmailText +} from "./components/Email"; + +type IdentityApiKeyGeneratedProps = { + orgName: string; + accountLabel?: string | null; + credential: string; + resourceUrls: string[]; + hasMoreResources: boolean; +}; + +export const IdentityApiKeyGenerated = ({ + orgName, + accountLabel, + credential, + resourceUrls, + hasMoreResources +}: IdentityApiKeyGeneratedProps) => { + const previewText = `Your personal identity key for ${orgName}`; + + return ( + + + {previewText} + + + + + + Hi there, + + + This is your personal identity key for{" "} + {orgName}. It belongs to your + account and identifies you when you use public AI + gateways. + + + + Use it with resources your administrator has granted + you, or that your role has access to. Treat this key + like a password and do not share it. + + + + Your identity key: +
+
+ + {credential} + +
+
+
+ + + + +
+ +
+ + ); +}; + +export default IdentityApiKeyGenerated; diff --git a/server/emails/templates/VirtualApiKeyGenerated.tsx b/server/emails/templates/VirtualApiKeyGenerated.tsx index f156c1b6e..df7b700a8 100644 --- a/server/emails/templates/VirtualApiKeyGenerated.tsx +++ b/server/emails/templates/VirtualApiKeyGenerated.tsx @@ -42,8 +42,9 @@ export const VirtualApiKeyGenerated = ({ A virtual API key for {orgName} has - been shared with you. Treat this key like a password - and do not share it. + been shared with you. This key grants access to the + public AI gateways it was created for. Treat this + key like a password and do not share it. diff --git a/server/emails/templates/components/Email.tsx b/server/emails/templates/components/Email.tsx index f74046042..61e1b7116 100644 --- a/server/emails/templates/components/Email.tsx +++ b/server/emails/templates/components/Email.tsx @@ -18,7 +18,7 @@ export function EmailLetterHead() { Pangolin Logo diff --git a/server/lib/sendVirtualApiKeyEmail.ts b/server/lib/sendVirtualApiKeyEmail.ts index 48d7d0d6b..4eecd180f 100644 --- a/server/lib/sendVirtualApiKeyEmail.ts +++ b/server/lib/sendVirtualApiKeyEmail.ts @@ -2,6 +2,7 @@ import { db, resources, users, virtualApiKeyResources } from "@server/db"; import { and, asc, eq } from "drizzle-orm"; import config from "@server/lib/config"; import { sendEmail } from "@server/emails"; +import IdentityApiKeyGenerated from "@server/emails/templates/IdentityApiKeyGenerated"; import VirtualApiKeyGenerated from "@server/emails/templates/VirtualApiKeyGenerated"; import { formatVirtualApiKeyCredential } from "@server/lib/virtualApiKey"; @@ -60,6 +61,17 @@ async function listVirtualApiKeyGatewayUrls(params: { }; } +export async function listOrgInferenceGatewayUrls(orgId: string): Promise<{ + urls: string[]; + hasMore: boolean; +}> { + return listVirtualApiKeyGatewayUrls({ + orgId, + allResources: true, + virtualApiKeyId: "" + }); +} + export async function resolveVirtualApiKeyEmailRecipients(params: { sendEmail: boolean; sendToAttributedUser: boolean; @@ -125,6 +137,9 @@ export async function sendVirtualApiKeyEmails(params: { virtualApiKeyId: string; secret: string; allResources: boolean; + isIdentityKey?: boolean; + accountLabel?: string | null; + gatewayUrls?: { urls: string[]; hasMore: boolean }; }): Promise { if (params.recipients.length === 0) { return; @@ -134,23 +149,35 @@ export async function sendVirtualApiKeyEmails(params: { params.virtualApiKeyId, params.secret ); - const { urls, hasMore } = await listVirtualApiKeyGatewayUrls({ - orgId: params.orgId, - allResources: params.allResources, - virtualApiKeyId: params.virtualApiKeyId - }); + const { urls, hasMore } = + params.gatewayUrls ?? + (await listVirtualApiKeyGatewayUrls({ + orgId: params.orgId, + allResources: params.allResources, + virtualApiKeyId: params.virtualApiKeyId + })); const from = config.getNoReplyEmail(); - const subject = `Virtual API key for ${params.orgName}`; + const subject = params.isIdentityKey + ? `Your identity key for ${params.orgName}` + : `Virtual API key for ${params.orgName}`; for (const to of params.recipients) { await sendEmail( - VirtualApiKeyGenerated({ - orgName: params.orgName, - keyName: params.keyName, - credential, - resourceUrls: urls, - hasMoreResources: hasMore - }), + params.isIdentityKey + ? IdentityApiKeyGenerated({ + orgName: params.orgName, + accountLabel: params.accountLabel, + credential, + resourceUrls: urls, + hasMoreResources: hasMore + }) + : VirtualApiKeyGenerated({ + orgName: params.orgName, + keyName: params.keyName, + credential, + resourceUrls: urls, + hasMoreResources: hasMore + }), { to, from, diff --git a/server/routers/external.ts b/server/routers/external.ts index e182d2b44..b51fbf2de 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1735,6 +1735,15 @@ authenticated.get( virtualApiKey.listVirtualApiKeys ); +authenticated.post( + "/org/:orgId/virtual-api-keys/email-identity-keys", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getVirtualApiKey), + virtualApiKey.emailIdentityKeysRateLimit, + logActionAudit(ActionsEnum.getVirtualApiKey), + virtualApiKey.emailIdentityKeys +); + authenticated.get( "/org/:orgId/my-virtual-api-keys", verifyOrgAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index f609443d4..214c1ff27 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -1769,6 +1769,15 @@ authenticated.get( virtualApiKey.listVirtualApiKeys ); +authenticated.post( + "/org/:orgId/virtual-api-keys/email-identity-keys", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getVirtualApiKey), + virtualApiKey.emailIdentityKeysRateLimit, + logActionAudit(ActionsEnum.getVirtualApiKey), + virtualApiKey.emailIdentityKeys +); + authenticated.get( "/virtual-api-key/:virtualApiKeyId", verifyApiKeyVirtualApiKeyAccess, diff --git a/server/routers/virtualApiKey/emailIdentityKeys.ts b/server/routers/virtualApiKey/emailIdentityKeys.ts new file mode 100644 index 000000000..4c4963751 --- /dev/null +++ b/server/routers/virtualApiKey/emailIdentityKeys.ts @@ -0,0 +1,264 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, orgs, roles, userOrgRoles, userOrgs, users } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq, inArray } from "drizzle-orm"; +import config from "@server/lib/config"; +import { getOrCreateUserVirtualApiKey } from "@server/lib/virtualApiKey"; +import { + sendVirtualApiKeyEmails, + listOrgInferenceGatewayUrls +} from "@server/lib/sendVirtualApiKeyEmail"; +import type { EmailIdentityKeysResponse } from "@server/routers/virtualApiKey/types"; +import rateLimit, { ipKeyGenerator } from "express-rate-limit"; +import { createStore } from "#dynamic/lib/rateLimitStore"; + +const EMAIL_IDENTITY_KEYS_WINDOW_MINUTES = 15; +const EMAIL_IDENTITY_KEYS_MAX = 3; + +export const emailIdentityKeysRateLimit = rateLimit({ + windowMs: EMAIL_IDENTITY_KEYS_WINDOW_MINUTES * 60 * 1000, + max: EMAIL_IDENTITY_KEYS_MAX, + keyGenerator: (req) => { + const actor = + req.user?.userId || + req.apiKey?.apiKeyId || + ipKeyGenerator(req.ip || ""); + const orgId = + typeof req.params.orgId === "string" ? req.params.orgId : ""; + return `emailIdentityKeys:${actor}:${orgId}`; + }, + handler: (_req, _res, next) => { + const message = `You can only email identity keys ${EMAIL_IDENTITY_KEYS_MAX} times every ${EMAIL_IDENTITY_KEYS_WINDOW_MINUTES} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() +}); + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z + .strictObject({ + sendToAll: z.boolean().optional().default(false), + userIds: z.array(z.string().nonempty()).optional().default([]), + roleIds: z.array(z.number().int().positive()).optional().default([]) + }) + .superRefine((data, ctx) => { + if ( + !data.sendToAll && + data.userIds.length === 0 && + data.roleIds.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Select at least one user or role, or send to all users", + path: ["userIds"] + }); + } + }); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/virtual-api-keys/email-identity-keys", + description: + "Email identity virtual API keys to selected organization members and roles, or to all members.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function emailIdentityKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + if (!config.getRawConfig().email) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email is not configured on this server" + ) + ); + } + + const { orgId } = parsedParams.data; + const { sendToAll, userIds, roleIds } = parsedBody.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const uniqueUserIds = [...new Set(userIds)]; + const uniqueRoleIds = [...new Set(roleIds)]; + + if (!sendToAll && uniqueRoleIds.length > 0) { + const orgRoles = await db + .select({ roleId: roles.roleId }) + .from(roles) + .where( + and( + eq(roles.orgId, orgId), + inArray(roles.roleId, uniqueRoleIds) + ) + ); + + if (orgRoles.length !== uniqueRoleIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more roles are invalid for this organization" + ) + ); + } + } + + let targetUserIds: string[] | null = null; + if (!sendToAll) { + let roleUserIds: string[] = []; + if (uniqueRoleIds.length > 0) { + const roleMembers = await db + .select({ userId: userOrgRoles.userId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.orgId, orgId), + inArray(userOrgRoles.roleId, uniqueRoleIds) + ) + ); + roleUserIds = roleMembers.map((row) => row.userId); + } + + targetUserIds = [...new Set([...uniqueUserIds, ...roleUserIds])]; + if (targetUserIds.length === 0) { + return response(res, { + data: { sent: 0, skipped: 0 }, + success: true, + error: false, + message: "Identity keys emailed successfully", + status: HttpCode.OK + }); + } + } + + const memberConditions = [eq(userOrgs.orgId, orgId)]; + if (targetUserIds) { + memberConditions.push(inArray(users.userId, targetUserIds)); + } + + const members = await db + .select({ user: users }) + .from(users) + .innerJoin(userOrgs, eq(userOrgs.userId, users.userId)) + .where(and(...memberConditions)); + + if (!sendToAll && uniqueUserIds.length > 0) { + const foundIds = new Set(members.map((row) => row.user.userId)); + if (uniqueUserIds.some((id) => !foundIds.has(id))) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more users are not members of this organization" + ) + ); + } + } + + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + const orgName = org?.name || orgId; + const gatewayUrls = await listOrgInferenceGatewayUrls(orgId); + let sent = 0; + let skipped = 0; + + for (const { user } of members) { + if (!user.email) { + skipped += 1; + continue; + } + + const { key, secret } = await getOrCreateUserVirtualApiKey({ + orgId, + user, + createdByUserId: req.user?.userId ?? null + }); + + await sendVirtualApiKeyEmails({ + recipients: [user.email], + orgName, + orgId, + keyName: key.name, + virtualApiKeyId: key.virtualApiKeyId, + secret, + allResources: true, + isIdentityKey: true, + accountLabel: user.email || user.name || user.username, + gatewayUrls + }); + sent += 1; + } + + return response(res, { + data: { sent, skipped }, + success: true, + error: false, + message: "Identity keys emailed successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/index.ts b/server/routers/virtualApiKey/index.ts index 2cb06a5ca..0a6c16a06 100644 --- a/server/routers/virtualApiKey/index.ts +++ b/server/routers/virtualApiKey/index.ts @@ -5,4 +5,5 @@ export * from "./getVirtualApiKey"; export * from "./getMyVirtualApiKey"; export * from "./updateVirtualApiKey"; export * from "./deleteVirtualApiKey"; +export * from "./emailIdentityKeys"; export * from "./types"; diff --git a/server/routers/virtualApiKey/types.ts b/server/routers/virtualApiKey/types.ts index 91bf17c0a..e6a387446 100644 --- a/server/routers/virtualApiKey/types.ts +++ b/server/routers/virtualApiKey/types.ts @@ -28,3 +28,8 @@ export type ListMyVirtualApiKeysResponse = { export type GetMyVirtualApiKeyResponse = { virtualApiKey: VirtualApiKeyWithResources; }; + +export type EmailIdentityKeysResponse = { + sent: number; + skipped: number; +}; diff --git a/src/app/[orgId]/settings/virtual-api-keys/(list)/identity/page.tsx b/src/app/[orgId]/settings/virtual-api-keys/(list)/identity/page.tsx new file mode 100644 index 000000000..4709345ed --- /dev/null +++ b/src/app/[orgId]/settings/virtual-api-keys/(list)/identity/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import IdentityKeysSplash from "@app/components/IdentityKeysSplash"; + +export const metadata: Metadata = { + title: "Identity Keys" +}; + +type IdentityKeysPageProps = { + params: Promise<{ orgId: string }>; +}; + +export default async function IdentityKeysPage(props: IdentityKeysPageProps) { + const params = await props.params; + + return ; +} diff --git a/src/app/[orgId]/settings/virtual-api-keys/(list)/keys/page.tsx b/src/app/[orgId]/settings/virtual-api-keys/(list)/keys/page.tsx new file mode 100644 index 000000000..2a5cb0e57 --- /dev/null +++ b/src/app/[orgId]/settings/virtual-api-keys/(list)/keys/page.tsx @@ -0,0 +1,142 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { cache } from "react"; +import { GetOrgResponse } from "@server/routers/org"; +import OrgProvider from "@app/providers/OrgProvider"; +import VirtualApiKeysTable, { + type VirtualApiKeyRow +} from "@app/components/VirtualApiKeysTable"; +import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; +import type { ListVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types"; +import type { ListUsersResponse } from "@server/routers/user"; +import type { ListResourcesResponse } from "@server/routers/resource"; + +export const metadata: Metadata = { + title: "Virtual Keys" +}; + +type VirtualApiKeysTablePageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function VirtualApiKeysTablePage( + props: VirtualApiKeysTablePageProps +) { + const params = await props.params; + const cookieHeader = await authCookieHeader(); + const t = await getTranslations(); + + let keys: ListVirtualApiKeysResponse["virtualApiKeys"] = []; + let users: { + userId: string; + email: string | null; + name: string | null; + username: string | null; + }[] = []; + let resources: { + resourceId: number; + name: string; + niceId: string; + }[] = []; + + try { + const [keysRes, usersRes, resourcesRes] = await Promise.all([ + internal.get>( + `/org/${params.orgId}/virtual-api-keys?page=1&pageSize=1000`, + cookieHeader + ), + internal.get>( + `/org/${params.orgId}/users?page=1&pageSize=1000`, + cookieHeader + ), + internal.get>( + `/org/${params.orgId}/resources?page=1&pageSize=1000`, + cookieHeader + ) + ]); + + keys = keysRes.data.data.virtualApiKeys ?? []; + users = (usersRes.data.data.users ?? []).map((u) => ({ + userId: u.id, + email: u.email ?? null, + name: u.name ?? null, + username: u.username ?? null + })); + resources = (resourcesRes.data.data.resources ?? []).map((r) => ({ + resourceId: r.resourceId, + name: r.name, + niceId: r.niceId + })); + } catch { + // leave empty; page still renders + } + + let org = null; + try { + const getOrg = cache(async () => + internal.get>( + `/org/${params.orgId}`, + cookieHeader + ) + ); + const res = await getOrg(); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/resources`); + } + + if (!org) { + redirect(`/${params.orgId}/settings/resources`); + } + + const userById = new Map(users.map((u) => [u.userId, u])); + const resourceById = new Map(resources.map((r) => [r.resourceId, r])); + + const rows: VirtualApiKeyRow[] = keys.map((key) => { + const user = key.userId ? userById.get(key.userId) : undefined; + const keyResources = key.resourceIds + .map((id) => resourceById.get(id)) + .filter(Boolean) as { + resourceId: number; + name: string; + niceId: string; + }[]; + + const resourceNames = key.allResources + ? t("virtualApiKeysAllResources") + : keyResources.map((r) => r.name).join(", ") || + t("virtualApiKeysNoResources"); + + return { + virtualApiKeyId: key.virtualApiKeyId, + orgId: key.orgId, + kind: key.kind, + userId: key.userId, + name: key.name, + description: key.description, + lastChars: key.lastChars, + allResources: key.allResources, + expiresAt: key.expiresAt, + lastUsedAt: key.lastUsedAt, + createdAt: key.createdAt, + createdByUserId: key.createdByUserId, + resourceIds: key.resourceIds, + userName: user?.name ?? null, + username: user?.username ?? null, + userEmail: user?.email ?? null, + resourceNames, + resources: keyResources + }; + }); + + return ( + + + + ); +} diff --git a/src/app/[orgId]/settings/virtual-api-keys/(list)/layout.tsx b/src/app/[orgId]/settings/virtual-api-keys/(list)/layout.tsx new file mode 100644 index 000000000..20d3af73f --- /dev/null +++ b/src/app/[orgId]/settings/virtual-api-keys/(list)/layout.tsx @@ -0,0 +1,37 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { getTranslations } from "next-intl/server"; + +type VirtualApiKeysListLayoutProps = { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}; + +export default async function VirtualApiKeysListLayout({ + children, + params +}: VirtualApiKeysListLayoutProps) { + const { orgId } = await params; + const t = await getTranslations(); + + const navItems = [ + { + title: t("virtualApiKeysTabIdentity"), + href: `/${orgId}/settings/virtual-api-keys/identity` + }, + { + title: t("virtualApiKeysTabVirtual"), + href: `/${orgId}/settings/virtual-api-keys/keys` + } + ]; + + return ( + <> + + {children} + + ); +} diff --git a/src/app/[orgId]/settings/virtual-api-keys/page.tsx b/src/app/[orgId]/settings/virtual-api-keys/page.tsx index 28a73253e..d841a6e34 100644 --- a/src/app/[orgId]/settings/virtual-api-keys/page.tsx +++ b/src/app/[orgId]/settings/virtual-api-keys/page.tsx @@ -1,156 +1,17 @@ -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { AxiosResponse } from "axios"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { redirect } from "next/navigation"; -import { cache } from "react"; -import { GetOrgResponse } from "@server/routers/org"; -import OrgProvider from "@app/providers/OrgProvider"; -import VirtualApiKeysBanner from "@app/components/VirtualApiKeysBanner"; -import VirtualApiKeysTable, { - type VirtualApiKeyRow -} from "@app/components/VirtualApiKeysTable"; -import { getTranslations } from "next-intl/server"; import type { Metadata } from "next"; -import type { ListVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types"; -import type { ListUsersResponse } from "@server/routers/user"; -import type { ListResourcesResponse } from "@server/routers/resource"; +import { redirect } from "next/navigation"; export const metadata: Metadata = { title: "Virtual API Keys" }; -type VirtualApiKeysPageProps = { +type VirtualApiKeysIndexPageProps = { params: Promise<{ orgId: string }>; }; -export const dynamic = "force-dynamic"; - -export default async function VirtualApiKeysPage( - props: VirtualApiKeysPageProps +export default async function VirtualApiKeysIndexPage( + props: VirtualApiKeysIndexPageProps ) { const params = await props.params; - const cookieHeader = await authCookieHeader(); - const t = await getTranslations(); - - let keys: ListVirtualApiKeysResponse["virtualApiKeys"] = []; - let users: { - userId: string; - email: string | null; - name: string | null; - username: string | null; - }[] = []; - let resources: { - resourceId: number; - name: string; - niceId: string; - }[] = []; - - try { - const [keysRes, usersRes, resourcesRes] = await Promise.all([ - internal.get>( - `/org/${params.orgId}/virtual-api-keys?page=1&pageSize=1000`, - cookieHeader - ), - internal.get>( - `/org/${params.orgId}/users?page=1&pageSize=1000`, - cookieHeader - ), - internal.get>( - `/org/${params.orgId}/resources?page=1&pageSize=1000`, - cookieHeader - ) - ]); - - keys = keysRes.data.data.virtualApiKeys ?? []; - users = (usersRes.data.data.users ?? []).map((u) => ({ - userId: u.id, - email: u.email ?? null, - name: u.name ?? null, - username: u.username ?? null - })); - resources = (resourcesRes.data.data.resources ?? []).map((r) => ({ - resourceId: r.resourceId, - name: r.name, - niceId: r.niceId - })); - } catch { - // leave empty; page still renders - } - - let org = null; - try { - const getOrg = cache(async () => - internal.get>( - `/org/${params.orgId}`, - cookieHeader - ) - ); - const res = await getOrg(); - org = res.data.data; - } catch { - redirect(`/${params.orgId}/settings/resources`); - } - - if (!org) { - redirect(`/${params.orgId}/settings/resources`); - } - - const userById = new Map(users.map((u) => [u.userId, u])); - const resourceById = new Map(resources.map((r) => [r.resourceId, r])); - - const rows: VirtualApiKeyRow[] = keys.map((key) => { - const user = key.userId ? userById.get(key.userId) : undefined; - const keyResources = key.resourceIds - .map((id) => resourceById.get(id)) - .filter(Boolean) as { - resourceId: number; - name: string; - niceId: string; - }[]; - - const resourceNames = key.allResources - ? t("virtualApiKeysAllResources") - : keyResources.map((r) => r.name).join(", ") || - t("virtualApiKeysNoResources"); - - return { - virtualApiKeyId: key.virtualApiKeyId, - orgId: key.orgId, - kind: key.kind, - userId: key.userId, - name: key.name, - description: key.description, - lastChars: key.lastChars, - allResources: key.allResources, - expiresAt: key.expiresAt, - lastUsedAt: key.lastUsedAt, - createdAt: key.createdAt, - createdByUserId: key.createdByUserId, - resourceIds: key.resourceIds, - userName: user?.name ?? null, - username: user?.username ?? null, - userEmail: user?.email ?? null, - resourceNames, - resources: keyResources - }; - }); - - return ( - <> - - - - - - - - - ); + redirect(`/${params.orgId}/settings/virtual-api-keys/identity`); } diff --git a/src/components/EmailIdentityKeysForm.tsx b/src/components/EmailIdentityKeysForm.tsx new file mode 100644 index 000000000..f0189bf0a --- /dev/null +++ b/src/components/EmailIdentityKeysForm.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Label } from "@app/components/ui/label"; +import { + RolesSelector, + type SelectedRole +} from "@app/components/roles-selector"; +import { + UsersSelector, + type SelectedUser +} from "@app/components/users-selector"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import type { EmailIdentityKeysResponse } from "@server/routers/virtualApiKey/types"; +import { AxiosResponse } from "axios"; +import { useState } from "react"; +import { useTranslations } from "next-intl"; + +type EmailIdentityKeysFormProps = { + orgId: string; + open: boolean; + setOpen: (open: boolean) => void; +}; + +export default function EmailIdentityKeysForm({ + orgId, + open, + setOpen +}: EmailIdentityKeysFormProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [sendToAll, setSendToAll] = useState(false); + const [selectedUsers, setSelectedUsers] = useState([]); + const [selectedRoles, setSelectedRoles] = useState([]); + const [loading, setLoading] = useState(false); + + function resetState() { + setSendToAll(false); + setSelectedUsers([]); + setSelectedRoles([]); + setLoading(false); + } + + async function onSubmit() { + if ( + !sendToAll && + selectedUsers.length === 0 && + selectedRoles.length === 0 + ) { + toast({ + variant: "destructive", + title: t("virtualApiKeysEmailIdentityRecipientsRequired"), + description: t("virtualApiKeysEmailIdentityRecipientsRequired") + }); + return; + } + + setLoading(true); + try { + const res = await api.post< + AxiosResponse + >(`/org/${orgId}/virtual-api-keys/email-identity-keys`, { + sendToAll, + userIds: sendToAll ? [] : selectedUsers.map((user) => user.id), + roleIds: sendToAll + ? [] + : selectedRoles.map((role) => Number(role.id)) + }); + + const { sent, skipped } = res.data.data; + toast({ + title: t("virtualApiKeysEmailIdentitySuccess"), + description: + skipped > 0 + ? `${t("virtualApiKeysEmailIdentitySuccessDescription", { sent })} ${t("virtualApiKeysEmailIdentitySkipped", { skipped })}` + : t("virtualApiKeysEmailIdentitySuccessDescription", { + sent + }) + }); + setOpen(false); + resetState(); + } catch (e) { + toast({ + variant: "destructive", + title: t("virtualApiKeysEmailIdentityError"), + description: formatAxiosError( + e, + t("virtualApiKeysEmailIdentityErrorDescription") + ) + }); + } + setLoading(false); + } + + return ( + { + setOpen(val); + if (!val) { + resetState(); + } + }} + > + + + + {t("virtualApiKeysEmailIdentity")} + + + {t("virtualApiKeysEmailIdentityDescription")} + + + +
+
+ + setSendToAll(val === true) + } + className="mt-0.5" + /> +
+ +

+ {t( + "virtualApiKeysEmailIdentitySendAllDescription" + )} +

+
+
+
+ + +
+
+ + +
+
+
+ + + + + + +
+
+ ); +} diff --git a/src/components/IdentityKeysSplash.tsx b/src/components/IdentityKeysSplash.tsx new file mode 100644 index 000000000..b939dedab --- /dev/null +++ b/src/components/IdentityKeysSplash.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionFooter +} from "@app/components/Settings"; +import EmailIdentityKeysForm from "@app/components/EmailIdentityKeysForm"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat"; +import { ArrowRight, ExternalLink, Globe, KeyRound, Mail } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useState } from "react"; + +const EXAMPLE_IDENTITY_KEY = formatVirtualApiKeyCredential( + "k7m2n9qx", + "a8f3c1e0b5d24791" +); + +type IdentityKeysSplashProps = { + orgId: string; +}; + +export default function IdentityKeysSplash({ orgId }: IdentityKeysSplashProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const [emailOpen, setEmailOpen] = useState(false); + const emailEnabled = env.email.emailEnabled; + + const dashboardUrl = env.app.dashboardUrl?.replace(/\/$/, "") ?? ""; + const keysPath = `/${orgId}/keys`; + const keysUrl = dashboardUrl ? `${dashboardUrl}${keysPath}` : keysPath; + + return ( + <> + + +
+ +

+ {t("virtualApiKeysIdentitySplashTitle")} +

+

+ {t("virtualApiKeysIdentitySplashDescription")} +

+ +
+

+ {t("virtualApiKeysIdentitySplashRetrieveTitle")} +

+
    +
  • + + + {t( + "virtualApiKeysIdentitySplashRetrieveResource" + )} + +
  • +
  • + + + {t.rich( + "virtualApiKeysIdentitySplashRetrievePage", + { + url: () => ( + + {keysUrl} + + ) + } + )} + +
  • +
+
+ +

+ {t("virtualApiKeysIdentitySplashManual")} +

+ {!emailEnabled && ( +

+ {t( + "virtualApiKeysEmailSmtpRequiredDescription" + )} +

+ )} +
+
+ + + + +
+ + + ); +} diff --git a/src/components/VirtualApiKeysBanner.tsx b/src/components/VirtualApiKeysBanner.tsx deleted file mode 100644 index dd2aa1186..000000000 --- a/src/components/VirtualApiKeysBanner.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { Button } from "@app/components/ui/button"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { ArrowRight, KeyRound } from "lucide-react"; -import { useTranslations } from "next-intl"; -import Link from "next/link"; -import DismissableBanner from "./DismissableBanner"; - -type VirtualApiKeysBannerProps = { - orgId: string; -}; - -export const VirtualApiKeysBanner = ({ orgId }: VirtualApiKeysBannerProps) => { - const t = useTranslations(); - const { env } = useEnvContext(); - - const dashboardUrl = env.app.dashboardUrl?.replace(/\/$/, "") ?? ""; - const keysUrl = dashboardUrl - ? `${dashboardUrl}/${orgId}/keys` - : `/${orgId}/keys`; - - return ( - } - description={t("virtualApiKeysBannerDescription", { keysUrl })} - > - - - - - ); -}; - -export default VirtualApiKeysBanner; From ca7ea72ff155e98c68091acc403f975d170a16c9 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 14 Aug 2026 17:53:53 -0400 Subject: [PATCH 377/494] restyle domain picker --- src/components/DomainPicker.tsx | 234 +++++++++++++++----------------- 1 file changed, 110 insertions(+), 124 deletions(-) diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 957d759eb..2abb403cb 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -38,14 +38,11 @@ import { useQuery } from "@tanstack/react-query"; import { AxiosResponse } from "axios"; import { AlertCircle, - Building2, - Check, - CheckCircle2, + CheckIcon, ChevronsUpDown, ExternalLink, Globe, - KeyRound, - Zap + KeyRound } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; @@ -609,61 +606,72 @@ export default function DomainPicker({ - - + + - -
+ + {t("domainPickerNoDomainsFound")} -
-
- - {organizationDomains.length > 0 && ( - <> + + {organizationDomains.length > 0 && ( - - {organizationDomains.map( - (orgDomain) => ( + {organizationDomains.map( + (orgDomain) => { + const description = + orgDomain.type === + "wildcard" + ? t( + "domainPickerManual" + ) + : `${orgDomain.type.toUpperCase()} · ${ + orgDomain.verified + ? t( + "domainPickerVerified" + ) + : t( + "domainPickerUnverified" + ) + }`; + const optionId = `org-${orgDomain.domainId}`; + + return ( handleBaseDomainSelect( { - id: `org-${orgDomain.domainId}`, + id: optionId, domain: orgDomain.baseDomain, type: "organization", verified: @@ -675,80 +683,63 @@ export default function DomainPicker({ } ) } - className="mx-2 rounded-md" - disabled={ - !orgDomain.verified - } > -
- -
-
- - { - orgDomain.baseDomain - } - - - {orgDomain.type === - "wildcard" ? ( - t( - "domainPickerManual" - ) - ) : ( - <> - {orgDomain.type.toUpperCase()}{" "} - •{" "} - {orgDomain.verified - ? t( - "domainPickerVerified" - ) - : t( - "domainPickerUnverified" - )} - - )} - -
- +
+ + { + orgDomain.baseDomain + } + + + { + description + } + +
- ) - )} -
-
- {(build === "saas" || - build === "enterprise") && - !hideFreeDomain && ( - + ); + } )} - - )} - - {(build === "saas" || build === "enterprise") && - !hideFreeDomain && ( - - + + )} + {organizationDomains.length > 0 && + (build === "saas" || + build === "enterprise") && + !hideFreeDomain && } + {(build === "saas" || + build === "enterprise") && + !hideFreeDomain && ( + handleBaseDomainSelect({ id: "provided-search", @@ -764,14 +755,18 @@ export default function DomainPicker({ type: "provided-search" }) } - className="mx-2 rounded-md" - disabled={requiresPaywall} > -
- -
-
- + +
+ {build === "enterprise" ? t( @@ -781,25 +776,16 @@ export default function DomainPicker({ "domainPickerFreeProvidedDomain" )} - + {t( "domainPickerSearchForAvailableDomains" )}
- - - - )} + + )} + From b51aecf45a609f2c97977bf3a64a3dcd8908c972 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 16:33:05 -0400 Subject: [PATCH 378/494] Remove advanced resources paywall --- server/lib/billing/tierMatrix.ts | 10 +- server/lib/blueprints/privateResources.ts | 24 -- server/lib/blueprints/publicResources.ts | 16 +- .../routers/billing/featureLifecycle.ts | 17 +- server/private/routers/external.ts | 28 +- server/private/routers/internal.ts | 7 - server/private/routers/ssh/index.ts | 14 - server/routers/external.ts | 26 +- server/routers/internal.ts | 15 +- server/routers/resource/createResource.ts | 17 +- server/routers/role/createRole.ts | 2 +- server/routers/role/updateRole.ts | 2 +- .../siteResource/createSiteResource.ts | 48 +-- .../siteResource/updateSiteResource.ts | 29 +- server/routers/ssh/index.ts | 1 + .../{private => }/routers/ssh/signSshKey.ts | 28 -- .../resources/private/[niceId]/http/page.tsx | 7 +- .../resources/private/[niceId]/ssh/page.tsx | 116 +++--- .../resources/private/create/page.tsx | 177 +++------ .../public/[niceId]/maintenance/page.tsx | 4 +- .../resources/public/[niceId]/rdp/page.tsx | 9 +- .../resources/public/[niceId]/ssh/page.tsx | 9 +- .../resources/public/[niceId]/vnc/page.tsx | 9 +- .../settings/resources/public/create/page.tsx | 62 +-- src/components/CreateRoleForm.tsx | 2 +- src/components/EditRoleForm.tsx | 7 +- src/components/PrivateResourceHttpFields.tsx | 13 +- src/components/PrivateResourceSshFields.tsx | 9 - src/components/RoleForm.tsx | 368 +++++++++--------- src/lib/setClientCookie.ts | 7 - 30 files changed, 368 insertions(+), 715 deletions(-) delete mode 100644 server/private/routers/ssh/index.ts create mode 100644 server/routers/ssh/index.ts rename server/{private => }/routers/ssh/signSshKey.ts (97%) diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 7c0b591ca..7e49121dc 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -10,7 +10,7 @@ export enum TierFeature { ActionLogs = "actionLogs", // set the retention period to none on downgrade ConnectionLogs = "connectionLogs", RotateCredentials = "rotateCredentials", - MaintencePage = "maintencePage", // handle downgrade + MaintenancePage = "maintenancePage", // handle downgrade DevicePosture = "devicePosture", TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration @@ -25,8 +25,7 @@ export enum TierFeature { WildcardSubdomain = "wildcardSubdomain", NewtAutoUpdate = "newtAutoUpdate", ResourcePolicies = "resourcePolicies", - AdvancedPublicResources = "advancedPublicResources", - AdvancedPrivateResources = "advancedPrivateResources" + RoleBasedSSHControls = "roleBasedSSHControls" } export const tierMatrix: Record = { @@ -39,7 +38,7 @@ export const tierMatrix: Record = { [TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"], [TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"], [TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"], - [TierFeature.MaintencePage]: ["tier1", "tier2", "tier3", "enterprise"], + [TierFeature.MaintenancePage]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"], [TierFeature.TwoFactorEnforcement]: [ "tier1", @@ -69,6 +68,5 @@ export const tierMatrix: Record = { [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.ResourcePolicies]: ["tier3", "enterprise"], - [TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"], - [TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"] + [TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"] }; diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 9cec7b487..cb8f8e8b5 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -128,30 +128,6 @@ export async function updatePrivateResources( for (const [resourceNiceId, resourceData] of Object.entries( config["client-resources"] )) { - if (resourceData.mode === "http") { - const hasHttpFeature = await isLicensedOrSubscribed( - orgId, - tierMatrix.advancedPrivateResources - ); - if (!hasHttpFeature) { - throw new Error( - "HTTP private resources are not included in your current plan. Please upgrade." - ); - } - } - - if (resourceData.mode === "ssh") { - const hasSshFeature = await isLicensedOrSubscribed( - orgId, - tierMatrix.advancedPrivateResources - ); - if (!hasSshFeature) { - throw new Error( - "SSH private resources are not included in your current plan. Please upgrade." - ); - } - } - const [existingResource] = await trx .select() .from(siteResources) diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 2f4d9773d..61985a06e 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -262,18 +262,6 @@ export async function updatePublicResources( headers = JSON.stringify(resourceData.headers); } - if (["ssh", "rdp", "vnc"].includes(resourceData.mode || "")) { - const isLicensed = await isLicensedOrSubscribed( - orgId, - tierMatrix.advancedPublicResources - ); - if (!isLicensed) { - throw new Error( - "Your current subscription does not support browser gateway resources. Please upgrade to access this feature." - ); - } - } - if (resourceData.policy) { const isLicensed = await isLicensedOrSubscribed( orgId, @@ -331,7 +319,7 @@ export async function updatePublicResources( const isLicensed = await isLicensedOrSubscribed( orgId, - tierMatrix.maintencePage + tierMatrix.maintenancePage ); if (!isLicensed) { resourceData.maintenance = undefined; @@ -1138,7 +1126,7 @@ export async function updatePublicResources( const isLicensed = await isLicensedOrSubscribed( orgId, - tierMatrix.maintencePage + tierMatrix.maintenancePage ); if (!isLicensed) { resourceData.maintenance = undefined; diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index 84a7b4f5a..b32d83f7e 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -295,8 +295,8 @@ async function disableFeature( await disableRotateCredentials(orgId); break; - case TierFeature.MaintencePage: - await disableMaintencePage(orgId); + case TierFeature.MaintenancePage: + await disablemaintenancePage(orgId); break; case TierFeature.DevicePosture: @@ -319,10 +319,6 @@ async function disableFeature( await disableAutoProvisioning(orgId); break; - case TierFeature.AdvancedPrivateResources: - await disableAdvancedPrivateResources(orgId); - break; - case TierFeature.FullRbac: await disableFullRbac(orgId); break; @@ -368,13 +364,6 @@ async function disableDeviceApprovals(orgId: string): Promise { logger.info(`Disabled device approvals on all roles for org ${orgId}`); } -async function disableAdvancedPrivateResources(orgId: string): Promise { - // TODO: implement logic to disable advanced private resourcs like ssh and ssh pam - // logger.info( - // `Disabled advanced private resources on all roles and site resources for org ${orgId}` - // ); -} - async function disableFullRbac(orgId: string): Promise { logger.info(`Disabled full RBAC for org ${orgId}`); } @@ -506,7 +495,7 @@ async function disableConnectionLogs(orgId: string): Promise { async function disableRotateCredentials(orgId: string): Promise {} -async function disableMaintencePage(orgId: string): Promise { +async function disablemaintenancePage(orgId: string): Promise { await db .update(resources) .set({ diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index fab026418..32cb68945 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -20,19 +20,16 @@ import * as orgIdp from "#private/routers/orgIdp"; import * as domain from "#private/routers/domain"; import * as auth from "#private/routers/auth"; import * as license from "#private/routers/license"; -import * as generateLicense from "./generatedLicense"; +import * as generateLicense from "#private/routers/generatedLicense"; import * as logs from "#private/routers/auditLogs"; import * as misc from "#private/routers/misc"; import * as reKey from "#private/routers/re-key"; import * as approval from "#private/routers/approvals"; -import * as ssh from "#private/routers/ssh"; import * as user from "#private/routers/user"; import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as alertRule from "#private/routers/alertRule"; import * as healthChecks from "#private/routers/healthChecks"; -import * as client from "@server/routers/client"; -import * as resource from "#private/routers/resource"; import * as policy from "#private/routers/policy"; import { @@ -652,17 +649,6 @@ authenticated.put( reKey.reGenerateExitNodeSecret ); -authenticated.post( - "/org/:orgId/ssh/sign-key", - verifyValidLicense, - verifyValidSubscription(tierMatrix.advancedPrivateResources), - verifyOrgAccess, - verifyLimits, - // verifyUserHasAction(ActionsEnum.signSshKey), // this check happens inside of the function now - // logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata - ssh.signSshKey -); - authenticated.post( "/user/:userId/add-role/:roleId", verifyRoleAccess, @@ -868,18 +854,6 @@ authenticated.get( healthChecks.getBatchedHealthCheckStatusHistory ); -authenticated.get( - "/client/:clientId/verify-associations-cache", - verifyClientAccess, - client.verifyClientAssociationsCache -); - -authenticated.post( - "/client/:clientId/rebuild-associations-cache", - verifyClientAccess, - client.rebuildClientAssociationsCacheRoute -); - authenticated.post( "/org/:orgId/logs/access/attempt", verifyOrgAccess, diff --git a/server/private/routers/internal.ts b/server/private/routers/internal.ts index c45fe36b9..23188dcd7 100644 --- a/server/private/routers/internal.ts +++ b/server/private/routers/internal.ts @@ -17,7 +17,6 @@ import * as orgIdp from "#private/routers/orgIdp"; import * as billing from "#private/routers/billing"; import * as license from "#private/routers/license"; import * as resource from "#private/routers/resource"; -import * as ssh from "#private/routers/ssh"; import * as ws from "@server/routers/ws"; import * as browserTarget from "#private/routers/browserGatewayTarget"; @@ -47,12 +46,6 @@ internalRouter.get(`/license/status`, license.getLicenseStatus); internalRouter.get("/maintenance/info", resource.getMaintenanceInfo); -internalRouter.post( - "/org/:orgId/ssh/sign-key", - verifyUserFromResourceSessionMiddleware, - ssh.signSshKey -); - internalRouter.get( "/ws/round-trip-message/:messageId", verifyUserFromResourceSessionMiddleware, diff --git a/server/private/routers/ssh/index.ts b/server/private/routers/ssh/index.ts deleted file mode 100644 index d2f607f81..000000000 --- a/server/private/routers/ssh/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -export * from "./signSshKey"; \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index b51fbf2de..9c273fce5 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -20,6 +20,7 @@ import * as logs from "./auditLogs"; import * as launcher from "./launcher"; import * as newt from "./newt"; import * as olm from "./olm"; +import * as ssh from "./ssh"; import * as serverInfo from "./serverInfo"; import HttpCode from "@server/types/HttpCode"; import { @@ -56,12 +57,13 @@ import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import createHttpError from "http-errors"; import { build } from "@server/build"; import { createStore } from "#dynamic/lib/rateLimitStore"; -import { logActionAudit } from "#dynamic/middlewares"; +import { logActionAudit, verifyValidLicense } from "#dynamic/middlewares"; import { checkRoundTripMessage } from "./ws"; import * as labels from "@server/routers/labels"; import * as aiProvider from "@server/routers/aiProvider"; import * as aiBudget from "@server/routers/aiBudget"; import * as virtualApiKey from "@server/routers/virtualApiKey"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; // Root routes export const unauthenticated = Router(); @@ -1863,6 +1865,28 @@ authenticated.put( labels.detachLabelFromItem ); +authenticated.post( + "/org/:orgId/ssh/sign-key", + verifyValidLicense, + verifyOrgAccess, + verifyLimits, + // verifyUserHasAction(ActionsEnum.signSshKey), // this check happens inside of the function now + // logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata + ssh.signSshKey +); + +authenticated.get( + "/client/:clientId/verify-associations-cache", + verifyClientAccess, + client.verifyClientAssociationsCache +); + +authenticated.post( + "/client/:clientId/rebuild-associations-cache", + verifyClientAccess, + client.rebuildClientAssociationsCacheRoute +); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); diff --git a/server/routers/internal.ts b/server/routers/internal.ts index b06015a5f..27414c47b 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -1,15 +1,17 @@ import { Router } from "express"; import * as gerbil from "@server/routers/gerbil"; import * as traefik from "@server/routers/traefik"; -import * as resource from "./resource"; -import * as badger from "./badger"; +import * as resource from "@server/routers/resource"; +import * as badger from "@server/routers/badger"; import * as auth from "@server/routers/auth"; import * as supporterKey from "@server/routers/supporterKey"; import * as idp from "@server/routers/idp"; +import * as ssh from "@server/routers/ssh"; import HttpCode from "@server/types/HttpCode"; import { verifyResourceAccess, - verifySessionUserMiddleware + verifySessionUserMiddleware, + verifyUserFromResourceSessionMiddleware } from "@server/middlewares"; // Root routes @@ -42,6 +44,12 @@ internalRouter.get("/idp", idp.listIdps); internalRouter.get("/idp/:idpId", idp.getIdp); +internalRouter.post( + "/org/:orgId/ssh/sign-key", + verifyUserFromResourceSessionMiddleware, + ssh.signSshKey +); + // Gerbil routes const gerbilRouter = Router(); internalRouter.use("/gerbil", gerbilRouter); @@ -63,4 +71,3 @@ internalRouter.use("/badger", badgerRouter); badgerRouter.post("/verify-session", badger.verifyResourceSession); badgerRouter.post("/exchange-session", badger.exchangeSession); - diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index c0c7a7d5d..094e092b3 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -31,7 +31,7 @@ import { } from "@server/lib/domainUtils"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { getUniqueResourceName, getUniqueResourcePolicyName @@ -454,21 +454,6 @@ async function createHttpResource( } } - if ( - ["ssh", "rdp", "vnc"].includes(effectiveMode) && - !isLicensedOrSubscribed( - orgId!, - tierMatrix[TierFeature.AdvancedPublicResources] - ) - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Your current subscription does not support browser gateway resources. Please upgrade to access this feature." - ) - ); - } - // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( domainId, diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts index 6d1ecb503..1cce7a5ae 100644 --- a/server/routers/role/createRole.ts +++ b/server/routers/role/createRole.ts @@ -135,7 +135,7 @@ export async function createRole( const isLicensedSshPam = await isLicensedOrSubscribed( orgId, - tierMatrix.advancedPrivateResources + tierMatrix.roleBasedSSHControls ); const roleInsertValues: Record = { name: roleData.name, diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts index aa01899db..2c1dcc887 100644 --- a/server/routers/role/updateRole.ts +++ b/server/routers/role/updateRole.ts @@ -144,7 +144,7 @@ export async function updateRole( const isLicensedSshPam = await isLicensedOrSubscribed( orgId, - tierMatrix.advancedPrivateResources + tierMatrix.roleBasedSSHControls ); if (!isLicensedSshPam) { delete updateData.sshSudoMode; diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index f3794d35a..54a1bc004 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -10,8 +10,7 @@ import { SiteResource, siteResources, sites, - userSiteResources, - primaryDb + userSiteResources } from "@server/db"; import { getUniqueSiteResourceName } from "@server/db/names"; import { @@ -19,8 +18,6 @@ import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; -import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { rebuildClientAssociationsFromSiteResource, isOrgRebuildRateLimited @@ -408,21 +405,6 @@ export async function createSiteResource( } } - if (mode == "http") { - const hasHttpFeature = await isLicensedOrSubscribed( - orgId, - tierMatrix[TierFeature.AdvancedPrivateResources] - ); - if (!hasHttpFeature) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "HTTP private resources are not included in your current plan. Please upgrade." - ) - ); - } - } - // Verify the site exists and belongs to the org const sitesToAssign = await db .select() @@ -557,20 +539,6 @@ export async function createSiteResource( } } - const isLicensedSshPam = await isLicensedOrSubscribed( - orgId, - tierMatrix.advancedPrivateResources - ); - - if (mode == "ssh" && !isLicensedSshPam) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "SSH private resources are not included in your current plan. Please upgrade." - ) - ); - } - let updatedNiceId = niceId; if (!niceId) { updatedNiceId = await getUniqueSiteResourceName(orgId); @@ -646,13 +614,13 @@ export async function createSiteResource( fullDomain, requiresExitNodeConnection: mode === "inference" // in the future we might want to have different modes that do this }; - if (isLicensedSshPam) { - if (authDaemonPort !== undefined) - insertValues.authDaemonPort = authDaemonPort; - if (authDaemonMode !== undefined) - insertValues.authDaemonMode = authDaemonMode; - if (pamMode !== undefined) insertValues.pamMode = pamMode; - } + + if (authDaemonPort !== undefined) + insertValues.authDaemonPort = authDaemonPort; + if (authDaemonMode !== undefined) + insertValues.authDaemonMode = authDaemonMode; + if (pamMode !== undefined) insertValues.pamMode = pamMode; + [newSiteResource] = await trx .insert(siteResources) .values(insertValues) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 2e57b13a7..f0ee514a7 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -10,8 +10,6 @@ import { sites, userSiteResources } from "@server/db"; -import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import response from "@server/lib/response"; import { eq, and, ne, inArray } from "drizzle-orm"; @@ -362,26 +360,6 @@ export async function updateSiteResource( ); } - if (mode == "http") { - const hasHttpFeature = await isLicensedOrSubscribed( - existingSiteResource.orgId, - tierMatrix[TierFeature.AdvancedPrivateResources] - ); - if (!hasHttpFeature) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "HTTP private resources are not included in your current plan. Please upgrade." - ) - ); - } - } - - const isLicensedSshPam = await isLicensedOrSubscribed( - existingSiteResource.orgId, - tierMatrix.advancedPrivateResources - ); - const [org] = await db .select() .from(orgs) @@ -541,10 +519,9 @@ export async function updateSiteResource( await db.transaction(async (trx) => { // Update the site resource const sshPamSet = - isLicensedSshPam && - (authDaemonPort !== undefined || - authDaemonMode !== undefined || - pamMode !== undefined) + authDaemonPort !== undefined || + authDaemonMode !== undefined || + pamMode !== undefined ? { ...(authDaemonPort !== undefined && { authDaemonPort diff --git a/server/routers/ssh/index.ts b/server/routers/ssh/index.ts new file mode 100644 index 000000000..4cfcb6df3 --- /dev/null +++ b/server/routers/ssh/index.ts @@ -0,0 +1 @@ +export * from "./signSshKey"; \ No newline at end of file diff --git a/server/private/routers/ssh/signSshKey.ts b/server/routers/ssh/signSshKey.ts similarity index 97% rename from server/private/routers/ssh/signSshKey.ts rename to server/routers/ssh/signSshKey.ts index ae74a07a0..dfd73fd13 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/routers/ssh/signSshKey.ts @@ -1,16 +1,3 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - import { Request, Response, NextFunction } from "express"; import { randomInt } from "crypto"; import { z } from "zod"; @@ -35,8 +22,6 @@ import { SiteResource } from "@server/db"; import { logAccessAudit } from "#private/lib/logAccessAudit"; -import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -163,19 +148,6 @@ export async function signSshKey( ); } - const isLicensed = await isLicensedOrSubscribed( - orgId, - tierMatrix.advancedPrivateResources - ); - if (!isLicensed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "SSH key signing requires a paid plan" - ) - ); - } - // Get and decrypt the org's CA keys const caKeys = await getOrgCAKeys( orgId, diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx index c1263c162..c764b0446 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx @@ -35,10 +35,6 @@ import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; export default function PrivateResourceHttpPage() { const t = useTranslations(); const { save, siteResource } = useSaveSiteResource(); - const { isPaidUser } = usePaidStatus(); - const httpSectionDisabled = !isPaidUser( - tierMatrix.advancedPrivateResources - ); const [selectedSites, setSelectedSites] = useState(() => buildSelectedSitesForResource(siteResource) ); @@ -120,7 +116,7 @@ export default function PrivateResourceHttpPage() { )} orgId={siteResource.orgId} watch={asAnyWatch(form.watch)} - disabled={httpSectionDisabled} + disabled={false} siteResourceId={siteResource.id} /> @@ -135,7 +131,6 @@ export default function PrivateResourceHttpPage() { type="submit" form="private-resource-http-form" loading={saveLoading} - disabled={httpSectionDisabled} > {t("saveSettings")} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx index 9a739a073..ba60e41e6 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx @@ -12,16 +12,13 @@ import { SettingsFormGrid } from "@app/components/Settings"; import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { Button } from "@app/components/ui/button"; import { Form } from "@app/components/ui/form"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { createSshFormSchema, inferSshPamMode } from "@app/lib/privateResourceForm"; import { zodResolver } from "@hookform/resolvers/zod"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { useTranslations } from "next-intl"; import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; @@ -39,8 +36,6 @@ import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; export default function PrivateResourceSshPage() { const t = useTranslations(); const { save, siteResource } = useSaveSiteResource(); - const { isPaidUser } = usePaidStatus(); - const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources); const isNative = siteResource.authDaemonMode === "native"; const [sshServerMode] = useState<"standard" | "native">( isNative ? "native" : "standard" @@ -150,7 +145,6 @@ export default function PrivateResourceSshPage() { return ( - @@ -161,68 +155,56 @@ export default function PrivateResourceSshPage() { -
-
- - - - - form.setValue( - "authDaemonPort", - value, - { shouldValidate: true } - ) - } - authDaemonPortError={ - form.formState.errors.authDaemonPort - ?.message - } - sshServerMode={sshServerMode} - serverModeDisplay="badge" - /> - - - - + + + + + + form.setValue("authDaemonPort", value, { + shouldValidate: true + }) + } + authDaemonPortError={ + form.formState.errors.authDaemonPort + ?.message + } + sshServerMode={sshServerMode} + serverModeDisplay="badge" + /> + + + + - - - - -
- -
+ +
+ +
+
+
); diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index 96e8f84d8..717464283 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -16,7 +16,6 @@ import { type DescribedSelectOption } from "@app/components/DescribedSelect"; import DomainPicker from "@app/components/DomainPicker"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { Button } from "@app/components/ui/button"; import { Form, @@ -30,7 +29,6 @@ import { import { Input } from "@app/components/ui/input"; import type { Selectedsite } from "@app/components/site-selector"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { @@ -77,12 +75,6 @@ export default function CreatePrivateResourcePage() { const { env } = useEnvContext(); const api = createApiClient({ env }); const orgId = params.orgId as string; - const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures; - const { isPaidUser } = usePaidStatus(); - const httpSectionDisabled = !isPaidUser( - tierMatrix.advancedPrivateResources - ); - const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources); const [isSubmitting, startTransition] = useTransition(); const siteIdParam = searchParams.get("siteId"); @@ -158,20 +150,16 @@ export default function CreatePrivateResourcePage() { title: t("createInternalResourceDialogModeCidr"), description: t("privateResourceTypeCidrDescription") }, - ...(!disableEnterpriseFeatures - ? [ - { - value: "http" as const, - title: t("createInternalResourceDialogModeHttp"), - description: t("privateResourceTypeHttpDescription") - }, - { - value: "ssh" as const, - title: t("createInternalResourceDialogModeSsh"), - description: t("privateResourceTypeSshDescription") - } - ] - : []), + { + value: "http" as const, + title: t("createInternalResourceDialogModeHttp"), + description: t("privateResourceTypeHttpDescription") + }, + { + value: "ssh" as const, + title: t("createInternalResourceDialogModeSsh"), + description: t("privateResourceTypeSshDescription") + }, { value: "inference" as const, title: t("createInternalResourceDialogModeInference"), @@ -179,11 +167,6 @@ export default function CreatePrivateResourcePage() { } ]; - const submitDisabled = - isSubmitting || - (mode === "http" && httpSectionDisabled) || - (mode === "ssh" && sshSectionDisabled); - function onSubmit(values: FormValues) { startTransition(async () => { try { @@ -467,10 +450,7 @@ export default function CreatePrivateResourcePage() { )} watch={asAnyWatch(form.watch)} labelPrefix="create" - disabled={ - mode === "ssh" && - sshSectionDisabled - } + disabled={false} /> )} @@ -584,9 +564,6 @@ export default function CreatePrivateResourcePage() { {/* HTTP configuration */} {mode === "http" && ( - {t("httpSettings")} @@ -597,62 +574,44 @@ export default function CreatePrivateResourcePage() { )} -
- - - - - - - - - - - - -
+ + + + + + + + + + + + +
)} {/* SSH server */} {mode === "ssh" && ( - {t("sshSettings")} @@ -661,37 +620,23 @@ export default function CreatePrivateResourcePage() { {t("sshServerDescription")} -
- - - - - -
+ + + + +
)} @@ -776,7 +721,7 @@ export default function CreatePrivateResourcePage() {

rE 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 xS$Sp|pefQ>8NF&YPSE-fn1VDe&_GV{r7u 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