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 001/678] 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 1580b7abff4d434baf550e1057a23bad76cdd1c2 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 10 Jul 2026 07:02:22 +0200 Subject: [PATCH 002/678] =?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 0275f44056f215dc7cd983c7d441f0e18aeda4a8 Mon Sep 17 00:00:00 2001 From: Shubham Singh Date: Sun, 19 Jul 2026 13:45:33 +0530 Subject: [PATCH 003/678] 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 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 004/678] 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 005/678] =?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 006/678] =?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 007/678] =?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 008/678] =?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 009/678] 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 010/678] =?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 011/678] =?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 012/678] =?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 013/678] =?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 014/678] =?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 015/678] =?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 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 016/678] 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 84d5a4b86c667c77f358801924ecabb8a536f56a Mon Sep 17 00:00:00 2001 From: ThanatosDi Date: Thu, 23 Jul 2026 14:40:42 +0800 Subject: [PATCH 017/678] feat: translate zh-tw for 1.21.0 --- messages/zh-TW.json | 3001 +++++++++++++++++++++++++++++++------------ 1 file changed, 2213 insertions(+), 788 deletions(-) diff --git a/messages/zh-TW.json b/messages/zh-TW.json index c37430567..9e32a32f6 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -1,207 +1,206 @@ { - "setupCreate": "創建您的第一個組織、網站和資源", - "headerAuthCompatibilityInfo": "啟用此選項以在缺少驗證令牌時強制回傳 401 未授權回應。這對於不會在沒有伺服器挑戰的情況下發送憑證的瀏覽器或特定 HTTP 函式庫是必需的。", - "headerAuthCompatibility": "擴展相容性", + "setupCreate": "建立您的第一個組織、網站和資源", + "headerAuthCompatibilityInfo": "啟用此選項以在缺少驗證權杖時強制回傳 401 未授權回應。這對於不會在沒有伺服器挑戰的情況下傳送憑證的瀏覽器或特定 HTTP 函式庫是必需的。", + "headerAuthCompatibility": "擴充相容性", "setupNewOrg": "新建組織", - "setupCreateOrg": "創建組織", - "setupCreateResources": "創建資源", + "setupCreateOrg": "建立組織", + "setupCreateResources": "建立資源", "setupOrgName": "組織名稱", "orgDisplayName": "這是您組織的顯示名稱。", "orgId": "組織ID", "setupIdentifierMessage": "這是您組織的唯一標識符。這是與顯示名稱分開的。", "setupErrorIdentifier": "組織ID 已被使用。請另選一個。", - "componentsErrorNoMemberCreate": "您目前不是任何組織的成員。創建組織以開始操作。", + "componentsErrorNoMemberCreate": "您目前不是任何組織的成員。建立組織以開始操作。", "componentsErrorNoMember": "您目前不是任何組織的成員。", "welcome": "歡迎使用 Pangolin", "welcomeTo": "歡迎來到", - "componentsCreateOrg": "創建組織", + "componentsCreateOrg": "建立組織", "componentsMember": "您屬於 {count, plural, =0 {沒有組織} one {一個組織} other {# 個組織}}。", "componentsInvalidKey": "檢測到無效或過期的許可證金鑰。按照許可證條款操作以繼續使用所有功能。", "dismiss": "忽略", - "componentsLicenseViolation": "許可證超限:該伺服器使用了 {usedSites} 個站點,已超過授權的 {maxSites} 個。請遵守許可證條款以繼續使用全部功能。", - "componentsSupporterMessage": "感謝您的支持!您現在是 Pangolin 的 {tier} 用戶。", - "inviteErrorNotValid": "很抱歉,但看起來你試圖訪問的邀請尚未被接受或不再有效。", - "inviteErrorUser": "很抱歉,但看起來你想要訪問的邀請不是這個用戶。", - "inviteLoginUser": "請確保您以正確的用戶登錄。", - "inviteErrorNoUser": "很抱歉,但看起來你想訪問的邀請不是一個存在的用戶。", - "inviteCreateUser": "請先創建一個帳戶。", + "componentsLicenseViolation": "許可證超限:該伺服器使用了 {usedSites} 個站台,已超過授權的 {maxSites} 個。請遵守許可證條款以繼續使用全部功能。", + "componentsSupporterMessage": "感謝您的支持!您現在是 Pangolin 的 {tier} 使用者。", + "inviteErrorNotValid": "很抱歉,但看起來你試圖存取的邀請尚未被接受或不再有效。", + "inviteErrorUser": "很抱歉,但看起來你想要存取的邀請不是這個使用者。", + "inviteLoginUser": "請確保您以正確的使用者登入。", + "inviteErrorNoUser": "很抱歉,但看起來你想存取的邀請不是一個存在的使用者。", + "inviteCreateUser": "請先建立一個帳戶。", "goHome": "返回首頁", - "inviteLogInOtherUser": "以不同的用戶登錄", - "createAnAccount": "創建帳戶", + "inviteLogInOtherUser": "以不同的使用者登入", + "createAnAccount": "建立帳戶", "inviteNotAccepted": "邀請未接受", - "authCreateAccount": "創建一個帳戶以開始", + "authCreateAccount": "建立一個帳戶以開始", "authNoAccount": "沒有帳戶?", - "email": "電子郵件地址", + "email": "電子郵件位址", "password": "密碼", "confirmPassword": "確認密碼", - "createAccount": "創建帳戶", - "viewSettings": "查看設置", + "createAccount": "建立帳戶", + "viewSettings": "查看設定", "delete": "刪除", "name": "名稱", "online": "在線", "offline": "離線的", - "site": "站點", - "dataIn": "數據輸入", - "dataOut": "數據輸出", + "site": "站台", + "dataIn": "資料輸入", + "dataOut": "資料輸出", "connectionType": "連接類型", "tunnelType": "隧道類型", "local": "本地的", "edit": "編輯", - "siteConfirmDelete": "確認刪除站點", - "siteDelete": "刪除站點", - "siteMessageRemove": "一旦移除,站點將無法訪問。與站點相關的所有目標也將被移除。", - "siteQuestionRemove": "您確定要從組織中刪除該站點嗎?", - "siteManageSites": "管理站點", + "siteConfirmDelete": "確認刪除站台", + "siteDelete": "刪除站台", + "siteMessageRemove": "一旦移除,站台將無法存取。與站台相關的所有目標也將被移除。", + "siteQuestionRemove": "您確定要從組織中刪除該站台嗎?", + "siteManageSites": "管理站台", "siteDescription": "允許通過安全隧道連接到您的網路", "sitesBannerTitle": "連接任何網路", - "sitesBannerDescription": "站點是與遠端網路的連接,使 Pangolin 能夠為任何地方的使用者提供對公共或私有資源的存取。在任何可以執行二進位檔案或容器的地方安裝站點網路連接器 (Newt) 以建立連接。", - "sitesBannerButtonText": "安裝站點", - "siteCreate": "創建站點", - "siteCreateDescription2": "按照下面的步驟創建和連接一個新站點", - "siteCreateDescription": "創建一個新站點開始連接您的資源", + "sitesBannerDescription": "站台是與遠端網路的連接,使 Pangolin 能夠為任何地方的使用者提供對公共或私有資源的存取。在任何可以執行二進位檔案或容器的地方安裝站台網路連接器 (Newt) 以建立連接。", + "sitesBannerButtonText": "安裝站台", + "siteCreate": "建立站台", + "siteCreateDescription2": "按照下面的步驟建立和連接一個新站台", + "siteCreateDescription": "建立一個新站台開始連接您的資源", "close": "關閉", - "siteErrorCreate": "創建站點出錯", - "siteErrorCreateKeyPair": "找不到金鑰對或站點預設值", - "siteErrorCreateDefaults": "未找到站點預設值", + "siteErrorCreate": "建立站台出錯", + "siteErrorCreateKeyPair": "找不到金鑰對或站台預設值", + "siteErrorCreateDefaults": "未找到站台預設值", "method": "方法", "siteMethodDescription": "這是您將如何顯示連接。", "siteLearnNewt": "學習如何在您的系統上安裝 Newt", - "siteSeeConfigOnce": "您只能看到一次配置。", - "siteLoadWGConfig": "正在載入 WireGuard 配置...", - "siteDocker": "擴展 Docker 部署詳細資訊", + "siteSeeConfigOnce": "您只能看到一次設定。", + "siteLoadWGConfig": "正在載入 WireGuard 設定...", + "siteDocker": "擴充 Docker 部署詳細資訊", "toggle": "切換", "dockerCompose": "Docker Compose", "dockerRun": "Docker Run", - "siteLearnLocal": "本地站點不需要隧道連接,點擊了解更多", - "siteConfirmCopy": "我已經複製了配置資訊", - "searchSitesProgress": "搜索站點...", - "siteAdd": "添加站點", + "siteLearnLocal": "本地站台不需要隧道連接,點選了解更多", + "siteConfirmCopy": "我已經複製了設定資訊", + "searchSitesProgress": "搜尋站台...", + "siteAdd": "新增站台", "siteInstallNewt": "安裝 Newt", - "siteInstallNewtDescription": "在您的系統中運行 Newt", - "WgConfiguration": "WireGuard 配置", - "WgConfigurationDescription": "使用以下配置連接到您的網路", + "siteInstallNewtDescription": "在您的系統中執行 Newt", + "WgConfiguration": "WireGuard 設定", + "WgConfigurationDescription": "使用以下設定連接到您的網路", "operatingSystem": "操作系統", "commands": "命令", "recommended": "推薦", - "siteNewtDescription": "為獲得最佳用戶體驗,請使用 Newt。其底層採用 WireGuard 技術,可直接通過 Pangolin 控制台,使用區域網路地址訪問您私有網路中的資源。", - "siteRunsInDocker": "在 Docker 中運行", - "siteRunsInShell": "在 macOS 、 Linux 和 Windows 的 Shell 中運行", - "siteErrorDelete": "刪除站點出錯", - "siteErrorUpdate": "更新站點失敗", - "siteErrorUpdateDescription": "更新站點時出錯。", - "siteUpdated": "站點已更新", + "siteNewtDescription": "為獲得最佳使用者體驗,請使用 Newt。其底層採用 WireGuard 技術,可直接通過 Pangolin 控制台,使用區域網路位址存取您私有網路中的資源。", + "siteRunsInDocker": "在 Docker 中執行", + "siteRunsInShell": "在 macOS 、 Linux 和 Windows 的 Shell 中執行", + "siteErrorDelete": "刪除站台出錯", + "siteErrorUpdate": "更新站台失敗", + "siteErrorUpdateDescription": "更新站台時出錯。", + "siteUpdated": "站台已更新", "siteUpdatedDescription": "網站已更新。", - "siteGeneralDescription": "配置此站點的常規設置", - "siteSettingDescription": "配置您網站上的設置", - "siteSetting": "{siteName} 設置", + "siteGeneralDescription": "設定此站台的常規設定", + "siteSettingDescription": "設定您網站上的設定", + "siteSetting": "{siteName} 設定", "siteNewtTunnel": "Newt 隧道 (推薦)", - "siteNewtTunnelDescription": "最簡單的方式來連接到您的網路。不需要任何額外設置。", + "siteNewtTunnelDescription": "最簡單的方式來連接到您的網路。不需要任何額外設定。", "siteWg": "基本 WireGuard", - "siteWgDescription": "使用任何 WireGuard 用戶端來建立隧道。需要手動配置 NAT。", - "siteWgDescriptionSaas": "使用任何 WireGuard 用戶端建立隧道。需要手動配置 NAT。僅適用於自託管節點。", + "siteWgDescription": "使用任何 WireGuard 用戶端來建立隧道。需要手動設定 NAT。", + "siteWgDescriptionSaas": "使用任何 WireGuard 用戶端建立隧道。需要手動設定 NAT。僅適用於自託管節點。", "siteLocalDescription": "僅限本地資源。不需要隧道。", "siteLocalDescriptionSaas": "僅本地資源。沒有隧道。僅在遠程節點上可用。", - "siteSeeAll": "查看所有站點", + "siteSeeAll": "查看所有站台", "siteTunnelDescription": "確定如何連接到您的網站", "siteNewtCredentials": "Newt 憑證", - "siteNewtCredentialsDescription": "這是 Newt 伺服器的身份驗證憑證", + "siteNewtCredentialsDescription": "這是 Newt 伺服器的身分驗證憑證", "remoteNodeCredentialsDescription": "這是遠端節點與伺服器進行驗證的方式", "siteCredentialsSave": "保存您的憑證", "siteCredentialsSaveDescription": "您只能看到一次。請確保將其複製並保存到一個安全的地方。", - "siteInfo": "站點資訊", + "siteInfo": "站台資訊", "status": "狀態", "shareTitle": "管理共享連結", - "shareDescription": "創建可共享的連結,允許暫時或永久訪問您的資源", - "shareSearch": "搜索共享連結...", - "shareCreate": "創建共享連結", + "shareDescription": "建立可共享的連結,允許暫時或永久存取您的資源", + "shareSearch": "搜尋共享連結...", + "shareCreate": "建立共享連結", "shareErrorDelete": "刪除連結失敗", "shareErrorDeleteMessage": "刪除連結時出錯", "shareDeleted": "連結已刪除", "shareDeletedDescription": "連結已刪除", - "shareTokenDescription": "您的訪問令牌可以透過兩種方式傳遞:作為查詢參數或請求頭。 每次驗證訪問請求都必須從用戶端傳遞。", - "accessToken": "訪問令牌", + "shareTokenDescription": "您的存取權杖可以透過兩種方式傳遞:作為查詢參數或請求頭。 每次驗證存取請求都必須從用戶端傳遞。", + "accessToken": "存取權杖", "usageExamples": "用法範例", - "tokenId": "令牌 ID", + "tokenId": "權杖 ID", "requestHeades": "請求頭", "queryParameter": "查詢參數", "importantNote": "重要提示", "shareImportantDescription": "出於安全考慮,建議盡可能在使用請求頭傳遞參數,因為查詢參數可能會被瀏覽器歷史記錄或伺服器日誌記錄。", - "token": "令牌", - "shareTokenSecurety": "請妥善保管您的訪問令牌,不要將其暴露在公開訪問的區域或用戶端代碼中。", - "shareErrorFetchResource": "獲取資源失敗", - "shareErrorFetchResourceDescription": "獲取資源時出錯", - "shareErrorCreate": "無法創建共享連結", - "shareErrorCreateDescription": "創建共享連結時出錯", - "shareCreateDescription": "任何具有此連結的人都可以訪問資源", + "token": "權杖", + "shareTokenSecurety": "請妥善保管您的存取權杖,不要將其暴露在公開存取的區域或用戶端代碼中。", + "shareErrorFetchResource": "取得資源失敗", + "shareErrorFetchResourceDescription": "取得資源時出錯", + "shareErrorCreate": "無法建立共享連結", + "shareErrorCreateDescription": "建立共享連結時出錯", + "shareCreateDescription": "任何具有此連結的人都可以存取資源", "shareTitleOptional": "標題 (可選)", "expireIn": "過期時間", "neverExpire": "永不過期", - "shareExpireDescription": "過期時間是連結可以使用並提供對資源的訪問時間。 此時間後,連結將不再工作,使用此連結的用戶將失去對資源的訪問。", + "shareExpireDescription": "過期時間是連結可以使用並提供對資源的存取時間。 此時間後,連結將不再工作,使用此連結的使用者將失去對資源的存取。", "shareSeeOnce": "您只能看到一次此連結。請確保複製它。", - "shareAccessHint": "任何具有此連結的人都可以訪問該資源。小心地分享它。", - "shareTokenUsage": "查看訪問令牌使用情況", - "createLink": "創建連結", + "shareAccessHint": "任何具有此連結的人都可以存取該資源。小心地分享它。", + "shareTokenUsage": "查看存取權杖使用情況", + "createLink": "建立連結", "resourcesNotFound": "找不到資源", - "resourceSearch": "搜索資源", - "openMenu": "打開菜單", + "resourceSearch": "搜尋資源", + "openMenu": "打開選單", "resource": "資源", "title": "標題", - "created": "已創建", + "created": "已建立", "expires": "過期時間", "never": "永不過期", "shareErrorSelectResource": "請選擇一個資源", "proxyResourceTitle": "管理公開資源", "proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源", "publicResourcesBannerTitle": "基於網頁的公開存取", - "publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。", + "publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身分和情境感知的存取策略。", "clientResourceTitle": "管理私有資源", "clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源", "privateResourcesBannerTitle": "零信任私有存取", "privateResourcesBannerDescription": "私有資源使用零信任安全性,確保使用者和機器只能存取您明確授權的資源。連接使用者裝置或機器客戶端以透過安全的虛擬私人網路存取這些資源。", - "resourcesSearch": "搜索資源...", - "resourceAdd": "添加資源", + "resourcesSearch": "搜尋資源...", + "resourceAdd": "新增資源", "resourceErrorDelte": "刪除資源時出錯", "authentication": "認證", "protected": "受到保護", "notProtected": "未受到保護", - "resourceMessageRemove": "一旦刪除,資源將不再可訪問。與該資源相關的所有目標也將被刪除。", + "resourceMessageRemove": "一旦刪除,資源將不再可存取。與該資源相關的所有目標也將被刪除。", "resourceQuestionRemove": "您確定要從組織中刪除資源嗎?", "resourceHTTP": "HTTPS 資源", "resourceHTTPDescription": "使用子域或根域名通過 HTTPS 向您的應用程式提出代理請求。", "resourceRaw": "TCP/UDP 資源", - "resourceRawDescription": "使用 TCP/UDP 使用埠號向您的應用提出代理請求。", - "resourceCreate": "創建資源", - "resourceCreateDescription": "按照下面的步驟創建新資源", + "resourceRawDescription": "使用 TCP/UDP 使用埠號向您的應用程式提出代理請求。", + "resourceCreate": "建立資源", + "resourceCreateDescription": "按照下面的步驟建立新資源", "resourceSeeAll": "查看所有資源", - "resourceInfo": "資源資訊", "resourceNameDescription": "這是資源的顯示名稱。", - "siteSelect": "選擇站點", - "siteSearch": "搜索站點", - "siteNotFound": "未找到站點。", + "siteSelect": "選擇站台", + "siteSearch": "搜尋站台", + "siteNotFound": "未找到站台。", "selectCountry": "選擇國家", - "searchCountries": "搜索國家...", + "searchCountries": "搜尋國家...", "noCountryFound": "找不到國家。", - "siteSelectionDescription": "此站點將為目標提供連接。", + "siteSelectionDescription": "此站台將為目標提供連接。", "resourceType": "資源類型", - "resourceTypeDescription": "確定如何訪問您的資源", - "resourceHTTPSSettings": "HTTPS 設置", - "resourceHTTPSSettingsDescription": "配置如何通過 HTTPS 訪問您的資源", + "resourceTypeDescription": "確定如何存取您的資源", + "resourceHTTPSSettings": "HTTPS 設定", + "resourceHTTPSSettingsDescription": "設定如何通過 HTTPS 存取您的資源", "domainType": "域類型", "subdomain": "子域名", "baseDomain": "根域名", - "subdomnainDescription": "您的資源可以訪問的子域名。", - "resourceRawSettings": "TCP/UDP 設置", + "subdomnainDescription": "您的資源可以存取的子域名。", + "resourceRawSettings": "TCP/UDP 設定", "resourceRawSettingsDescription": "設定如何透過 TCP/UDP 存取資源", "protocol": "協議", "protocolSelect": "選擇協議", "resourcePortNumber": "埠號", "resourcePortNumberDescription": "代理請求的外部埠號。", "cancel": "取消", - "resourceConfig": "配置片段", - "resourceConfigDescription": "複製並黏貼這些配置片段以設置您的 TCP/UDP 資源", - "resourceAddEntrypoints": "Traefik: 添加入口點", + "resourceConfig": "設定片段", + "resourceConfigDescription": "複製並貼上這些設定片段以設定您的 TCP/UDP 資源", + "resourceAddEntrypoints": "Traefik: 新增入口點", "resourceExposePorts": "Gerbil:在 Docker Compose 中顯示埠", - "resourceLearnRaw": "學習如何配置 TCP/UDP 資源", + "resourceLearnRaw": "學習如何設定 TCP/UDP 資源", "resourceBack": "返回資源", "resourceGoTo": "轉到資源", "resourceDelete": "刪除資源", @@ -210,57 +209,57 @@ "enabled": "已啟用", "disabled": "已禁用", "general": "概覽", - "generalSettings": "常規設置", + "generalSettings": "常規設定", "proxy": "代理伺服器", - "internal": "內部設置", + "internal": "內部設定", "rules": "規則", - "resourceSettingDescription": "配置您資源上的設置", - "resourceSetting": "{resourceName} 設置", + "resourceSettingDescription": "設定您資源上的設定", + "resourceSetting": "{resourceName} 設定", "alwaysAllow": "一律允許", "alwaysDeny": "一律拒絕", "passToAuth": "傳遞至認證", - "orgSettingsDescription": "配置您組織的一般設定", - "orgGeneralSettings": "組織設置", - "orgGeneralSettingsDescription": "管理您的機構詳細資訊和配置", - "saveGeneralSettings": "保存常規設置", - "saveSettings": "保存設置", + "orgSettingsDescription": "設定您組織的一般設定", + "orgGeneralSettings": "組織設定", + "orgGeneralSettingsDescription": "管理您的機構詳細資訊和設定", + "saveGeneralSettings": "保存常規設定", + "saveSettings": "保存設定", "orgDangerZone": "危險區域", "orgDangerZoneDescription": "一旦刪除該組織,將無法恢復,請務必確認。", "orgDelete": "刪除組織", "orgDeleteConfirm": "確認刪除組織", - "orgMessageRemove": "此操作不可逆,這將刪除所有相關數據。", + "orgMessageRemove": "此操作不可逆,這將刪除所有相關資料。", "orgMessageConfirm": "要確認,請在下面輸入組織名稱。", "orgQuestionRemove": "您確定要刪除組織嗎?", "orgUpdated": "組織已更新", "orgUpdatedDescription": "組織已更新。", "orgErrorUpdate": "更新組織失敗", "orgErrorUpdateMessage": "更新組織時出錯。", - "orgErrorFetch": "獲取組織失敗", + "orgErrorFetch": "取得組織失敗", "orgErrorFetchMessage": "列出您的組織時出錯", "orgErrorDelete": "刪除組織失敗", "orgErrorDeleteMessage": "刪除組織時出錯。", "orgDeleted": "組織已刪除", - "orgDeletedMessage": "組織及其數據已被刪除。", + "orgDeletedMessage": "組織及其資料已被刪除。", "orgMissing": "缺少組織 ID", "orgMissingMessage": "沒有組織ID,無法重新生成邀請。", - "accessUsersManage": "管理用戶", - "accessUsersDescription": "邀請用戶並位他們添加角色以管理訪問您的組織", - "accessUsersSearch": "搜索用戶...", - "accessUserCreate": "創建用戶", - "accessUserRemove": "刪除用戶", + "accessUsersManage": "管理使用者", + "accessUsersDescription": "邀請使用者並位他們新增角色以管理存取您的組織", + "accessUsersSearch": "搜尋使用者...", + "accessUserCreate": "建立使用者", + "accessUserRemove": "刪除使用者", "username": "使用者名稱", - "identityProvider": "身份提供商", + "identityProvider": "身分提供者", "role": "角色", "nameRequired": "名稱是必填項", "accessRolesManage": "管理角色", - "accessRolesDescription": "配置角色來管理訪問您的組織", - "accessRolesSearch": "搜索角色...", - "accessRolesAdd": "添加角色", + "accessRolesDescription": "設定角色來管理存取您的組織", + "accessRolesSearch": "搜尋角色...", + "accessRolesAdd": "新增角色", "accessRoleDelete": "刪除角色", "description": "描述", "inviteTitle": "打開邀請", - "inviteDescription": "管理您給其他用戶的邀請", - "inviteSearch": "搜索邀請...", + "inviteDescription": "管理您給其他使用者的邀請", + "inviteSearch": "搜尋邀請...", "minutes": "分鐘", "hours": "小時", "days": "天", @@ -270,8 +269,8 @@ "day": "{count, plural, other {# 天}}", "apiKeysTitle": "API 金鑰", "apiKeysConfirmCopy2": "您必須確認您已複製 API 金鑰。", - "apiKeysErrorCreate": "創建 API 金鑰出錯", - "apiKeysErrorSetPermission": "設置權限出錯", + "apiKeysErrorCreate": "建立 API 金鑰出錯", + "apiKeysErrorSetPermission": "設定權限出錯", "apiKeysCreate": "生成 API 金鑰", "apiKeysCreateDescription": "為您的組織生成一個新的 API 金鑰", "apiKeysGeneralSettings": "權限", @@ -285,7 +284,7 @@ "done": "完成", "apiKeysSeeAll": "查看所有 API 金鑰", "apiKeysPermissionsErrorLoadingActions": "載入 API 金鑰操作時出錯", - "apiKeysPermissionsErrorUpdate": "設置權限出錯", + "apiKeysPermissionsErrorUpdate": "設定權限出錯", "apiKeysPermissionsUpdated": "權限已更新", "apiKeysPermissionsUpdatedDescription": "權限已更新。", "apiKeysPermissionsGeneralSettings": "權限", @@ -293,7 +292,7 @@ "apiKeysPermissionsSave": "保存權限", "apiKeysPermissionsTitle": "權限", "apiKeys": "API 金鑰", - "searchApiKeys": "搜索 API 金鑰...", + "searchApiKeys": "搜尋 API 金鑰...", "apiKeysAdd": "生成 API 金鑰", "apiKeysErrorDelete": "刪除 API 金鑰出錯", "apiKeysErrorDeleteMessage": "刪除 API 金鑰出錯", @@ -302,24 +301,24 @@ "apiKeysDeleteConfirm": "確認刪除 API 金鑰", "apiKeysDelete": "刪除 API 金鑰", "apiKeysManage": "管理 API 金鑰", - "apiKeysDescription": "API 金鑰用於認證集成 API", - "apiKeysSettings": "{apiKeyName} 設置", - "userTitle": "管理所有用戶", - "userDescription": "查看和管理系統中的所有用戶", - "userAbount": "關於用戶管理", - "userAbountDescription": "此表格顯示系統中所有根用戶對象。每個用戶可能屬於多個組織。 從組織中刪除用戶不會刪除其根用戶對象 - 他們將保留在系統中。 要從系統中完全刪除用戶,您必須使用此表格中的刪除操作刪除其根用戶對象。", - "userServer": "伺服器用戶", - "userSearch": "搜索伺服器用戶...", - "userErrorDelete": "刪除用戶時出錯", - "userDeleteConfirm": "確認刪除用戶", - "userDeleteServer": "從伺服器刪除用戶", - "userMessageRemove": "該用戶將被從所有組織中刪除並完全從伺服器中刪除。", - "userQuestionRemove": "您確定要從伺服器永久刪除用戶嗎?", + "apiKeysDescription": "API 金鑰用於認證整合 API", + "apiKeysSettings": "{apiKeyName} 設定", + "userTitle": "管理所有使用者", + "userDescription": "查看和管理系統中的所有使用者", + "userAbount": "關於使用者管理", + "userAbountDescription": "此表格顯示系統中所有根使用者物件。每個使用者可能屬於多個組織。 從組織中刪除使用者不會刪除其根使用者物件 - 他們將保留在系統中。 要從系統中完全刪除使用者,您必須使用此表格中的刪除操作刪除其根使用者物件。", + "userServer": "伺服器使用者", + "userSearch": "搜尋伺服器使用者...", + "userErrorDelete": "刪除使用者時出錯", + "userDeleteConfirm": "確認刪除使用者", + "userDeleteServer": "從伺服器刪除使用者", + "userMessageRemove": "該使用者將被從所有組織中刪除並完全從伺服器中刪除。", + "userQuestionRemove": "您確定要從伺服器永久刪除使用者嗎?", "licenseKey": "許可證金鑰", "valid": "有效", - "numberOfSites": "站點數量", - "licenseKeySearch": "搜索許可證金鑰...", - "licenseKeyAdd": "添加許可證金鑰", + "numberOfSites": "站台數量", + "licenseKeySearch": "搜尋許可證金鑰...", + "licenseKeyAdd": "新增許可證金鑰", "type": "類型", "licenseKeyRequired": "需要許可證金鑰", "licenseTermsAgree": "您必須同意許可條款", @@ -336,8 +335,8 @@ "licenseBannerDescription": "Unlock enterprise features for your self-hosted Pangolin instance. Purchase a license key to activate premium capabilities, then add it below.", "licenseBannerGetLicense": "Get a License", "licenseBannerViewDocs": "View Documentation", - "communityEdition": "社區版", - "licenseAboutDescription": "這是針對商業環境中使用Pangolin的商業和企業用戶。 如果您正在使用 Pangolin 供個人使用,您可以忽略此部分。", + "communityEdition": "社群版", + "licenseAboutDescription": "這是針對商業環境中使用Pangolin的商業和企業使用者。 如果您正在使用 Pangolin 供個人使用,您可以忽略此部分。", "licenseKeyActivated": "授權金鑰已啟用", "licenseKeyActivatedDescription": "已成功啟用許可證金鑰。", "licenseErrorKeyRecheck": "重新檢查許可證金鑰失敗", @@ -361,95 +360,95 @@ "licensedNot": "未授權", "hostId": "主機 ID", "licenseReckeckAll": "重新檢查所有金鑰", - "licenseSiteUsage": "站點使用情況", - "licenseSiteUsageDecsription": "查看使用此許可的站點數量。", - "licenseNoSiteLimit": "使用未經許可主機的站點數量沒有限制。", + "licenseSiteUsage": "站台使用情況", + "licenseSiteUsageDecsription": "查看使用此許可的站台數量。", + "licenseNoSiteLimit": "使用未經許可主機的站台數量沒有限制。", "licensePurchase": "購買許可證", - "licensePurchaseSites": "購買更多站點", - "licenseSitesUsedMax": "使用了 {usedSites}/{maxSites} 個站點", - "licenseSitesUsed": "{count, plural, =0 {# 站點} one {# 站點} other {# 站點}}", - "licensePurchaseDescription": "請選擇您希望 {selectedMode, select, license {直接購買許可證,您可以隨時增加更多站點。} other {為現有許可證購買更多站點}}", + "licensePurchaseSites": "購買更多站台", + "licenseSitesUsedMax": "使用了 {usedSites}/{maxSites} 個站台", + "licenseSitesUsed": "{count, plural, =0 {# 站台} one {# 站台} other {# 站台}}", + "licensePurchaseDescription": "請選擇您希望 {selectedMode, select, license {直接購買許可證,您可以隨時增加更多站台。} other {為現有許可證購買更多站台}}", "licenseFee": "許可證費用", - "licensePriceSite": "每個站點的價格", + "licensePriceSite": "每個站台的價格", "total": "總計", "licenseContinuePayment": "繼續付款", "pricingPage": "定價頁面", "pricingPortal": "前往付款頁面", - "licensePricingPage": "關於最新的價格和折扣,請訪問 ", + "licensePricingPage": "關於最新的價格和折扣,請存取 ", "invite": "邀請", "inviteRegenerate": "重新生成邀請", - "inviteRegenerateDescription": "撤銷以前的邀請並創建一個新的邀請", + "inviteRegenerateDescription": "撤銷以前的邀請並建立一個新的邀請", "inviteRemove": "移除邀請", "inviteRemoveError": "刪除邀請失敗", "inviteRemoveErrorDescription": "刪除邀請時出錯。", "inviteRemoved": "邀請已刪除", - "inviteRemovedDescription": "為 {email} 創建的邀請已刪除", + "inviteRemovedDescription": "為 {email} 建立的邀請已刪除", "inviteQuestionRemove": "您確定要刪除邀請嗎?", "inviteMessageRemove": "一旦刪除,這個邀請將不再有效。", - "inviteMessageConfirm": "要確認,請在下面輸入邀請的電子郵件地址。", + "inviteMessageConfirm": "要確認,請在下面輸入邀請的電子郵件位址。", "inviteQuestionRegenerate": "您確定要重新邀請 {email} 嗎?這將會撤銷掉之前的邀請", "inviteRemoveConfirm": "確認刪除邀請", "inviteRegenerated": "重新生成邀請", - "inviteSent": "邀請郵件已成功發送至 {email}。", - "inviteSentEmail": "發送電子郵件通知給用戶", - "inviteGenerate": "已為 {email} 創建新的邀請。", + "inviteSent": "邀請郵件已成功傳送至 {email}。", + "inviteSentEmail": "傳送電子郵件通知給使用者", + "inviteGenerate": "已為 {email} 建立新的邀請。", "inviteDuplicateError": "重複的邀請", - "inviteDuplicateErrorDescription": "此用戶的邀請已存在。", + "inviteDuplicateErrorDescription": "此使用者的邀請已存在。", "inviteRateLimitError": "超出速率限制", "inviteRateLimitErrorDescription": "您超過了每小時3次再生的限制。請稍後再試。", "inviteRegenerateError": "重新生成邀請失敗", "inviteRegenerateErrorDescription": "重新生成邀請時出錯。", "inviteValidityPeriod": "有效期", "inviteValidityPeriodSelect": "選擇有效期", - "inviteRegenerateMessage": "邀請已重新生成。用戶必須訪問下面的連結才能接受邀請。", + "inviteRegenerateMessage": "邀請已重新生成。使用者必須存取下面的連結才能接受邀請。", "inviteRegenerateButton": "重新生成", "expiresAt": "到期於", "accessRoleUnknown": "未知角色", "placeholder": "占位符", - "userErrorOrgRemove": "刪除用戶失敗", - "userErrorOrgRemoveDescription": "刪除用戶時出錯。", - "userOrgRemoved": "用戶已刪除", + "userErrorOrgRemove": "刪除使用者失敗", + "userErrorOrgRemoveDescription": "刪除使用者時出錯。", + "userOrgRemoved": "使用者已刪除", "userOrgRemovedDescription": "已將 {email} 從組織中移除。", - "userQuestionOrgRemove": "您確定要從組織中刪除此用戶嗎?", - "userMessageOrgRemove": "一旦刪除,這個用戶將不再能夠訪問組織。 你總是可以稍後重新邀請他們,但他們需要再次接受邀請。", - "userRemoveOrgConfirm": "確認刪除用戶", - "userRemoveOrg": "從組織中刪除用戶", - "users": "用戶", + "userQuestionOrgRemove": "您確定要從組織中刪除此使用者嗎?", + "userMessageOrgRemove": "一旦刪除,這個使用者將不再能夠存取組織。 你總是可以稍後重新邀請他們,但他們需要再次接受邀請。", + "userRemoveOrgConfirm": "確認刪除使用者", + "userRemoveOrg": "從組織中刪除使用者", + "users": "使用者", "accessRoleMember": "成員", "accessRoleOwner": "所有者", "userConfirmed": "已確認", - "idpNameInternal": "內部設置", - "emailInvalid": "無效的電子郵件地址", + "idpNameInternal": "內部設定", + "emailInvalid": "無效的電子郵件位址", "inviteValidityDuration": "請選擇持續時間", "accessRoleSelectPlease": "請選擇一個角色", "usernameRequired": "必須輸入使用者名稱", - "idpSelectPlease": "請選擇身份提供商", - "idpGenericOidc": "通用的 OAuth2/OIDC 提供商。", - "accessRoleErrorFetch": "獲取角色失敗", - "accessRoleErrorFetchDescription": "獲取角色時出錯", - "idpErrorFetch": "獲取身份提供者失敗", - "idpErrorFetchDescription": "獲取身份提供者時出錯", - "userErrorExists": "用戶已存在", - "userErrorExistsDescription": "此用戶已經是組織成員。", - "inviteError": "邀請用戶失敗", - "inviteErrorDescription": "邀請用戶時出錯", - "userInvited": "用戶邀請", - "userInvitedDescription": "用戶已被成功邀請。", - "userErrorCreate": "創建用戶失敗", - "userErrorCreateDescription": "創建用戶時出錯", - "userCreated": "用戶已創建", - "userCreatedDescription": "用戶已成功創建。", - "userTypeInternal": "內部用戶", - "userTypeInternalDescription": "邀請用戶直接加入您的組織。", - "userTypeExternal": "外部用戶", - "userTypeExternalDescription": "創建一個具有外部身份提供商的用戶。", - "accessUserCreateDescription": "按照下面的步驟創建一個新用戶", - "userSeeAll": "查看所有用戶", - "userTypeTitle": "用戶類型", - "userTypeDescription": "確定如何創建用戶", - "userSettings": "用戶資訊", - "userSettingsDescription": "輸入新用戶的詳細資訊", - "inviteEmailSent": "發送邀請郵件給用戶", + "idpSelectPlease": "請選擇身分提供者", + "idpGenericOidc": "通用的 OAuth2/OIDC 提供者。", + "accessRoleErrorFetch": "取得角色失敗", + "accessRoleErrorFetchDescription": "取得角色時出錯", + "idpErrorFetch": "取得身分提供者失敗", + "idpErrorFetchDescription": "取得身分提供者時出錯", + "userErrorExists": "使用者已存在", + "userErrorExistsDescription": "此使用者已經是組織成員。", + "inviteError": "邀請使用者失敗", + "inviteErrorDescription": "邀請使用者時出錯", + "userInvited": "使用者邀請", + "userInvitedDescription": "使用者已被成功邀請。", + "userErrorCreate": "建立使用者失敗", + "userErrorCreateDescription": "建立使用者時出錯", + "userCreated": "使用者已建立", + "userCreatedDescription": "使用者已成功建立。", + "userTypeInternal": "內部使用者", + "userTypeInternalDescription": "邀請使用者直接加入您的組織。", + "userTypeExternal": "外部使用者", + "userTypeExternalDescription": "建立一個具有外部身分提供者的使用者。", + "accessUserCreateDescription": "按照下面的步驟建立一個新使用者", + "userSeeAll": "查看所有使用者", + "userTypeTitle": "使用者類型", + "userTypeDescription": "確定如何建立使用者", + "userSettings": "使用者資訊", + "userSettingsDescription": "輸入新使用者的詳細資訊", + "inviteEmailSent": "傳送邀請郵件給使用者", "inviteValid": "有效", "selectDuration": "選擇持續時間", "selectResource": "選擇資源", @@ -463,103 +462,103 @@ "allowed": "已允許", "topCountries": "熱門國家/地區", "accessRoleSelect": "選擇角色", - "inviteEmailSentDescription": "一封電子郵件已經發送給用戶,帶有下面的訪問連結。他們必須訪問該連結才能接受邀請。", - "inviteSentDescription": "用戶已被邀請。他們必須訪問下面的連結才能接受邀請。", + "inviteEmailSentDescription": "一封電子郵件已經傳送給使用者,帶有下面的存取連結。他們必須存取該連結才能接受邀請。", + "inviteSentDescription": "使用者已被邀請。他們必須存取下面的連結才能接受邀請。", "inviteExpiresIn": "邀請將在{days, plural, other {# 天}}後過期。", - "idpTitle": "身份提供商", - "idpSelect": "為外部用戶選擇身份提供商", - "idpNotConfigured": "沒有配置身份提供者。請在創建外部用戶之前配置身份提供者。", - "usernameUniq": "這必須匹配所選身份提供者中存在的唯一使用者名稱。", + "idpTitle": "身分提供者", + "idpSelect": "為外部使用者選擇身分提供者", + "idpNotConfigured": "沒有設定身分提供者。請在建立外部使用者之前設定身分提供者。", + "usernameUniq": "這必須匹配所選身分提供者中存在的唯一使用者名稱。", "emailOptional": "電子郵件(可選)", "nameOptional": "名稱(可選)", - "accessControls": "訪問控制", - "userDescription2": "管理此用戶的設置", - "accessRoleErrorAdd": "添加用戶到角色失敗", - "accessRoleErrorAddDescription": "添加用戶到角色時出錯。", - "userSaved": "用戶已保存", - "userSavedDescription": "用戶已更新。", - "autoProvisioned": "自動設置", - "autoProvisionedDescription": "允許此用戶由身份提供商自動管理", - "accessControlsDescription": "管理此用戶在組織中可以訪問和做什麼", - "accessControlsSubmit": "保存訪問控制", + "accessControls": "存取控制", + "userDescription2": "管理此使用者的設定", + "accessRoleErrorAdd": "新增使用者到角色失敗", + "accessRoleErrorAddDescription": "新增使用者到角色時出錯。", + "userSaved": "使用者已保存", + "userSavedDescription": "使用者已更新。", + "autoProvisioned": "自動設定", + "autoProvisionedDescription": "允許此使用者由身分提供者自動管理", + "accessControlsDescription": "管理此使用者在組織中可以存取和做什麼", + "accessControlsSubmit": "保存存取控制", "roles": "角色", - "accessUsersRoles": "管理用戶和角色", - "accessUsersRolesDescription": "邀請用戶並將他們添加到角色以管理訪問您的組織", + "accessUsersRoles": "管理使用者和角色", + "accessUsersRolesDescription": "邀請使用者並將他們新增到角色以管理存取您的組織", "key": "關鍵字", - "createdAt": "創建於", + "createdAt": "建立於", "proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。", "proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。", "proxyEnableSSL": "啟用 TLS", "proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。", "target": "目標", - "configureTarget": "配置目標", - "targetErrorFetch": "獲取目標失敗", - "targetErrorFetchDescription": "獲取目標時出錯", - "siteErrorFetch": "獲取資源失敗", - "siteErrorFetchDescription": "獲取資源時出錯", + "configureTarget": "設定目標", + "targetErrorFetch": "取得目標失敗", + "targetErrorFetchDescription": "取得目標時出錯", + "siteErrorFetch": "取得資源失敗", + "siteErrorFetchDescription": "取得資源時出錯", "targetErrorDuplicate": "重複的目標", - "targetErrorDuplicateDescription": "具有這些設置的目標已存在", + "targetErrorDuplicateDescription": "具有這些設定的目標已存在", "targetWireGuardErrorInvalidIp": "無效的目標IP", - "targetWireGuardErrorInvalidIpDescription": "目標IP必須在站點子網內", + "targetWireGuardErrorInvalidIpDescription": "目標IP必須在站台子網內", "targetsUpdated": "目標已更新", - "targetsUpdatedDescription": "目標和設置更新成功", + "targetsUpdatedDescription": "目標和設定更新成功", "targetsErrorUpdate": "更新目標失敗", "targetsErrorUpdateDescription": "更新目標時出錯", - "targetTlsUpdate": "TLS 設置已更新", - "targetTlsUpdateDescription": "您的 TLS 設置已成功更新", - "targetErrorTlsUpdate": "更新 TLS 設置失敗", - "targetErrorTlsUpdateDescription": "更新 TLS 設置時出錯", - "proxyUpdated": "代理設置已更新", - "proxyUpdatedDescription": "您的代理設置已成功更新", - "proxyErrorUpdate": "更新代理設置失敗", - "proxyErrorUpdateDescription": "更新代理設置時出錯", + "targetTlsUpdate": "TLS 設定已更新", + "targetTlsUpdateDescription": "您的 TLS 設定已成功更新", + "targetErrorTlsUpdate": "更新 TLS 設定失敗", + "targetErrorTlsUpdateDescription": "更新 TLS 設定時出錯", + "proxyUpdated": "代理設定已更新", + "proxyUpdatedDescription": "您的代理設定已成功更新", + "proxyErrorUpdate": "更新代理設定失敗", + "proxyErrorUpdateDescription": "更新代理設定時出錯", "targetAddr": "IP / 域名", "targetPort": "埠", "targetProtocol": "協議", - "targetTlsSettings": "安全連接配置", - "targetTlsSettingsDescription": "配置資源的 SSL/TLS 設置", - "targetTlsSettingsAdvanced": "高級TLS設置", + "targetTlsSettings": "安全連接設定", + "targetTlsSettingsDescription": "設定資源的 SSL/TLS 設定", + "targetTlsSettingsAdvanced": "高級TLS設定", "targetTlsSni": "TLS 伺服器名稱", "targetTlsSniDescription": "SNI使用的 TLS 伺服器名稱。留空使用預設值。", - "targetTlsSubmit": "保存設置", - "targets": "目標配置", - "targetsDescription": "設置目標來路由流量到您的後端服務", + "targetTlsSubmit": "保存設定", + "targets": "目標設定", + "targetsDescription": "設定目標來路由流量到您的後端服務", "targetStickySessions": "啟用置頂會話", "targetStickySessionsDescription": "將連接保持在同一個後端目標的整個會話中。", "methodSelect": "選擇方法", - "targetSubmit": "添加目標", - "targetNoOne": "此資源沒有任何目標。添加目標來配置向您後端發送請求的位置。", - "targetNoOneDescription": "在上面添加多個目標將啟用負載平衡。", + "targetSubmit": "新增目標", + "targetNoOne": "此資源沒有任何目標。新增目標來設定向您後端傳送請求的位置。", + "targetNoOneDescription": "在上面新增多個目標將啟用負載平衡。", "targetsSubmit": "保存目標", - "addTarget": "添加目標", - "targetErrorInvalidIp": "無效的 IP 地址", + "addTarget": "新增目標", + "targetErrorInvalidIp": "無效的 IP 位址", "targetErrorInvalidIpDescription": "請輸入有效的IP位址或主機名", "targetErrorInvalidPort": "無效的埠", "targetErrorInvalidPortDescription": "請輸入有效的埠號", - "targetErrorNoSite": "沒有選擇站點", - "targetErrorNoSiteDescription": "請選擇目標站點", - "targetCreated": "目標已創建", - "targetCreatedDescription": "目標已成功創建", - "targetErrorCreate": "創建目標失敗", - "targetErrorCreateDescription": "創建目標時出錯", + "targetErrorNoSite": "沒有選擇站台", + "targetErrorNoSiteDescription": "請選擇目標站台", + "targetCreated": "目標已建立", + "targetCreatedDescription": "目標已成功建立", + "targetErrorCreate": "建立目標失敗", + "targetErrorCreateDescription": "建立目標時出錯", "tlsServerName": "TLS 伺服器名稱", "tlsServerNameDescription": "用於 SNI 的 TLS 伺服器名稱", "save": "保存", - "proxyAdditional": "附加代理設置", - "proxyAdditionalDescription": "配置你的資源如何處理代理設置", + "proxyAdditional": "附加代理設定", + "proxyAdditionalDescription": "設定你的資源如何處理代理設定", "proxyCustomHeader": "自訂主機 Header", - "proxyCustomHeaderDescription": "代理請求時設置的 Header。留空則使用預設值。", - "proxyAdditionalSubmit": "保存代理設置", + "proxyCustomHeaderDescription": "代理請求時設定的 Header。留空則使用預設值。", + "proxyAdditionalSubmit": "保存代理設定", "subnetMaskErrorInvalid": "子網掩碼無效。必須在 0 和 32 之間。", - "ipAddressErrorInvalidFormat": "無效的 IP 地址格式", - "ipAddressErrorInvalidOctet": "無效的 IP 地址", + "ipAddressErrorInvalidFormat": "無效的 IP 位址格式", + "ipAddressErrorInvalidOctet": "無效的 IP 位址", "path": "路徑", "matchPath": "匹配路徑", "ipAddressRange": "IP 範圍", - "rulesErrorFetch": "獲取規則失敗", - "rulesErrorFetchDescription": "獲取規則時出錯", + "rulesErrorFetch": "取得規則失敗", + "rulesErrorFetchDescription": "取得規則時出錯", "rulesErrorDuplicate": "複製規則", - "rulesErrorDuplicateDescription": "帶有這些設置的規則已存在", + "rulesErrorDuplicateDescription": "帶有這些設定的規則已存在", "rulesErrorInvalidIpAddressRange": "無效的 CIDR", "rulesErrorInvalidIpAddressRangeDescription": "請輸入一個有效的 CIDR 值", "rulesErrorInvalidUrl": "無效的 URL 路徑", @@ -570,7 +569,7 @@ "rulesErrorUpdateDescription": "更新規則時出錯", "rulesUpdated": "啟用規則", "rulesUpdatedDescription": "規則已更新", - "rulesMatchIpAddressRangeDescription": "以 CIDR 格式輸入地址(如:103.21.244.0/22)", + "rulesMatchIpAddressRangeDescription": "以 CIDR 格式輸入位址(如:103.21.244.0/22)", "rulesMatchIpAddress": "輸入IP位址(例如,103.21.244.12)", "rulesMatchUrl": "輸入一個 URL 路徑或模式(例如/api/v1/todos 或 /api/v1/*)", "rulesErrorInvalidPriority": "無效的優先度", @@ -586,130 +585,130 @@ "rulesMatchType": "匹配類型", "value": "值", "rulesAbout": "關於規則", - "rulesAboutDescription": "規則使您能夠依據特定條件控制資源訪問權限。您可以創建基於 IP 地址或 URL 路徑的規則,以允許或拒絕訪問。", + "rulesAboutDescription": "規則使您能夠依據特定條件控制資源存取權限。您可以建立基於 IP 位址或 URL 路徑的規則,以允許或拒絕存取。", "rulesActions": "行動", - "rulesActionAlwaysAllow": "總是允許:繞過所有身份驗證方法", + "rulesActionAlwaysAllow": "總是允許:繞過所有身分驗證方法", "rulesActionAlwaysDeny": "總是拒絕:阻止所有請求;無法嘗試驗證", - "rulesActionPassToAuth": "傳遞至認證:允許嘗試身份驗證方法", + "rulesActionPassToAuth": "傳遞至認證:允許嘗試身分驗證方法", "rulesMatchCriteria": "匹配條件", - "rulesMatchCriteriaIpAddress": "匹配一個指定的 IP 地址", + "rulesMatchCriteriaIpAddress": "匹配一個指定的 IP 位址", "rulesMatchCriteriaIpAddressRange": "在 CIDR 符號中匹配一系列IP位址", "rulesMatchCriteriaUrl": "匹配一個 URL 路徑或模式", "rulesEnable": "啟用規則", "rulesEnableDescription": "啟用或禁用此資源的規則評估", - "rulesResource": "資源規則配置", - "rulesResourceDescription": "配置規則來控制對您資源的訪問", - "ruleSubmit": "添加規則", - "rulesNoOne": "沒有規則。使用表單添加規則。", + "rulesResource": "資源規則設定", + "rulesResourceDescription": "設定規則來控制對您資源的存取", + "ruleSubmit": "新增規則", + "rulesNoOne": "沒有規則。使用表單新增規則。", "rulesOrder": "規則按優先順序評定。", "rulesSubmit": "保存規則", - "resourceErrorCreate": "創建資源時出錯", - "resourceErrorCreateDescription": "創建資源時出錯", - "resourceErrorCreateMessage": "創建資源時發生錯誤:", + "resourceErrorCreate": "建立資源時出錯", + "resourceErrorCreateDescription": "建立資源時出錯", + "resourceErrorCreateMessage": "建立資源時發生錯誤:", "resourceErrorCreateMessageDescription": "發生意外錯誤", - "sitesErrorFetch": "獲取站點出錯", - "sitesErrorFetchDescription": "獲取站點時出錯", - "domainsErrorFetch": "獲取域名出錯", - "domainsErrorFetchDescription": "獲取域時出錯", + "sitesErrorFetch": "取得站台出錯", + "sitesErrorFetchDescription": "取得站台時出錯", + "domainsErrorFetch": "取得域名出錯", + "domainsErrorFetchDescription": "取得域時出錯", "none": "無", "unknown": "未知", "resources": "資源", - "resourcesDescription": "資源是您私有網路中運行的應用程式的代理。您可以為私有網路中的任何 HTTP/HTTPS 或 TCP/UDP 服務創建資源。每個資源都必須連接到一個站點,以通過加密的 WireGuard 隧道實現私密且安全的連接。", + "resourcesDescription": "資源是您私有網路中執行的應用程式的代理。您可以為私有網路中的任何 HTTP/HTTPS 或 TCP/UDP 服務建立資源。每個資源都必須連接到一個站台,以通過加密的 WireGuard 隧道實現私密且安全的連接。", "resourcesWireGuardConnect": "採用 WireGuard 提供的加密安全連接", - "resourcesMultipleAuthenticationMethods": "配置多個身份驗證方法", - "resourcesUsersRolesAccess": "基於用戶和角色的訪問控制", + "resourcesMultipleAuthenticationMethods": "設定多個身分驗證方法", + "resourcesUsersRolesAccess": "基於使用者和角色的存取控制", "resourcesErrorUpdate": "切換資源失敗", "resourcesErrorUpdateDescription": "更新資源時出錯", - "access": "訪問權限", + "access": "存取權限", "shareLink": "{resource} 的分享連結", "resourceSelect": "選擇資源", "shareLinks": "分享連結", "share": "分享連結", - "shareDescription2": "創建資源共享連結。連結提供對資源的臨時或無限制訪問。 當您創建連結時,您可以配置連結的到期時間。", - "shareEasyCreate": "輕鬆創建和分享", - "shareConfigurableExpirationDuration": "可配置的過期時間", + "shareDescription2": "建立資源共享連結。連結提供對資源的臨時或無限制存取。 當您建立連結時,您可以設定連結的到期時間。", + "shareEasyCreate": "輕鬆建立和分享", + "shareConfigurableExpirationDuration": "可設定的過期時間", "shareSecureAndRevocable": "安全和可撤銷的", "nameMin": "名稱長度必須大於 {len} 字元。", "nameMax": "名稱長度必須小於 {len} 字元。", - "sitesConfirmCopy": "請確認您已經複製了配置。", + "sitesConfirmCopy": "請確認您已經複製了設定。", "unknownCommand": "未知命令", - "newtErrorFetchReleases": "無法獲取版本資訊: {err}", - "newtErrorFetchLatest": "無法獲取最新版資訊: {err}", + "newtErrorFetchReleases": "無法取得版本資訊: {err}", + "newtErrorFetchLatest": "無法取得最新版資訊: {err}", "newtEndpoint": "Newt 端點", "newtId": "Newt ID", "newtSecretKey": "Newt 私鑰", "architecture": "架構", - "sites": "站點", + "sites": "站台", "siteWgAnyClients": "使用任何 WireGuard 用戶端連接。您必須使用對等IP解決您的內部資源。", "siteWgCompatibleAllClients": "與所有 WireGuard 用戶端相容", - "siteWgManualConfigurationRequired": "需要手動配置", - "userErrorNotAdminOrOwner": "用戶不是管理員或所有者", - "pangolinSettings": "設置 - Pangolin", + "siteWgManualConfigurationRequired": "需要手動設定", + "userErrorNotAdminOrOwner": "使用者不是管理員或所有者", + "pangolinSettings": "設定 - Pangolin", "accessRoleYour": "您的角色:", "accessRoleSelect2": "選擇角色", - "accessUserSelect": "選擇一個用戶", + "accessUserSelect": "選擇一個使用者", "otpEmailEnter": "輸入電子郵件", - "otpEmailEnterDescription": "在輸入欄位輸入後按 Enter 鍵添加電子郵件。", - "otpEmailErrorInvalid": "無效的信箱地址。通配符(*)必須占據整個開頭部分。", - "otpEmailSmtpRequired": "需要先配置 SMTP", + "otpEmailEnterDescription": "在輸入欄位輸入後按 Enter 鍵新增電子郵件。", + "otpEmailErrorInvalid": "無效的信箱位址。通配符(*)必須占據整個開頭部分。", + "otpEmailSmtpRequired": "需要先設定 SMTP", "otpEmailSmtpRequiredDescription": "必須在伺服器上啟用 SMTP 才能使用一次性密碼驗證。", "otpEmailTitle": "一次性密碼", - "otpEmailTitleDescription": "資源訪問需要基於電子郵件的身份驗證", + "otpEmailTitleDescription": "資源存取需要基於電子郵件的身分驗證", "otpEmailWhitelist": "電子郵件白名單", "otpEmailWhitelistList": "白名單郵件", - "otpEmailWhitelistListDescription": "只有擁有這些電子郵件地址的用戶才能訪問此資源。 他們將被提示輸入一次性密碼發送到他們的電子郵件。 通配符 (*@example.com) 可以用來允許來自一個域名的任何電子郵件地址。", + "otpEmailWhitelistListDescription": "只有擁有這些電子郵件位址的使用者才能存取此資源。 他們將被提示輸入一次性密碼傳送到他們的電子郵件。 通配符 (*@example.com) 可以用來允許來自一個域名的任何電子郵件位址。", "otpEmailWhitelistSave": "保存白名單", - "passwordAdd": "添加密碼", + "passwordAdd": "新增密碼", "passwordRemove": "刪除密碼", - "pincodeAdd": "添加 PIN 碼", + "pincodeAdd": "新增 PIN 碼", "pincodeRemove": "移除 PIN 碼", - "resourceAuthMethods": "身份驗證方法", - "resourceAuthMethodsDescriptions": "允許透過額外的認證方法訪問資源", + "resourceAuthMethods": "身分驗證方法", + "resourceAuthMethodsDescriptions": "允許透過額外的認證方法存取資源", "resourceAuthSettingsSave": "保存成功", - "resourceAuthSettingsSaveDescription": "已保存身份驗證設置", - "resourceErrorAuthFetch": "獲取數據失敗", - "resourceErrorAuthFetchDescription": "獲取數據時出錯", + "resourceAuthSettingsSaveDescription": "已保存身分驗證設定", + "resourceErrorAuthFetch": "取得資料失敗", + "resourceErrorAuthFetchDescription": "取得資料時出錯", "resourceErrorPasswordRemove": "刪除資源密碼出錯", "resourceErrorPasswordRemoveDescription": "刪除資源密碼時出錯", - "resourceErrorPasswordSetup": "設置資源密碼出錯", - "resourceErrorPasswordSetupDescription": "設置資源密碼時出錯", + "resourceErrorPasswordSetup": "設定資源密碼出錯", + "resourceErrorPasswordSetupDescription": "設定資源密碼時出錯", "resourceErrorPincodeRemove": "刪除資源固定碼時出錯", "resourceErrorPincodeRemoveDescription": "刪除資源PIN碼時出錯", - "resourceErrorPincodeSetup": "設置資源 PIN 碼時出錯", - "resourceErrorPincodeSetupDescription": "設置資源 PIN 碼時發生錯誤", - "resourceErrorUsersRolesSave": "設置角色失敗", - "resourceErrorUsersRolesSaveDescription": "設置角色時出錯", + "resourceErrorPincodeSetup": "設定資源 PIN 碼時出錯", + "resourceErrorPincodeSetupDescription": "設定資源 PIN 碼時發生錯誤", + "resourceErrorUsersRolesSave": "設定角色失敗", + "resourceErrorUsersRolesSaveDescription": "設定角色時出錯", "resourceErrorWhitelistSave": "保存白名單失敗", "resourceErrorWhitelistSaveDescription": "保存白名單時出錯", "resourcePasswordSubmit": "啟用密碼保護", "resourcePasswordProtection": "密碼保護 {status}", "resourcePasswordRemove": "已刪除資源密碼", "resourcePasswordRemoveDescription": "已成功刪除資源密碼", - "resourcePasswordSetup": "設置資源密碼", - "resourcePasswordSetupDescription": "已成功設置資源密碼", - "resourcePasswordSetupTitle": "設置密碼", - "resourcePasswordSetupTitleDescription": "設置密碼來保護此資源", + "resourcePasswordSetup": "設定資源密碼", + "resourcePasswordSetupDescription": "已成功設定資源密碼", + "resourcePasswordSetupTitle": "設定密碼", + "resourcePasswordSetupTitleDescription": "設定密碼來保護此資源", "resourcePincode": "PIN 碼", "resourcePincodeSubmit": "啟用 PIN 碼保護", "resourcePincodeProtection": "PIN 碼保護 {status}", "resourcePincodeRemove": "資源 PIN 碼已刪除", "resourcePincodeRemoveDescription": "已成功刪除資源 PIN 碼", - "resourcePincodeSetup": "資源 PIN 碼已設置", - "resourcePincodeSetupDescription": "資源 PIN 碼已成功設置", - "resourcePincodeSetupTitle": "設置 PIN 碼", - "resourcePincodeSetupTitleDescription": "設置 PIN 碼來保護此資源", - "resourceRoleDescription": "管理員總是可以訪問此資源。", - "resourceUsersRoles": "用戶和角色", - "resourceUsersRolesDescription": "配置用戶和角色可以訪問此資源", - "resourceUsersRolesSubmit": "保存用戶和角色", + "resourcePincodeSetup": "資源 PIN 碼已設定", + "resourcePincodeSetupDescription": "資源 PIN 碼已成功設定", + "resourcePincodeSetupTitle": "設定 PIN 碼", + "resourcePincodeSetupTitleDescription": "設定 PIN 碼來保護此資源", + "resourceRoleDescription": "管理員總是可以存取此資源。", + "resourceUsersRoles": "使用者和角色", + "resourceUsersRolesDescription": "設定使用者和角色可以存取此資源", + "resourceUsersRolesSubmit": "保存使用者和角色", "resourceWhitelistSave": "保存成功", - "resourceWhitelistSaveDescription": "白名單設置已保存", + "resourceWhitelistSaveDescription": "白名單設定已保存", "ssoUse": "使用平台 SSO", - "ssoUseDescription": "對於所有啟用此功能的資源,現有用戶只需登錄一次。", + "ssoUseDescription": "對於所有啟用此功能的資源,現有使用者只需登入一次。", "proxyErrorInvalidPort": "無效的埠號", "subdomainErrorInvalid": "無效的子域", - "domainErrorFetch": "獲取域名失敗", - "domainErrorFetchDescription": "獲取域名時出錯", + "domainErrorFetch": "取得域名失敗", + "domainErrorFetchDescription": "取得域名時出錯", "resourceErrorUpdate": "更新資源失敗", "resourceErrorUpdateDescription": "更新資源時出錯", "resourceUpdated": "資源已更新", @@ -722,22 +721,22 @@ "resourceErrorToggleDescription": "更新資源時出錯", "resourceVisibilityTitle": "可見性", "resourceVisibilityTitleDescription": "完全啟用或禁用資源可見性", - "resourceGeneral": "常規設置", - "resourceGeneralDescription": "配置此資源的常規設置", + "resourceGeneral": "常規設定", + "resourceGeneralDescription": "設定此資源的常規設定", "resourceEnable": "啟用資源", "resourceTransfer": "轉移資源", - "resourceTransferDescription": "將此資源轉移到另一個站點", + "resourceTransferDescription": "將此資源轉移到另一個站台", "resourceTransferSubmit": "轉移資源", - "siteDestination": "目標站點", - "searchSites": "搜索站點", + "siteDestination": "目標站台", + "searchSites": "搜尋站台", "countries": "國家/地區", - "accessRoleCreate": "創建角色", - "accessRoleCreateDescription": "創建一個新角色來分組用戶並管理他們的權限。", - "accessRoleCreateSubmit": "創建角色", - "accessRoleCreated": "角色已創建", - "accessRoleCreatedDescription": "角色已成功創建。", - "accessRoleErrorCreate": "創建角色失敗", - "accessRoleErrorCreateDescription": "創建角色時出錯。", + "accessRoleCreate": "建立角色", + "accessRoleCreateDescription": "建立一個新角色來分組使用者並管理他們的權限。", + "accessRoleCreateSubmit": "建立角色", + "accessRoleCreated": "角色已建立", + "accessRoleCreatedDescription": "角色已成功建立。", + "accessRoleErrorCreate": "建立角色失敗", + "accessRoleErrorCreateDescription": "建立角色時出錯。", "accessRoleErrorNewRequired": "需要新角色", "accessRoleErrorRemove": "刪除角色失敗", "accessRoleErrorRemoveDescription": "刪除角色時出錯。", @@ -750,7 +749,7 @@ "accessRoleRemovedDescription": "角色已成功刪除。", "accessRoleRequiredRemove": "刪除此角色之前,請選擇一個新角色來轉移現有成員。", "manage": "管理", - "sitesNotFound": "未找到站點。", + "sitesNotFound": "未找到站台。", "pangolinServerAdmin": "伺服器管理員 - Pangolin", "licenseTierProfessional": "專業許可證", "licenseTierEnterprise": "企業許可證", @@ -758,129 +757,129 @@ "licensed": "已授權", "yes": "是", "no": "否", - "sitesAdditional": "其他站點", + "sitesAdditional": "其他站台", "licenseKeys": "許可證金鑰", - "sitestCountDecrease": "減少站點數量", - "sitestCountIncrease": "增加站點數量", - "idpManage": "管理身份提供商", - "idpManageDescription": "查看和管理系統中的身份提供商", - "idpDeletedDescription": "身份提供商刪除成功", + "sitestCountDecrease": "減少站台數量", + "sitestCountIncrease": "增加站台數量", + "idpManage": "管理身分提供者", + "idpManageDescription": "查看和管理系統中的身分提供者", + "idpDeletedDescription": "身分提供者刪除成功", "idpOidc": "OAuth2/OIDC", - "idpQuestionRemove": "您確定要永久刪除身份提供者嗎?", - "idpMessageRemove": "這將刪除身份提供者和所有相關的配置。透過此提供者進行身份驗證的用戶將無法登錄。", - "idpMessageConfirm": "要確認,請在下面輸入身份提供者的名稱。", - "idpConfirmDelete": "確認刪除身份提供商", - "idpDelete": "刪除身份提供商", - "idp": "身份提供商", - "idpSearch": "搜索身份提供者...", - "idpAdd": "添加身份提供商", + "idpQuestionRemove": "您確定要永久刪除身分提供者嗎?", + "idpMessageRemove": "這將刪除身分提供者和所有相關的設定。透過此提供者進行身分驗證的使用者將無法登入。", + "idpMessageConfirm": "要確認,請在下面輸入身分提供者的名稱。", + "idpConfirmDelete": "確認刪除身分提供者", + "idpDelete": "刪除身分提供者", + "idp": "身分提供者", + "idpSearch": "搜尋身分提供者...", + "idpAdd": "新增身分提供者", "idpClientIdRequired": "用戶端 ID 是必需的。", "idpClientSecretRequired": "用戶端金鑰是必需的。", - "idpErrorAuthUrlInvalid": "身份驗證 URL 必須是有效的 URL。", - "idpErrorTokenUrlInvalid": "令牌 URL 必須是有效的 URL。", + "idpErrorAuthUrlInvalid": "身分驗證 URL 必須是有效的 URL。", + "idpErrorTokenUrlInvalid": "權杖 URL 必須是有效的 URL。", "idpPathRequired": "標識符路徑是必需的。", "idpScopeRequired": "授權範圍是必需的。", - "idpOidcDescription": "配置 OpenID 連接身份提供商", - "idpCreatedDescription": "身份提供商創建成功", - "idpCreate": "創建身份提供商", - "idpCreateDescription": "配置用戶身份驗證的新身份提供商", - "idpSeeAll": "查看所有身份提供商", - "idpSettingsDescription": "配置身份提供者的基本資訊", - "idpDisplayName": "此身份提供商的顯示名稱", - "idpAutoProvisionUsers": "自動提供用戶", - "idpAutoProvisionUsersDescription": "如果啟用,用戶將在首次登錄時自動在系統中創建,並且能夠映射用戶到角色和組織。", + "idpOidcDescription": "設定 OpenID 連接身分提供者", + "idpCreatedDescription": "身分提供者建立成功", + "idpCreate": "建立身分提供者", + "idpCreateDescription": "設定使用者身分驗證的新身分提供者", + "idpSeeAll": "查看所有身分提供者", + "idpSettingsDescription": "設定身分提供者的基本資訊", + "idpDisplayName": "此身分提供者的顯示名稱", + "idpAutoProvisionUsers": "自動提供使用者", + "idpAutoProvisionUsersDescription": "如果啟用,使用者將在首次登入時自動在系統中建立,並且能夠映射使用者到角色和組織。", "licenseBadge": "EE", "idpType": "提供者類型", - "idpTypeDescription": "選擇您想要配置的身份提供者類型", - "idpOidcConfigure": "OAuth2/OIDC 配置", - "idpOidcConfigureDescription": "配置 OAuth2/OIDC 供應商端點和憑據", + "idpTypeDescription": "選擇您想要設定的身分提供者類型", + "idpOidcConfigure": "OAuth2/OIDC 設定", + "idpOidcConfigureDescription": "設定 OAuth2/OIDC 供應商端點和憑據", "idpClientId": "用戶端ID", - "idpClientIdDescription": "來自您身份提供商的 OAuth2 用戶端 ID", + "idpClientIdDescription": "來自您身分提供者的 OAuth2 用戶端 ID", "idpClientSecret": "用戶端金鑰", - "idpClientSecretDescription": "來自身份提供商的 OAuth2 用戶端金鑰", + "idpClientSecretDescription": "來自身分提供者的 OAuth2 用戶端金鑰", "idpAuthUrl": "授權 URL", "idpAuthUrlDescription": "OAuth2 授權端點的 URL", - "idpTokenUrl": "令牌 URL", - "idpTokenUrlDescription": "OAuth2 令牌端點的 URL", + "idpTokenUrl": "權杖 URL", + "idpTokenUrlDescription": "OAuth2 權杖端點的 URL", "idpOidcConfigureAlert": "重要提示", - "idpOidcConfigureAlertDescription": "創建身份提供方後,您需要在其設置中配置回調 URL。回調 URL 會在創建成功後提供。", - "idpToken": "令牌配置", - "idpTokenDescription": "配置如何從 ID 令牌中提取用戶資訊", + "idpOidcConfigureAlertDescription": "建立身分提供方後,您需要在其設定中設定回調 URL。回調 URL 會在建立成功後提供。", + "idpToken": "權杖設定", + "idpTokenDescription": "設定如何從 ID 權杖中提取使用者資訊", "idpJmespathAbout": "關於 JMESPath", - "idpJmespathAboutDescription": "以下路徑使用 JMESPath 語法從 ID 令牌中提取值。", + "idpJmespathAboutDescription": "以下路徑使用 JMESPath 語法從 ID 權杖中提取值。", "idpJmespathAboutDescriptionLink": "了解更多 JMESPath 資訊", "idpJmespathLabel": "標識符路徑", - "idpJmespathLabelDescription": "ID 令牌中用戶標識符的路徑", + "idpJmespathLabelDescription": "ID 權杖中使用者標識符的路徑", "idpJmespathEmailPathOptional": "信箱路徑(可選)", - "idpJmespathEmailPathOptionalDescription": "ID 令牌中用戶信箱的路徑", + "idpJmespathEmailPathOptionalDescription": "ID 權杖中使用者信箱的路徑", "idpJmespathNamePathOptional": "使用者名稱路徑(可選)", - "idpJmespathNamePathOptionalDescription": "ID 令牌中使用者名稱的路徑", + "idpJmespathNamePathOptionalDescription": "ID 權杖中使用者名稱的路徑", "idpOidcConfigureScopes": "作用域(Scopes)", "idpOidcConfigureScopesDescription": "以空格分隔的 OAuth2 請求作用域列表", - "idpSubmit": "創建身份提供商", + "idpSubmit": "建立身分提供者", "orgPolicies": "組織策略", - "idpSettings": "{idpName} 設置", - "idpCreateSettingsDescription": "配置身份提供商的設置", + "idpSettings": "{idpName} 設定", + "idpCreateSettingsDescription": "設定身分提供者的設定", "roleMapping": "角色映射", "orgMapping": "組織映射", - "orgPoliciesSearch": "搜索組織策略...", - "orgPoliciesAdd": "添加組織策略", + "orgPoliciesSearch": "搜尋組織策略...", + "orgPoliciesAdd": "新增組織策略", "orgRequired": "組織是必填項", "error": "錯誤", "success": "成功", - "orgPolicyAddedDescription": "策略添加成功", + "orgPolicyAddedDescription": "策略新增成功", "orgPolicyUpdatedDescription": "策略更新成功", "orgPolicyDeletedDescription": "已成功刪除策略", - "defaultMappingsUpdatedDescription": "默認映射更新成功", + "defaultMappingsUpdatedDescription": "預設映射更新成功", "orgPoliciesAbout": "關於組織政策", - "orgPoliciesAboutDescription": "組織策略用於根據用戶的 ID 令牌來控制對組織的訪問。 您可以指定 JMESPath 表達式來提取角色和組織資訊從 ID 令牌中提取資訊。", + "orgPoliciesAboutDescription": "組織策略用於根據使用者的 ID 權杖來控制對組織的存取。 您可以指定 JMESPath 表達式來提取角色和組織資訊從 ID 權杖中提取資訊。", "orgPoliciesAboutDescriptionLink": "欲了解更多資訊,請參閱文件。", - "defaultMappingsOptional": "默認映射(可選)", - "defaultMappingsOptionalDescription": "當沒有為某個組織定義組織的政策時,使用默認映射。 您可以指定默認角色和組織映射回到這裡。", - "defaultMappingsRole": "默認角色映射", + "defaultMappingsOptional": "預設映射(可選)", + "defaultMappingsOptionalDescription": "當沒有為某個組織定義組織的政策時,使用預設映射。 您可以指定預設角色和組織映射回到這裡。", + "defaultMappingsRole": "預設角色映射", "defaultMappingsRoleDescription": "此表達式的結果必須返回組織中定義的角色名稱作為字串。", - "defaultMappingsOrg": "默認組織映射", - "defaultMappingsOrgDescription": "此表達式必須返回 組織ID 或 true 才能允許用戶訪問組織。", - "defaultMappingsSubmit": "保存默認映射", + "defaultMappingsOrg": "預設組織映射", + "defaultMappingsOrgDescription": "此表達式必須返回 組織ID 或 true 才能允許使用者存取組織。", + "defaultMappingsSubmit": "保存預設映射", "orgPoliciesEdit": "編輯組織策略", "org": "組織", "orgSelect": "選擇組織", - "orgSearch": "搜索", + "orgSearch": "搜尋", "orgNotFound": "找不到組織。", "roleMappingPathOptional": "角色映射路徑(可選)", "orgMappingPathOptional": "組織映射路徑(可選)", "orgPolicyUpdate": "更新策略", - "orgPolicyAdd": "添加策略", - "orgPolicyConfig": "配置組織訪問權限", - "idpUpdatedDescription": "身份提供商更新成功", + "orgPolicyAdd": "新增策略", + "orgPolicyConfig": "設定組織存取權限", + "idpUpdatedDescription": "身分提供者更新成功", "redirectUrl": "重定向網址", "orgIdpRedirectUrls": "重新導向網址", "redirectUrlAbout": "關於重定向網址", - "redirectUrlAboutDescription": "這是用戶在驗證後將被重定向到的URL。您需要在身份提供商設置中配置此URL。", + "redirectUrlAboutDescription": "這是使用者在驗證後將被重定向到的URL。您需要在身分提供者設定中設定此URL。", "pangolinAuth": "認證 - Pangolin", "verificationCodeLengthRequirements": "您的驗證碼必須是 8 個字元。", "errorOccurred": "發生錯誤", "emailErrorVerify": "驗證電子郵件失敗:", "emailVerified": "電子郵件驗證成功!重定向您...", - "verificationCodeErrorResend": "無法重新發送驗證碼:", - "verificationCodeResend": "驗證碼已重新發送", - "verificationCodeResendDescription": "我們已將驗證碼重新發送到您的電子郵件地址。請檢查您的收件箱。", + "verificationCodeErrorResend": "無法重新傳送驗證碼:", + "verificationCodeResend": "驗證碼已重新傳送", + "verificationCodeResendDescription": "我們已將驗證碼重新傳送到您的電子郵件位址。請檢查您的收件箱。", "emailVerify": "驗證電子郵件", - "emailVerifyDescription": "輸入驗證碼發送到您的電子郵件地址。", + "emailVerifyDescription": "輸入驗證碼傳送到您的電子郵件位址。", "verificationCode": "驗證碼", - "verificationCodeEmailSent": "我們向您的電子郵件地址發送了驗證碼。", + "verificationCodeEmailSent": "我們向您的電子郵件位址傳送了驗證碼。", "submit": "提交", - "emailVerifyResendProgress": "正在重新發送...", - "emailVerifyResend": "沒有收到代碼?點擊此處重新發送", + "emailVerifyResendProgress": "正在重新傳送...", + "emailVerifyResend": "沒有收到代碼?點選此處重新傳送", "passwordNotMatch": "密碼不匹配", "signupError": "註冊時出錯", "pangolinLogoAlt": "Pangolin 標誌", "inviteAlready": "看起來您已被邀請!", - "inviteAlreadyDescription": "要接受邀請,您必須登錄或創建一個帳戶。", + "inviteAlreadyDescription": "要接受邀請,您必須登入或建立一個帳戶。", "signupQuestion": "已經有一個帳戶?", - "login": "登錄", + "login": "登入", "resourceNotFound": "找不到資源", - "resourceNotFoundDescription": "您要訪問的資源不存在。", + "resourceNotFoundDescription": "您要存取的資源不存在。", "pincodeRequirementsLength": "PIN碼必須是 6 位數字", "pincodeRequirementsChars": "PIN 必須只包含數字", "passwordRequirementsLength": "密碼必須至少 1 個字元長", @@ -903,42 +902,42 @@ "passwordRequirementSpecialText": "特殊字元 (!@#$%...)", "passwordsDoNotMatch": "密碼不匹配", "otpEmailRequirementsLength": "OTP 必須至少 1 個字元長", - "otpEmailSent": "OTP 已發送", - "otpEmailSentDescription": "OTP 已經發送到您的電子郵件", - "otpEmailErrorAuthenticate": "通過電子郵件身份驗證失敗", + "otpEmailSent": "OTP 已傳送", + "otpEmailSentDescription": "OTP 已經傳送到您的電子郵件", + "otpEmailErrorAuthenticate": "通過電子郵件身分驗證失敗", "pincodeErrorAuthenticate": "Pincode 驗證失敗", "passwordErrorAuthenticate": "密碼驗證失敗", "poweredBy": "支持者:", - "authenticationRequired": "需要身份驗證", - "authenticationMethodChoose": "請選擇您偏好的方式來訪問 {name}", - "authenticationRequest": "您必須通過身份驗證才能訪問 {name}", - "user": "用戶", + "authenticationRequired": "需要身分驗證", + "authenticationMethodChoose": "請選擇您偏好的方式來存取 {name}", + "authenticationRequest": "您必須通過身分驗證才能存取 {name}", + "user": "使用者", "pincodeInput": "6 位數字 PIN 碼", - "pincodeSubmit": "使用 PIN 登錄", - "passwordSubmit": "使用密碼登錄", - "otpEmailDescription": "一次性代碼將發送到此電子郵件。", - "otpEmailSend": "發送一次性代碼", + "pincodeSubmit": "使用 PIN 登入", + "passwordSubmit": "使用密碼登入", + "otpEmailDescription": "一次性代碼將傳送到此電子郵件。", + "otpEmailSend": "傳送一次性代碼", "otpEmail": "一次性密碼 (OTP)", "otpEmailSubmit": "提交 OTP", "backToEmail": "回到電子郵件", "noSupportKey": "伺服器當前未使用支持者金鑰,歡迎支持本項目!", - "accessDenied": "訪問被拒絕", - "accessDeniedDescription": "當前帳戶無權訪問此資源。如認為這是錯誤,請與管理員聯繫。", - "accessTokenError": "檢查訪問令牌時出錯", - "accessGranted": "已授予訪問", - "accessUrlInvalid": "訪問 URL 無效", - "accessGrantedDescription": "您已獲准訪問此資源,正在為您跳轉...", - "accessUrlInvalidDescription": "此共享訪問URL無效。請聯絡資源所有者獲取新URL。", - "tokenInvalid": "無效的令牌", + "accessDenied": "存取被拒絕", + "accessDeniedDescription": "當前帳戶無權存取此資源。如認為這是錯誤,請與管理員聯繫。", + "accessTokenError": "檢查存取權杖時出錯", + "accessGranted": "已授予存取", + "accessUrlInvalid": "存取 URL 無效", + "accessGrantedDescription": "您已獲准存取此資源,正在為您跳轉...", + "accessUrlInvalidDescription": "此共享存取URL無效。請聯絡資源所有者取得新URL。", + "tokenInvalid": "無效的權杖", "pincodeInvalid": "無效的代碼", "passwordErrorRequestReset": "請求重設失敗:", "passwordErrorReset": "重設密碼失敗:", - "passwordResetSuccess": "密碼重設成功!返回登錄...", + "passwordResetSuccess": "密碼重設成功!返回登入...", "passwordReset": "重設密碼", "passwordResetDescription": "按照步驟重設您的密碼", - "passwordResetSent": "我們將發送一個驗證碼到這個電子郵件地址。", + "passwordResetSent": "我們將傳送一個驗證碼到這個電子郵件位址。", "passwordResetCode": "驗證碼", - "passwordResetCodeDescription": "請檢查您的電子郵件以獲取驗證碼。", + "passwordResetCodeDescription": "請檢查您的電子郵件以取得驗證碼。", "generatePasswordResetCode": "產生密碼重設代碼", "passwordResetCodeGenerated": "密碼重設代碼已產生", "passwordResetCodeGeneratedDescription": "請將此代碼分享給使用者。他們可以用它來重設密碼。", @@ -964,39 +963,39 @@ "passwordResetSmtpRequired": "請聯絡您的管理員", "passwordResetSmtpRequiredDescription": "需要密碼重設代碼才能重設您的密碼。請聯絡您的管理員尋求協助。", "passwordBack": "回到密碼", - "loginBack": "返回登錄", + "loginBack": "返回登入", "signup": "註冊", - "loginStart": "登錄以開始", - "idpOidcTokenValidating": "正在驗證 OIDC 令牌", - "idpOidcTokenResponse": "驗證 OIDC 令牌響應", - "idpErrorOidcTokenValidating": "驗證 OIDC 令牌出錯", + "loginStart": "登入以開始", + "idpOidcTokenValidating": "正在驗證 OIDC 權杖", + "idpOidcTokenResponse": "驗證 OIDC 權杖響應", + "idpErrorOidcTokenValidating": "驗證 OIDC 權杖出錯", "idpConnectingTo": "連接到{name}", - "idpConnectingToDescription": "正在驗證您的身份", + "idpConnectingToDescription": "正在驗證您的身分", "idpConnectingToProcess": "正在連接...", "idpConnectingToFinished": "已連接", "idpErrorConnectingTo": "無法連接到 {name},請聯絡管理員協助處理。", "idpErrorNotFound": "找不到 IdP", "inviteInvalid": "無效邀請", "inviteInvalidDescription": "邀請連結無效。", - "inviteErrorWrongUser": "邀請不是該用戶的", - "inviteErrorUserNotExists": "用戶不存在。請先創建帳戶。", - "inviteErrorLoginRequired": "您必須登錄才能接受邀請", + "inviteErrorWrongUser": "邀請不是該使用者的", + "inviteErrorUserNotExists": "使用者不存在。請先建立帳戶。", + "inviteErrorLoginRequired": "您必須登入才能接受邀請", "inviteErrorExpired": "邀請可能已過期", "inviteErrorRevoked": "邀請可能已被吊銷了", "inviteErrorTypo": "邀請連結中可能有一個類型", "pangolinSetup": "認證 - Pangolin", "orgNameRequired": "組織名稱是必需的", "orgIdRequired": "組織ID是必需的", - "orgErrorCreate": "創建組織時出錯", + "orgErrorCreate": "建立組織時出錯", "pageNotFound": "找不到頁面", "pageNotFoundDescription": "哎呀!您正在尋找的頁面不存在。", "overview": "概覽", "home": "首頁", - "accessControl": "訪問控制", - "settings": "設置", - "usersAll": "所有用戶", + "accessControl": "存取控制", + "settings": "設定", + "usersAll": "所有使用者", "license": "許可協議", - "pangolinDashboard": "儀錶板 - Pangolin", + "pangolinDashboard": "儀表板 - Pangolin", "noResults": "未找到任何結果。", "terabytes": "{count} TB", "gigabytes": "{count} GB", @@ -1009,16 +1008,16 @@ "tagWarnTooShort": "標籤 {tagText} 太短", "tagWarnTooLong": "標籤 {tagText} 太長", "tagsWarnReachedMaxNumber": "已達到允許標籤的最大數量", - "tagWarnDuplicate": "未添加重複標籤 {tagText}", + "tagWarnDuplicate": "未新增重複標籤 {tagText}", "supportKeyInvalid": "無效金鑰", "supportKeyInvalidDescription": "您的支持者金鑰無效。", "supportKeyValid": "有效的金鑰", "supportKeyValidDescription": "您的支持者金鑰已被驗證。感謝您的支持!", "supportKeyErrorValidationDescription": "驗證支持者金鑰失敗。", "supportKey": "支持開發和通過一個 Pangolin !", - "supportKeyDescription": "購買支持者鑰匙,幫助我們繼續為社區發展 Pangolin 。 您的貢獻使我們能夠投入更多的時間來維護和添加所有人的新功能。 我們永遠不會用這個來支付牆上的功能。這與任何商業版是分開的。", + "supportKeyDescription": "購買支持者鑰匙,幫助我們繼續為社群發展 Pangolin 。 您的貢獻使我們能夠投入更多的時間來維護和新增所有人的新功能。 我們永遠不會用這個來支付牆上的功能。這與任何商業版是分開的。", "supportKeyPet": "您還可以領養並見到屬於自己的 Pangolin!", - "supportKeyPurchase": "付款通過 GitHub 進行處理,之後您可以在以下位置獲取您的金鑰:", + "supportKeyPurchase": "付款通過 GitHub 進行處理,之後您可以在以下位置取得您的金鑰:", "supportKeyPurchaseLink": "我們的網站", "supportKeyPurchase2": "並在這裡兌換。", "supportKeyLearnMore": "了解更多。", @@ -1029,7 +1028,7 @@ "supporterStatus": "支持者狀態", "buy": "購買", "supportKeyOptionLimited": "有限支持者", - "forFiveUsers": "適用於 5 或更少用戶", + "forFiveUsers": "適用於 5 或更少使用者", "supportKeyRedeem": "兌換支持者金鑰", "supportKeyHideSevenDays": "隱藏 7 天", "supportKeyEnter": "輸入支持者金鑰", @@ -1037,8 +1036,8 @@ "githubUsername": "GitHub 使用者名稱", "supportKeyInput": "支持者金鑰", "supportKeyBuy": "購買支持者金鑰", - "logoutError": "註銷錯誤", - "signingAs": "登錄為", + "logoutError": "登出錯誤", + "signingAs": "登入為", "serverAdmin": "伺服器管理員", "managedSelfhosted": "託管自託管", "otpEnable": "啟用雙因子認證", @@ -1046,110 +1045,109 @@ "logout": "登出", "licenseTierProfessionalRequired": "需要專業版", "licenseTierProfessionalRequiredDescription": "此功能僅在專業版可用。", - "actionGetOrg": "獲取組織", - "updateOrgUser": "更新組織用戶", - "createOrgUser": "創建組織用戶", + "actionGetOrg": "取得組織", + "updateOrgUser": "更新組織使用者", + "createOrgUser": "建立組織使用者", "actionUpdateOrg": "更新組織", "actionRemoveInvitation": "移除邀請", - "actionUpdateUser": "更新用戶", - "actionGetUser": "獲取用戶", - "actionGetOrgUser": "獲取組織用戶", + "actionUpdateUser": "更新使用者", + "actionGetUser": "取得使用者", + "actionGetOrgUser": "取得組織使用者", "actionListOrgDomains": "列出組織域", - "actionCreateSite": "創建站點", - "actionDeleteSite": "刪除站點", - "actionGetSite": "獲取站點", - "actionListSites": "站點列表", - "actionApplyBlueprint": "應用藍圖", + "actionCreateSite": "建立站台", + "actionDeleteSite": "刪除站台", + "actionGetSite": "取得站台", + "actionListSites": "站台列表", + "actionApplyBlueprint": "套用藍圖", "actionListBlueprints": "藍圖列表", - "actionGetBlueprint": "獲取藍圖", - "setupToken": "設置令牌", - "setupTokenDescription": "從伺服器控制台輸入設定令牌。", - "setupTokenRequired": "需要設置令牌", - "actionUpdateSite": "更新站點", - "actionListSiteRoles": "允許站點角色列表", - "actionCreateResource": "創建資源", + "actionGetBlueprint": "取得藍圖", + "setupToken": "設定權杖", + "setupTokenDescription": "從伺服器控制台輸入設定權杖。", + "setupTokenRequired": "需要設定權杖", + "actionUpdateSite": "更新站台", + "actionListSiteRoles": "允許站台角色列表", + "actionCreateResource": "建立資源", "actionDeleteResource": "刪除資源", - "actionGetResource": "獲取資源", + "actionGetResource": "取得資源", "actionListResource": "列出資源", "actionUpdateResource": "更新資源", - "actionListResourceUsers": "列出資源用戶", - "actionSetResourceUsers": "設置資源用戶", - "actionSetAllowedResourceRoles": "設置允許的資源角色", + "actionListResourceUsers": "列出資源使用者", + "actionSetResourceUsers": "設定資源使用者", + "actionSetAllowedResourceRoles": "設定允許的資源角色", "actionListAllowedResourceRoles": "列出允許的資源角色", - "actionSetResourcePassword": "設置資源密碼", - "actionSetResourcePincode": "設置資源粉碼", - "actionSetResourceEmailWhitelist": "設置資源電子郵件白名單", - "actionGetResourceEmailWhitelist": "獲取資源電子郵件白名單", - "actionCreateTarget": "創建目標", + "actionSetResourcePassword": "設定資源密碼", + "actionSetResourcePincode": "設定資源粉碼", + "actionSetResourceEmailWhitelist": "設定資源電子郵件白名單", + "actionGetResourceEmailWhitelist": "取得資源電子郵件白名單", + "actionCreateTarget": "建立目標", "actionDeleteTarget": "刪除目標", - "actionGetTarget": "獲取目標", + "actionGetTarget": "取得目標", "actionListTargets": "列表目標", "actionUpdateTarget": "更新目標", - "actionCreateRole": "創建角色", + "actionCreateRole": "建立角色", "actionDeleteRole": "刪除角色", - "actionGetRole": "獲取角色", + "actionGetRole": "取得角色", "actionListRole": "角色列表", "actionUpdateRole": "更新角色", "actionListAllowedRoleResources": "列表允許的角色資源", - "actionInviteUser": "邀請用戶", - "actionRemoveUser": "刪除用戶", - "actionListUsers": "列出用戶", - "actionAddUserRole": "添加用戶角色", + "actionInviteUser": "邀請使用者", + "actionRemoveUser": "刪除使用者", + "actionListUsers": "列出使用者", + "actionAddUserRole": "新增使用者角色", "actionSetUserOrgRoles": "Set User Roles", - "actionGenerateAccessToken": "生成訪問令牌", - "actionDeleteAccessToken": "刪除訪問令牌", - "actionListAccessTokens": "訪問令牌", + "actionGenerateAccessToken": "生成存取權杖", + "actionDeleteAccessToken": "刪除存取權杖", + "actionListAccessTokens": "存取權杖", "actionCreateResourceSessionToken": "建立資源工作階段權杖", - "actionCreateResourceRule": "創建資源規則", + "actionCreateResourceRule": "建立資源規則", "actionDeleteResourceRule": "刪除資源規則", "actionListResourceRules": "列出資源規則", "actionUpdateResourceRule": "更新資源規則", "actionListOrgs": "列出組織", "actionCheckOrgId": "檢查組織ID", - "actionCreateOrg": "創建組織", + "actionCreateOrg": "建立組織", "actionDeleteOrg": "刪除組織", "actionListApiKeys": "列出 API 金鑰", "actionListApiKeyActions": "列出 API 金鑰動作", - "actionSetApiKeyActions": "設置 API 金鑰允許的操作", - "actionCreateApiKey": "創建 API 金鑰", + "actionSetApiKeyActions": "設定 API 金鑰允許的操作", + "actionCreateApiKey": "建立 API 金鑰", "actionDeleteApiKey": "刪除 API 金鑰", - "actionCreateIdp": "創建 IDP", + "actionCreateIdp": "建立 IDP", "actionUpdateIdp": "更新 IDP", "actionDeleteIdp": "刪除 IDP", "actionListIdps": "列出 IDP", - "actionGetIdp": "獲取 IDP", - "actionCreateIdpOrg": "創建 IDP 組織策略", + "actionGetIdp": "取得 IDP", + "actionCreateIdpOrg": "建立 IDP 組織策略", "actionDeleteIdpOrg": "刪除 IDP 組織策略", "actionListIdpOrgs": "列出 IDP 組織", "actionUpdateIdpOrg": "更新 IDP 組織", - "actionCreateClient": "創建用戶端", + "actionCreateClient": "建立用戶端", "actionDeleteClient": "刪除用戶端", "actionUpdateClient": "更新用戶端", "actionListClients": "列出用戶端", - "actionGetClient": "獲取用戶端", - "actionCreateSiteResource": "創建站點資源", - "actionDeleteSiteResource": "刪除站點資源", - "actionGetSiteResource": "獲取站點資源", - "actionListSiteResources": "列出站點資源", - "actionUpdateSiteResource": "更新站點資源", + "actionGetClient": "取得用戶端", + "actionCreateSiteResource": "建立站台資源", + "actionDeleteSiteResource": "刪除站台資源", + "actionGetSiteResource": "取得站台資源", + "actionListSiteResources": "列出站台資源", + "actionUpdateSiteResource": "更新站台資源", "actionListInvitations": "邀請列表", "actionExportLogs": "匯出日誌", "actionViewLogs": "查看日誌", "noneSelected": "未選擇", "orgNotFound2": "未找到組織。", - "searchProgress": "搜索中...", - "create": "創建", + "create": "建立", "orgs": "組織", - "loginError": "登錄時出錯", + "loginError": "登入時出錯", "loginRequiredForDevice": "需要登入以驗證您的裝置。", "passwordForgot": "忘記密碼?", "otpAuth": "兩步驗證", - "otpAuthDescription": "從您的身份驗證程序中輸入代碼或您的單次備份代碼。", + "otpAuthDescription": "從您的身分驗證程式中輸入代碼或您的單次備份代碼。", "otpAuthSubmit": "提交代碼", "idpContinue": "或者繼續", - "otpAuthBack": "返回登錄", - "navbar": "導航菜單", - "navbarDescription": "應用程式的主導航菜單", + "otpAuthBack": "返回登入", + "navbar": "導航選單", + "navbarDescription": "應用程式的主導航選單", "navbarDocsLink": "文件", "otpErrorEnable": "無法啟用 2FA", "otpErrorEnableDescription": "啟用 2FA 時出錯", @@ -1157,7 +1155,7 @@ "otpSetupCheckCodeRetry": "無效的代碼。請重試。", "otpSetup": "啟用兩步驗證", "otpSetupDescription": "用額外的保護層來保護您的帳戶", - "otpSetupScanQr": "用您的身份驗證程序掃描此二維碼或手動輸入金鑰:", + "otpSetupScanQr": "用您的身分驗證程式掃描此QR 碼或手動輸入金鑰:", "otpSetupSecretCode": "驗證器代碼", "otpSetupSuccess": "啟用兩步驗證", "otpSetupSuccessStoreBackupCodes": "您的帳戶現在更加安全。不要忘記保存您的備份代碼。", @@ -1165,16 +1163,16 @@ "otpErrorDisableDescription": "禁用 2FA 時出錯", "otpRemove": "禁用兩步驗證", "otpRemoveDescription": "為您的帳戶禁用兩步驗證", - "otpRemoveSuccess": "雙重身份驗證已禁用", - "otpRemoveSuccessMessage": "您的帳戶已禁用雙重身份驗證。您可以隨時再次啟用它。", + "otpRemoveSuccess": "雙重身分驗證已禁用", + "otpRemoveSuccessMessage": "您的帳戶已禁用雙重身分驗證。您可以隨時再次啟用它。", "otpRemoveSubmit": "禁用兩步驗證", "paginator": "第 {current} 頁,共 {last} 頁", "paginatorToFirst": "轉到第一頁", "paginatorToPrevious": "轉到上一頁", "paginatorToNext": "轉到下一頁", "paginatorToLast": "轉到最後一頁", - "copyText": "複製文本", - "copyTextFailed": "複製文本失敗: ", + "copyText": "複製文字", + "copyTextFailed": "複製文字失敗: ", "copyTextClipboard": "複製到剪貼簿", "inviteErrorInvalidConfirmation": "無效確認", "passwordRequired": "必須填寫密碼", @@ -1185,30 +1183,30 @@ "passwordRequirementsChars": "密碼至少需要 8 個字元", "language": "語言", "verificationCodeRequired": "必須輸入代碼", - "userErrorNoUpdate": "沒有要更新的用戶", - "siteErrorNoUpdate": "沒有要更新的站點", + "userErrorNoUpdate": "沒有要更新的使用者", + "siteErrorNoUpdate": "沒有要更新的站台", "resourceErrorNoUpdate": "沒有可更新的資源", - "authErrorNoUpdate": "沒有要更新的身份驗證資訊", + "authErrorNoUpdate": "沒有要更新的身分驗證資訊", "orgErrorNoUpdate": "沒有要更新的組織", "orgErrorNoProvided": "未提供組織", "apiKeysErrorNoUpdate": "沒有要更新的 API 金鑰", "sidebarOverview": "概覽", "sidebarHome": "首頁", - "sidebarSites": "站點", + "sidebarSites": "站台", "sidebarResources": "資源", "sidebarProxyResources": "公開", "sidebarClientResources": "私有", - "sidebarAccessControl": "訪問控制", + "sidebarAccessControl": "存取控制", "sidebarLogsAndAnalytics": "日誌與分析", - "sidebarUsers": "用戶", + "sidebarUsers": "使用者", "sidebarAdmin": "管理員", "sidebarInvitations": "邀請", "sidebarRoles": "角色", "sidebarShareableLinks": "分享連結", "sidebarApiKeys": "API 金鑰", - "sidebarSettings": "設置", - "sidebarAllUsers": "所有用戶", - "sidebarIdentityProviders": "身份提供商", + "sidebarSettings": "設定", + "sidebarAllUsers": "所有使用者", + "sidebarIdentityProviders": "身分提供者", "sidebarLicense": "證書", "sidebarClients": "用戶端", "sidebarUserDevices": "使用者", @@ -1220,29 +1218,28 @@ "sidebarOrganization": "組織", "sidebarLogsAnalytics": "分析", "blueprints": "藍圖", - "blueprintsDescription": "應用聲明配置並查看先前運行的", - "blueprintAdd": "添加藍圖", + "blueprintsDescription": "套用聲明設定並查看先前執行的", + "blueprintAdd": "新增藍圖", "blueprintGoBack": "查看所有藍圖", - "blueprintCreate": "創建藍圖", - "blueprintCreateDescription2": "按照下面的步驟創建和應用新的藍圖", + "blueprintCreate": "建立藍圖", + "blueprintCreateDescription2": "按照下面的步驟建立和套用新的藍圖", "blueprintDetails": "藍圖詳細資訊", - "blueprintDetailsDescription": "查看應用藍圖的結果和發生的任何錯誤", + "blueprintDetailsDescription": "查看套用藍圖的結果和發生的任何錯誤", "blueprintInfo": "藍圖資訊", "message": "留言", "blueprintContentsDescription": "定義描述您基礎設施的 YAML 內容", - "blueprintErrorCreateDescription": "應用藍圖時出錯", - "blueprintErrorCreate": "創建藍圖時出錯", - "searchBlueprintProgress": "搜索藍圖...", - "appliedAt": "應用於", + "blueprintErrorCreateDescription": "套用藍圖時出錯", + "blueprintErrorCreate": "建立藍圖時出錯", + "searchBlueprintProgress": "搜尋藍圖...", + "appliedAt": "套用於", "source": "來源", "contents": "目錄", "parsedContents": "解析內容 (只讀)", "enableDockerSocket": "啟用 Docker 藍圖", "enableDockerSocketDescription": "啟用 Docker Socket 標籤擦除藍圖標籤。套接字路徑必須提供給新的。", - "enableDockerSocketLink": "了解更多", "viewDockerContainers": "查看停靠容器", "containersIn": "{siteName} 中的容器", - "selectContainerDescription": "選擇任何容器作為目標的主機名。點擊埠使用埠。", + "selectContainerDescription": "選擇任何容器作為目標的主機名。點選埠使用埠。", "containerName": "名稱", "containerImage": "圖片", "containerState": "狀態", @@ -1259,8 +1256,8 @@ "noContainersMatchingFilters": "沒有找到匹配當前過濾器的容器。", "showContainersWithoutPorts": "顯示沒有埠的容器", "showStoppedContainers": "顯示已停止的容器", - "noContainersFound": "未找到容器。請確保 Docker 容器正在運行。", - "searchContainersPlaceholder": "在 {count} 個容器中搜索...", + "noContainersFound": "未找到容器。請確保 Docker 容器正在執行。", + "searchContainersPlaceholder": "在 {count} 個容器中搜尋...", "searchResultsCount": "{count, plural, other {# 個結果}}", "filters": "篩選器", "filterOptions": "過濾器選項", @@ -1269,28 +1266,28 @@ "clearAllFilters": "清除所有過濾器", "columns": "列", "toggleColumns": "切換列", - "refreshContainersList": "刷新容器列表", - "searching": "搜索中...", + "refreshContainersList": "重新整理容器列表", + "searching": "搜尋中...", "noContainersFoundMatching": "未找到與 \"{filter}\" 匹配的容器。", "light": "淺色", "dark": "深色", "system": "系統", "theme": "主題", "subnetRequired": "子網是必填項", - "initialSetupTitle": "初始伺服器設置", - "initialSetupDescription": "創建初始伺服器管理員帳戶。 只能存在一個伺服器管理員。 您可以隨時更改這些憑據。", - "createAdminAccount": "創建管理員帳戶", - "setupErrorCreateAdmin": "創建伺服器管理員帳戶時發生錯誤。", + "initialSetupTitle": "初始伺服器設定", + "initialSetupDescription": "建立初始伺服器管理員帳戶。 只能存在一個伺服器管理員。 您可以隨時更改這些憑據。", + "createAdminAccount": "建立管理員帳戶", + "setupErrorCreateAdmin": "建立伺服器管理員帳戶時發生錯誤。", "certificateStatus": "證書狀態", "loading": "載入中", - "restart": "重啟", + "restart": "重新啟動", "domains": "域", "domainsDescription": "管理您的組織域", - "domainsSearch": "搜索域...", - "domainAdd": "添加域", + "domainsSearch": "搜尋域...", + "domainAdd": "新增域", "domainAddDescription": "在您的組織中註冊新域", - "domainCreate": "創建域", - "domainCreatedDescription": "域創建成功", + "domainCreate": "建立域", + "domainCreatedDescription": "域建立成功", "domainDeletedDescription": "成功刪除域", "domainQuestionRemove": "您確定要從您的帳戶中刪除域名嗎?", "domainMessageRemove": "移除後,該域將不再與您的帳戶關聯。", @@ -1306,8 +1303,8 @@ "domainDelegation": "單個域", "selectType": "選擇一個類型", "actions": "操作", - "refresh": "刷新", - "refreshError": "刷新數據失敗", + "refresh": "重新整理", + "refreshError": "重新整理資料失敗", "verified": "已驗證", "pending": "待定", "sidebarBilling": "計費", @@ -1317,24 +1314,24 @@ "pangolinHosted": "Pangolin 託管", "fossorial": "Fossorial", "completeAccountSetup": "完成帳戶設定", - "completeAccountSetupDescription": "設置您的密碼以開始", - "accountSetupSent": "我們將發送帳號設定代碼到該電子郵件地址。", - "accountSetupCode": "設置代碼", - "accountSetupCodeDescription": "請檢查您的信箱以獲取設置代碼。", - "passwordCreate": "創建密碼", + "completeAccountSetupDescription": "設定您的密碼以開始", + "accountSetupSent": "我們將傳送帳號設定代碼到該電子郵件位址。", + "accountSetupCode": "設定代碼", + "accountSetupCodeDescription": "請檢查您的信箱以取得設定代碼。", + "passwordCreate": "建立密碼", "passwordCreateConfirm": "確認密碼", - "accountSetupSubmit": "發送設置代碼", - "completeSetup": "完成設置", + "accountSetupSubmit": "傳送設定代碼", + "completeSetup": "完成設定", "accountSetupSuccess": "帳號設定完成!歡迎來到 Pangolin!", "documentation": "文件", - "saveAllSettings": "保存所有設置", + "saveAllSettings": "保存所有設定", "saveResourceTargets": "儲存目標", "saveResourceHttp": "儲存代理設定", "saveProxyProtocol": "儲存代理協定設定", - "settingsUpdated": "設置已更新", - "settingsUpdatedDescription": "所有設置已成功更新", - "settingsErrorUpdate": "設置更新失敗", - "settingsErrorUpdateDescription": "更新設置時發生錯誤", + "settingsUpdated": "設定已更新", + "settingsUpdatedDescription": "所有設定已成功更新", + "settingsErrorUpdate": "設定更新失敗", + "settingsErrorUpdateDescription": "更新設定時發生錯誤", "sidebarCollapse": "摺疊", "sidebarExpand": "展開", "productUpdateMoreInfo": "還有 {noOfUpdates} 項更新", @@ -1358,7 +1355,7 @@ "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "檢查可用性...", - "domainPickerNoMatchingDomains": "未找到匹配的域名。嘗試不同的域名或檢查您組織的域名設置。", + "domainPickerNoMatchingDomains": "未找到匹配的域名。嘗試不同的域名或檢查您組織的域名設定。", "domainPickerOrganizationDomains": "組織域", "domainPickerProvidedDomains": "提供的域", "domainPickerSubdomain": "子域:{subdomain}", @@ -1370,15 +1367,14 @@ "regionSelectorComingSoon": "即將推出", "billingLoadingSubscription": "正在載入訂閱...", "billingFreeTier": "免費層", - "billingWarningOverLimit": "警告:您已超出一個或多個使用限制。在您修改訂閱或調整使用情況之前,您的站點將無法連接。", + "billingWarningOverLimit": "警告:您已超出一個或多個使用限制。在您修改訂閱或調整使用情況之前,您的站台將無法連接。", "billingUsageLimitsOverview": "使用限制概覽", - "billingMonitorUsage": "監控您的使用情況以對比已配置的限制。如需提高限制請聯絡我們 support@pangolin.net。", - "billingDataUsage": "數據使用情況", - "billingOnlineTime": "站點在線時間", - "billingUsers": "活躍用戶", + "billingMonitorUsage": "監控您的使用情況以對比已設定的限制。如需提高限制請聯絡我們 support@pangolin.net。", + "billingDataUsage": "資料使用情況", + "billingUsers": "活躍使用者", "billingDomains": "活躍域", "billingRemoteExitNodes": "活躍自託管節點", - "billingNoLimitConfigured": "未配置限制", + "billingNoLimitConfigured": "未設定限制", "billingEstimatedPeriod": "估計結算週期", "billingIncludedUsage": "包含的使用量", "billingIncludedUsageDescription": "您當前訂閱計劃中包含的使用量", @@ -1392,28 +1388,27 @@ "billingModifySubscription": "修改訂閱", "billingStartSubscription": "開始訂閱", "billingRecurringCharge": "週期性收費", - "billingManageSubscriptionSettings": "管理您的訂閱設置和偏好", + "billingManageSubscriptionSettings": "管理您的訂閱設定和偏好", "billingNoActiveSubscription": "您沒有活躍的訂閱。開始訂閱以增加使用限制。", "billingFailedToLoadSubscription": "無法載入訂閱", "billingFailedToLoadUsage": "無法載入使用情況", - "billingFailedToGetCheckoutUrl": "無法獲取結帳網址", + "billingFailedToGetCheckoutUrl": "無法取得結帳網址", "billingPleaseTryAgainLater": "請稍後再試。", "billingCheckoutError": "結帳錯誤", - "billingFailedToGetPortalUrl": "無法獲取門戶網址", + "billingFailedToGetPortalUrl": "無法取得門戶網址", "billingPortalError": "門戶錯誤", - "billingDataUsageInfo": "當連接到雲端時,您將為透過安全隧道傳輸的所有數據收取費用。 這包括您所有站點的進出流量。 當您達到上限時,您的站點將斷開連接,直到您升級計劃或減少使用。使用節點時不收取數據。", - "billingOnlineTimeInfo": "您要根據您的網站連接到雲端的時間長短收取費用。 例如,44,640 分鐘等於一個 24/7 全月運行的網站。 當您達到上限時,您的站點將斷開連接,直到您升級計劃或減少使用。使用節點時不收取費用。", - "billingUsersInfo": "根據您組織中的活躍用戶數量收費。按日計算帳單。", + "billingDataUsageInfo": "當連接到雲端時,您將為透過安全隧道傳輸的所有資料收取費用。 這包括您所有站台的進出流量。 當您達到上限時,您的站台將斷開連接,直到您升級計劃或減少使用。使用節點時不收取資料。", + "billingUsersInfo": "根據您組織中的活躍使用者數量收費。按日計算帳單。", "billingDomainInfo": "根據組織中活躍域的數量收費。按日計算帳單。", "billingRemoteExitNodesInfo": "根據您組織中已管理節點的數量收費。按日計算帳單。", "domainNotFound": "域未找到", - "domainNotFoundDescription": "此資源已禁用,因為該域不再在我們的系統中存在。請為此資源設置一個新域。", + "domainNotFoundDescription": "此資源已禁用,因為該域不再在我們的系統中存在。請為此資源設定一個新域。", "failed": "失敗", - "createNewOrgDescription": "創建一個新組織", + "createNewOrgDescription": "建立一個新組織", "organization": "組織", "port": "埠", "securityKeyManage": "管理安全金鑰", - "securityKeyDescription": "添加或刪除用於無密碼認證的安全金鑰", + "securityKeyDescription": "新增或刪除用於無密碼認證的安全金鑰", "securityKeyRegister": "註冊新的安全金鑰", "securityKeyList": "您的安全金鑰", "securityKeyNone": "尚未註冊安全金鑰", @@ -1430,29 +1425,29 @@ "securityKeyAuthError": "使用安全金鑰認證失敗", "securityKeyRecommendation": "考慮在其他設備上註冊另一個安全金鑰,以確保不會被鎖定在您的帳戶之外。", "registering": "註冊中...", - "securityKeyPrompt": "請使用您的安全金鑰驗證身份。確保您的安全金鑰已連接並準備好。", - "securityKeyBrowserNotSupported": "您的瀏覽器不支持安全金鑰。請使用像 Chrome、Firefox 或 Safari 這樣的現代瀏覽器。", - "securityKeyPermissionDenied": "請允許訪問您的安全金鑰以繼續登錄。", - "securityKeyRemovedTooQuickly": "請保持您的安全金鑰連接,直到登錄過程完成。", + "securityKeyPrompt": "請使用您的安全金鑰驗證身分。確保您的安全金鑰已連接並準備好。", + "securityKeyBrowserNotSupported": "您的瀏覽器不支援安全金鑰。請使用像 Chrome、Firefox 或 Safari 這樣的現代瀏覽器。", + "securityKeyPermissionDenied": "請允許存取您的安全金鑰以繼續登入。", + "securityKeyRemovedTooQuickly": "請保持您的安全金鑰連接,直到登入過程完成。", "securityKeyNotSupported": "您的安全金鑰可能不相容。請嘗試不同的安全金鑰。", "securityKeyUnknownError": "使用安全金鑰時出現問題。請再試一次。", "twoFactorRequired": "註冊安全金鑰需要兩步驗證。", "twoFactor": "兩步驗證", "twoFactorAuthentication": "兩步驗證", - "twoFactorDescription": "這個組織需要雙重身份驗證。", + "twoFactorDescription": "這個組織需要雙重身分驗證。", "enableTwoFactor": "啟用兩步驗證", "organizationSecurityPolicy": "組織安全政策", - "organizationSecurityPolicyDescription": "此機構擁有安全要求,您必須先滿足才能訪問", + "organizationSecurityPolicyDescription": "此機構擁有安全要求,您必須先滿足才能存取", "securityRequirements": "安全要求", "allRequirementsMet": "已滿足所有要求", - "completeRequirementsToContinue": "完成下面的要求以繼續訪問此組織", - "youCanNowAccessOrganization": "您現在可以訪問此組織", + "completeRequirementsToContinue": "完成下面的要求以繼續存取此組織", + "youCanNowAccessOrganization": "您現在可以存取此組織", "reauthenticationRequired": "會話長度", - "reauthenticationDescription": "該機構要求您每 {maxDays} 天登錄一次。", - "reauthenticationDescriptionHours": "該機構要求您每 {maxHours} 小時登錄一次。", - "reauthenticateNow": "再次登錄", - "adminEnabled2FaOnYourAccount": "管理員已為 {email} 啟用兩步驗證。請完成設置以繼續。", - "securityKeyAdd": "添加安全金鑰", + "reauthenticationDescription": "該機構要求您每 {maxDays} 天登入一次。", + "reauthenticationDescriptionHours": "該機構要求您每 {maxHours} 小時登入一次。", + "reauthenticateNow": "再次登入", + "adminEnabled2FaOnYourAccount": "管理員已為 {email} 啟用兩步驗證。請完成設定以繼續。", + "securityKeyAdd": "新增安全金鑰", "securityKeyRegisterTitle": "註冊新安全金鑰", "securityKeyRegisterDescription": "連接您的安全金鑰並輸入名稱以便識別", "securityKeyTwoFactorRequired": "要求兩步驗證", @@ -1462,10 +1457,10 @@ "securityKeyRemoveTitle": "移除安全金鑰", "securityKeyRemoveDescription": "輸入您的密碼以移除安全金鑰 \"{name}\"", "securityKeyNoKeysRegistered": "沒有註冊安全金鑰", - "securityKeyNoKeysDescription": "添加安全金鑰以加強您的帳戶安全", + "securityKeyNoKeysDescription": "新增安全金鑰以加強您的帳戶安全", "createDomainRequired": "必須輸入域", - "createDomainAddDnsRecords": "添加 DNS 記錄", - "createDomainAddDnsRecordsDescription": "將以下 DNS 記錄添加到您的域名提供商以完成設置。", + "createDomainAddDnsRecords": "新增 DNS 記錄", + "createDomainAddDnsRecordsDescription": "將以下 DNS 記錄新增到您的域名提供者以完成設定。", "createDomainNsRecords": "NS 記錄", "createDomainRecord": "記錄", "createDomainType": "類型:", @@ -1478,9 +1473,9 @@ "createDomainSaveTheseRecords": "保存這些記錄", "createDomainSaveTheseRecordsDescription": "務必保存這些 DNS 記錄,因為您將無法再次查看它們。", "createDomainDnsPropagation": "DNS 傳播", - "createDomainDnsPropagationDescription": "DNS 更改可能需要一些時間才能在網路上傳播。這可能需要從幾分鐘到 48 小時,具體取決於您的 DNS 提供商和 TTL 設置。", + "createDomainDnsPropagationDescription": "DNS 更改可能需要一些時間才能在網路上傳播。這可能需要從幾分鐘到 48 小時,具體取決於您的 DNS 提供者和 TTL 設定。", "resourcePortRequired": "非 HTTP 資源必須輸入埠號", - "resourcePortNotAllowed": "HTTP 資源不應設置埠號", + "resourcePortNotAllowed": "HTTP 資源不應設定埠號", "billingPricingCalculatorLink": "價格計算機", "signUpTerms": { "IAgreeToThe": "我同意", @@ -1491,44 +1486,44 @@ "signUpMarketing": { "keepMeInTheLoop": "透過電子郵件接收新聞、更新和新功能通知。" }, - "siteRequired": "需要站點。", + "siteRequired": "需要站台。", "olmTunnel": "Olm 隧道", "olmTunnelDescription": "使用 Olm 進行用戶端連接", - "errorCreatingClient": "創建用戶端出錯", + "errorCreatingClient": "建立用戶端出錯", "clientDefaultsNotFound": "未找到用戶端預設值", - "createClient": "創建用戶端", - "createClientDescription": "創建一個新用戶端來連接您的站點", + "createClient": "建立用戶端", + "createClientDescription": "建立一個新用戶端來連接您的站台", "seeAllClients": "查看所有用戶端", "clientInformation": "用戶端資訊", "clientNamePlaceholder": "用戶端名稱", - "address": "地址", + "address": "位址", "subnetPlaceholder": "子網", - "addressDescription": "此用戶端將用於連接的地址", - "selectSites": "選擇站點", - "sitesDescription": "用戶端將與所選站點進行連接", + "addressDescription": "此用戶端將用於連接的位址", + "selectSites": "選擇站台", + "sitesDescription": "用戶端將與所選站台進行連接", "clientInstallOlm": "安裝 Olm", - "clientInstallOlmDescription": "在您的系統上運行 Olm", + "clientInstallOlmDescription": "在您的系統上執行 Olm", "clientOlmCredentials": "Olm 憑據", - "clientOlmCredentialsDescription": "這是 Olm 伺服器的身份驗證方式", + "clientOlmCredentialsDescription": "這是 Olm 伺服器的身分驗證方式", "olmEndpoint": "Olm 端點", "olmId": "Olm ID", "olmSecretKey": "Olm 私鑰", "clientCredentialsSave": "保存您的憑據", "clientCredentialsSaveDescription": "該資訊僅會顯示一次,請確保將其複製到安全位置。", - "generalSettingsDescription": "配置此用戶端的常規設置", + "generalSettingsDescription": "設定此用戶端的常規設定", "clientUpdated": "用戶端已更新", "clientUpdatedDescription": "用戶端已更新。", "clientUpdateFailed": "更新用戶端失敗", "clientUpdateError": "更新用戶端時出錯。", - "sitesFetchFailed": "獲取站點失敗", - "sitesFetchError": "獲取站點時出錯。", - "olmErrorFetchReleases": "獲取 Olm 發布版本時出錯。", - "olmErrorFetchLatest": "獲取最新 Olm 發布版本時出錯。", + "sitesFetchFailed": "取得站台失敗", + "sitesFetchError": "取得站台時出錯。", + "olmErrorFetchReleases": "取得 Olm 發布版本時出錯。", + "olmErrorFetchLatest": "取得最新 Olm 發布版本時出錯。", "enterCidrRange": "輸入 CIDR 範圍", "resourceEnableProxy": "啟用公共代理", - "resourceEnableProxyDescription": "啟用到此資源的公共代理。這允許外部網路通過開放埠訪問資源。需要 Traefik 配置。", + "resourceEnableProxyDescription": "啟用到此資源的公共代理。這允許外部網路通過開放埠存取資源。需要 Traefik 設定。", "externalProxyEnabled": "外部代理已啟用", - "addNewTarget": "添加新目標", + "addNewTarget": "新增新目標", "targetsList": "目標列表", "advancedMode": "高級模式", "advancedSettings": "進階設定", @@ -1537,8 +1532,8 @@ "healthCheckUnhealthy": "不正常", "healthCheckUnknown": "未知", "healthCheck": "健康檢查", - "configureHealthCheck": "配置健康檢查", - "configureHealthCheckDescription": "為 {target} 設置健康監控", + "configureHealthCheck": "設定健康檢查", + "configureHealthCheckDescription": "為 {target} 設定健康監控", "enableHealthChecks": "啟用健康檢查", "enableHealthChecksDescription": "監視此目標的健康狀況。如果需要,您可以監視一個不同的終點。", "healthScheme": "方法", @@ -1561,9 +1556,9 @@ "headersValidationError": "Header 必須是格式:Header 名稱:值。", "saveHealthCheck": "保存健康檢查", "healthCheckSaved": "健康檢查已保存", - "healthCheckSavedDescription": "健康檢查配置已成功保存。", + "healthCheckSavedDescription": "健康檢查設定已成功保存。", "healthCheckError": "健康檢查錯誤", - "healthCheckErrorDescription": "保存健康檢查配置時出錯", + "healthCheckErrorDescription": "保存健康檢查設定時出錯", "healthCheckPathRequired": "健康檢查路徑為必填項", "healthCheckMethodRequired": "HTTP 方法為必填項", "healthCheckIntervalMin": "檢查間隔必須至少為 5 秒", @@ -1573,19 +1568,19 @@ "selectHttpMethod": "選擇 HTTP 方法", "domainPickerSubdomainLabel": "子域名", "domainPickerBaseDomainLabel": "根域名", - "domainPickerSearchDomains": "搜索域名...", + "domainPickerSearchDomains": "搜尋域名...", "domainPickerNoDomainsFound": "未找到域名", "domainPickerLoadingDomains": "載入域名...", "domainPickerSelectBaseDomain": "選擇根域名...", "domainPickerNotAvailableForCname": "不適用於 CNAME 域", "domainPickerEnterSubdomainOrLeaveBlank": "輸入子域名或留空以使用根域名。", - "domainPickerEnterSubdomainToSearch": "輸入一個子域名以搜索並從可用免費域名中選擇。", + "domainPickerEnterSubdomainToSearch": "輸入一個子域名以搜尋並從可用免費域名中選擇。", "domainPickerFreeDomains": "免費域名", - "domainPickerSearchForAvailableDomains": "搜索可用域名", + "domainPickerSearchForAvailableDomains": "搜尋可用域名", "domainPickerNotWorkSelfHosted": "注意:自託管實例當前不提供免費的域名。", "resourceDomain": "域名", "resourceEditDomain": "編輯域名", - "siteName": "站點名稱", + "siteName": "站台名稱", "proxyPort": "埠", "resourcesTableProxyResources": "代理資源", "resourcesTableClientResources": "用戶端資源", @@ -1594,20 +1589,19 @@ "resourcesTableDestination": "目標", "resourcesTableAlias": "別名", "resourcesTableClients": "用戶端", - "resourcesTableAndOnlyAccessibleInternally": "且僅在與用戶端連接時可內部訪問。", + "resourcesTableAndOnlyAccessibleInternally": "且僅在與用戶端連接時可內部存取。", "resourcesTableNoTargets": "無目標", "resourcesTableHealthy": "健康", "resourcesTableDegraded": "降級", - "resourcesTableOffline": "離線", "resourcesTableUnknown": "未知", "resourcesTableNotMonitored": "未監控", "editInternalResourceDialogEditClientResource": "編輯用戶端資源", - "editInternalResourceDialogUpdateResourceProperties": "更新 {resourceName} 的資源屬性和目標配置。", + "editInternalResourceDialogUpdateResourceProperties": "更新 {resourceName} 的資源屬性和目標設定。", "editInternalResourceDialogResourceProperties": "資源屬性", "editInternalResourceDialogName": "名稱", "editInternalResourceDialogProtocol": "協議", - "editInternalResourceDialogSitePort": "站點埠", - "editInternalResourceDialogTargetConfiguration": "目標配置", + "editInternalResourceDialogSitePort": "站台埠", + "editInternalResourceDialogTargetConfiguration": "目標設定", "editInternalResourceDialogCancel": "取消", "editInternalResourceDialogSaveResource": "保存資源", "editInternalResourceDialogSuccess": "成功", @@ -1627,38 +1621,38 @@ "editInternalResourceDialogModeHost": "主機", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "目的地", - "editInternalResourceDialogDestinationHostDescription": "站點網路上資源的 IP 位址或主機名稱。", - "editInternalResourceDialogDestinationIPDescription": "站點網路上資源的 IP 或主機名稱位址。", - "editInternalResourceDialogDestinationCidrDescription": "站點網路上資源的 CIDR 範圍。", + "editInternalResourceDialogDestinationHostDescription": "站台網路上資源的 IP 位址或主機名稱。", + "editInternalResourceDialogDestinationIPDescription": "站台網路上資源的 IP 或主機名稱位址。", + "editInternalResourceDialogDestinationCidrDescription": "站台網路上資源的 CIDR 範圍。", "editInternalResourceDialogAlias": "別名", "editInternalResourceDialogAliasDescription": "此資源的可選內部 DNS 別名。", - "createInternalResourceDialogNoSitesAvailable": "暫無可用站點", - "createInternalResourceDialogNoSitesAvailableDescription": "您需要至少配置一個子網的 Newt 站點來創建內部資源。", + "createInternalResourceDialogNoSitesAvailable": "暫無可用站台", + "createInternalResourceDialogNoSitesAvailableDescription": "您需要至少設定一個子網的 Newt 站台來建立內部資源。", "createInternalResourceDialogClose": "關閉", - "createInternalResourceDialogCreateClientResource": "創建用戶端資源", - "createInternalResourceDialogCreateClientResourceDescription": "創建一個新資源,該資源將可供連接到所選站點的用戶端訪問。", + "createInternalResourceDialogCreateClientResource": "建立用戶端資源", + "createInternalResourceDialogCreateClientResourceDescription": "建立一個新資源,該資源將可供連接到所選站台的用戶端存取。", "createInternalResourceDialogResourceProperties": "資源屬性", "createInternalResourceDialogName": "名稱", - "createInternalResourceDialogSite": "站點", - "selectSite": "選擇站點...", - "noSitesFound": "找不到站點。", + "createInternalResourceDialogSite": "站台", + "selectSite": "選擇站台...", + "noSitesFound": "找不到站台。", "createInternalResourceDialogProtocol": "協議", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", - "createInternalResourceDialogSitePort": "站點埠", - "createInternalResourceDialogSitePortDescription": "使用此埠在連接到用戶端時訪問站點上的資源。", - "createInternalResourceDialogTargetConfiguration": "目標配置", - "createInternalResourceDialogDestinationIPDescription": "站點網路上資源的 IP 或主機名地址。", - "createInternalResourceDialogDestinationPortDescription": "資源在目標 IP 上可訪問的埠。", + "createInternalResourceDialogSitePort": "站台埠", + "createInternalResourceDialogSitePortDescription": "使用此埠在連接到用戶端時存取站台上的資源。", + "createInternalResourceDialogTargetConfiguration": "目標設定", + "createInternalResourceDialogDestinationIPDescription": "站台網路上資源的 IP 或主機名位址。", + "createInternalResourceDialogDestinationPortDescription": "資源在目標 IP 上可存取的埠。", "createInternalResourceDialogCancel": "取消", - "createInternalResourceDialogCreateResource": "創建資源", + "createInternalResourceDialogCreateResource": "建立資源", "createInternalResourceDialogSuccess": "成功", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "內部資源創建成功", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "內部資源建立成功", "createInternalResourceDialogError": "錯誤", - "createInternalResourceDialogFailedToCreateInternalResource": "創建內部資源失敗", + "createInternalResourceDialogFailedToCreateInternalResource": "建立內部資源失敗", "createInternalResourceDialogNameRequired": "名稱為必填項", "createInternalResourceDialogNameMaxLength": "名稱長度必須小於 255 個字元", - "createInternalResourceDialogPleaseSelectSite": "請選擇一個站點", + "createInternalResourceDialogPleaseSelectSite": "請選擇一個站台", "createInternalResourceDialogProxyPortMin": "代理埠必須至少為 1", "createInternalResourceDialogProxyPortMax": "代理埠必須小於 65536", "createInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式", @@ -1670,48 +1664,48 @@ "createInternalResourceDialogModeHost": "主機", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "目的地", - "createInternalResourceDialogDestinationHostDescription": "站點網路上資源的 IP 位址或主機名稱。", - "createInternalResourceDialogDestinationCidrDescription": "站點網路上資源的 CIDR 範圍。", + "createInternalResourceDialogDestinationHostDescription": "站台網路上資源的 IP 位址或主機名稱。", + "createInternalResourceDialogDestinationCidrDescription": "站台網路上資源的 CIDR 範圍。", "createInternalResourceDialogAlias": "別名", "createInternalResourceDialogAliasDescription": "此資源的可選內部 DNS 別名。", - "siteConfiguration": "配置", + "siteConfiguration": "設定", "siteAcceptClientConnections": "接受用戶端連接", "siteAcceptClientConnectionsDescription": "允許其他設備透過此 Newt 實例使用用戶端作為閘道器連接。", - "siteAddress": "站點地址", - "siteAddressDescription": "指定主機的 IP 位址以供用戶端連接。這是 Pangolin 網路中站點的內部地址,供用戶端訪問。必須在 Org 子網內。", - "siteNameDescription": "站點的顯示名稱,可以稍後更改。", - "autoLoginExternalIdp": "自動使用外部 IDP 登錄", - "autoLoginExternalIdpDescription": "立即將用戶重定向到外部 IDP 進行身份驗證。", + "siteAddress": "站台位址", + "siteAddressDescription": "指定主機的 IP 位址以供用戶端連接。這是 Pangolin 網路中站台的內部位址,供用戶端存取。必須在 Org 子網內。", + "siteNameDescription": "站台的顯示名稱,可以稍後更改。", + "autoLoginExternalIdp": "自動使用外部 IDP 登入", + "autoLoginExternalIdpDescription": "立即將使用者重定向到外部 IDP 進行身分驗證。", "selectIdp": "選擇 IDP", "selectIdpPlaceholder": "選擇一個 IDP...", - "selectIdpRequired": "在啟用自動登錄時,請選擇一個 IDP。", + "selectIdpRequired": "在啟用自動登入時,請選擇一個 IDP。", "autoLoginTitle": "重定向中", - "autoLoginDescription": "正在將您重定向到外部身份提供商進行身份驗證。", - "autoLoginProcessing": "準備身份驗證...", - "autoLoginRedirecting": "重定向到登錄...", - "autoLoginError": "自動登錄錯誤", - "autoLoginErrorNoRedirectUrl": "未從身份提供商收到重定向 URL。", - "autoLoginErrorGeneratingUrl": "生成身份驗證 URL 失敗。", + "autoLoginDescription": "正在將您重定向到外部身分提供者進行身分驗證。", + "autoLoginProcessing": "準備身分驗證...", + "autoLoginRedirecting": "重定向到登入...", + "autoLoginError": "自動登入錯誤", + "autoLoginErrorNoRedirectUrl": "未從身分提供者收到重定向 URL。", + "autoLoginErrorGeneratingUrl": "生成身分驗證 URL 失敗。", "remoteExitNodeManageRemoteExitNodes": "遠程節點", - "remoteExitNodeDescription": "自我主機一個或多個遠程節點來擴展您的網路連接並減少對雲的依賴性", + "remoteExitNodeDescription": "自我主機一個或多個遠程節點來擴充您的網路連接並減少對雲的依賴性", "remoteExitNodes": "節點", - "searchRemoteExitNodes": "搜索節點...", - "remoteExitNodeAdd": "添加節點", + "searchRemoteExitNodes": "搜尋節點...", + "remoteExitNodeAdd": "新增節點", "remoteExitNodeErrorDelete": "刪除節點時出錯", "remoteExitNodeQuestionRemove": "您確定要從組織中刪除該節點嗎?", - "remoteExitNodeMessageRemove": "一旦刪除,該節點將不再能夠訪問。", + "remoteExitNodeMessageRemove": "一旦刪除,該節點將不再能夠存取。", "remoteExitNodeConfirmDelete": "確認刪除節點", "remoteExitNodeDelete": "刪除節點", "sidebarRemoteExitNodes": "遠程節點", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "密鑰", "remoteExitNodeCreate": { - "title": "創建節點", - "description": "創建一個新節點來擴展您的網路連接", + "title": "建立節點", + "description": "建立一個新節點來擴充您的網路連接", "viewAllButton": "查看所有節點", "strategy": { - "title": "創建策略", - "description": "選擇此選項以手動配置您的節點或生成新憑據。", + "title": "建立策略", + "description": "選擇此選項以手動設定您的節點或生成新憑據。", "adopt": { "title": "採納節點", "description": "如果您已經擁有該節點的憑據,請選擇此項。" @@ -1732,12 +1726,12 @@ }, "generate": { "title": "生成的憑據", - "description": "使用這些生成的憑據來配置您的節點", + "description": "使用這些生成的憑據來設定您的節點", "nodeIdTitle": "節點 ID", "secretTitle": "金鑰", - "saveCredentialsTitle": "將憑據添加到配置中", - "saveCredentialsDescription": "將這些憑據添加到您的自託管 Pangolin 節點設定檔中以完成連接。", - "submitButton": "創建節點" + "saveCredentialsTitle": "將憑據新增到設定中", + "saveCredentialsDescription": "將這些憑據新增到您的自託管 Pangolin 節點設定檔中以完成連接。", + "submitButton": "建立節點" }, "validation": { "adoptRequired": "在通過現有節點時需要節點ID和金鑰" @@ -1745,17 +1739,17 @@ "errors": { "loadDefaultsFailed": "無法載入預設值", "defaultsNotLoaded": "預設值未載入", - "createFailed": "創建節點失敗" + "createFailed": "建立節點失敗" }, "success": { - "created": "節點創建成功" + "created": "節點建立成功" } }, "remoteExitNodeSelection": "節點選擇", - "remoteExitNodeSelectionDescription": "為此本地站點選擇要路由流量的節點", - "remoteExitNodeRequired": "必須為本地站點選擇節點", + "remoteExitNodeSelectionDescription": "為此本地站台選擇要路由流量的節點", + "remoteExitNodeRequired": "必須為本地站台選擇節點", "noRemoteExitNodesAvailable": "無可用節點", - "noRemoteExitNodesAvailableDescription": "此組織沒有可用的節點。首先創建一個節點來使用本地站點。", + "noRemoteExitNodesAvailableDescription": "此組織沒有可用的節點。首先建立一個節點來使用本地站台。", "exitNode": "出口節點", "country": "國家", "rulesMatchCountry": "當前基於源 IP", @@ -1763,15 +1757,15 @@ "title": "託管自託管", "description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能", "introTitle": "託管式自架 Pangolin", - "introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。", - "introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 - - 您的隧道、TLS 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:", + "introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的資料的私密性和自我託管性。", + "introDetail": "通過此選項,您仍然執行您自己的 Pangolin 節點 - - 您的隧道、TLS 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監控是通過我們的雲層儀表板進行的,該儀表板開啟了一些好處:", "benefitSimplerOperations": { "title": "簡單的操作", - "description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。" + "description": "無需執行您自己的郵件伺服器或設定複雜的警報。您將從方框中獲得健康檢查和下限提醒。" }, "benefitAutomaticUpdates": { "title": "自動更新", - "description": "雲儀錶板快速演化,所以您可以獲得新的功能和錯誤修復,而不必每次手動拉取新的容器。" + "description": "雲儀表板快速演化,所以您可以獲得新的功能和錯誤修復,而不必每次手動拉取新的容器。" }, "benefitLessMaintenance": { "title": "減少維護時間", @@ -1783,11 +1777,11 @@ }, "benefitHighAvailability": { "title": "高可用率(PoPs)", - "description": "您還可以將多個節點添加到您的帳戶中以獲取冗餘和更好的性能。" + "description": "您還可以將多個節點新增到您的帳戶中以取得冗餘和更好的性能。" }, "benefitFutureEnhancements": { "title": "將來的改進", - "description": "我們正在計劃添加更多的分析、警報和管理工具,使你的部署更加有力。" + "description": "我們正在計劃新增更多的分析、警報和管理工具,使你的部署更加有力。" }, "docsAlert": { "text": "在我們中更多地了解管理下的自託管選項", @@ -1797,22 +1791,22 @@ }, "internationaldomaindetected": "檢測到國際域", "willbestoredas": "儲存為:", - "roleMappingDescription": "確定當用戶啟用自動配送時如何分配他們的角色。", + "roleMappingDescription": "確定當使用者啟用自動配送時如何分配他們的角色。", "selectRole": "選擇角色", "roleMappingExpression": "表達式", "selectRolePlaceholder": "選擇角色", - "selectRoleDescription": "選擇一個角色,從此身份提供商分配給所有用戶", - "roleMappingExpressionDescription": "輸入一個 JMESPath 表達式來從 ID 令牌提取角色資訊", + "selectRoleDescription": "選擇一個角色,從此身分提供者分配給所有使用者", + "roleMappingExpressionDescription": "輸入一個 JMESPath 表達式來從 ID 權杖提取角色資訊", "idpTenantIdRequired": "租戶 ID 是必需的", "invalidValue": "無效的值", - "idpTypeLabel": "身份提供者類型", + "idpTypeLabel": "身分提供者類型", "roleMappingExpressionPlaceholder": "例如: contains(group, 'admin' &'Admin' || 'Member'", - "idpGoogleConfiguration": "Google 配置", - "idpGoogleConfigurationDescription": "配置您的 Google OAuth2 憑據", + "idpGoogleConfiguration": "Google 設定", + "idpGoogleConfigurationDescription": "設定您的 Google OAuth2 憑據", "idpGoogleClientIdDescription": "您的 Google OAuth2 用戶端 ID", "idpGoogleClientSecretDescription": "您的 Google OAuth2 用戶端金鑰", - "idpAzureConfiguration": "Azure Entra ID 配置", - "idpAzureConfigurationDescription": "配置您的 Azure Entra ID OAuth2 憑據", + "idpAzureConfiguration": "Azure Entra ID 設定", + "idpAzureConfigurationDescription": "設定您的 Azure Entra ID OAuth2 憑據", "idpTenantId": "租戶 ID", "idpTenantIdPlaceholder": "您的租戶 ID", "idpAzureTenantIdDescription": "您的 Azure 租戶ID (在 Azure Active Directory 概覽中發現)", @@ -1822,18 +1816,18 @@ "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", - "idpGoogleConfigurationTitle": "Google 配置", - "idpAzureConfigurationTitle": "Azure Entra ID 配置", + "idpGoogleConfigurationTitle": "Google 設定", + "idpAzureConfigurationTitle": "Azure Entra ID 設定", "idpTenantIdLabel": "租戶 ID", "idpAzureClientIdDescription2": "您的 Azure 應用程式註冊用戶端 ID", "idpAzureClientSecretDescription2": "您的 Azure 應用程式註冊用戶端金鑰", - "idpGoogleDescription": "Google OAuth2/OIDC 提供商", + "idpGoogleDescription": "Google OAuth2/OIDC 提供者", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC 提供者", "subnet": "子網", - "subnetDescription": "此組織網路配置的子網。", + "subnetDescription": "此組織網路設定的子網。", "customDomain": "自訂網域", "authPage": "認證頁面", - "authPageDescription": "配置您的組織認證頁面", + "authPageDescription": "設定您的組織認證頁面", "authPageDomain": "認證頁面域", "authPageBranding": "自訂品牌", "authPageBrandingDescription": "設定此組織驗證頁面上顯示的品牌", @@ -1855,15 +1849,15 @@ "saveAuthPageDomain": "儲存網域", "saveAuthPageBranding": "儲存品牌", "removeAuthPageBranding": "移除品牌", - "noDomainSet": "沒有域設置", + "noDomainSet": "沒有域設定", "changeDomain": "更改域", "selectDomain": "選擇域", "restartCertificate": "重新啟動證書", "editAuthPageDomain": "編輯認證頁面域", - "setAuthPageDomain": "設置認證頁面域", - "failedToFetchCertificate": "獲取證書失敗", + "setAuthPageDomain": "設定認證頁面域", + "failedToFetchCertificate": "取得證書失敗", "failedToRestartCertificate": "重新啟動證書失敗", - "addDomainToEnableCustomAuthPages": "為您的組織添加域名以啟用自訂認證頁面", + "addDomainToEnableCustomAuthPages": "為您的組織新增域名以啟用自訂認證頁面", "selectDomainForOrgAuthPage": "選擇組織認證頁面的域", "domainPickerProvidedDomain": "提供的域", "domainPickerFreeProvidedDomain": "免費提供的域", @@ -1878,10 +1872,10 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" 無法為 {domain} 變為有效。", "domainPickerSubdomainSanitized": "子域已淨化", "domainPickerSubdomainCorrected": "\"{sub}\" 已被更正為 \"{sanitized}\"", - "orgAuthSignInTitle": "登錄到您的組織", - "orgAuthChooseIdpDescription": "選擇您的身份提供商以繼續", - "orgAuthNoIdpConfigured": "此機構沒有配置任何身份提供者。您可以使用您的 Pangolin 身份登錄。", - "orgAuthSignInWithPangolin": "使用 Pangolin 登錄", + "orgAuthSignInTitle": "登入到您的組織", + "orgAuthChooseIdpDescription": "選擇您的身分提供者以繼續", + "orgAuthNoIdpConfigured": "此機構沒有設定任何身分提供者。您可以使用您的 Pangolin 身分登入。", + "orgAuthSignInWithPangolin": "使用 Pangolin 登入", "orgAuthSignInToOrg": "登入組織", "orgAuthSelectOrgTitle": "組織登入", "orgAuthSelectOrgDescription": "輸入您的組織 ID 以繼續", @@ -1892,28 +1886,28 @@ "orgAuthBackToSignIn": "返回標準登入", "orgAuthNoAccount": "沒有帳戶?", "subscriptionRequiredToUse": "需要訂閱才能使用此功能。", - "idpDisabled": "身份提供者已禁用。", + "idpDisabled": "身分提供者已禁用。", "orgAuthPageDisabled": "組織認證頁面已禁用。", "domainRestartedDescription": "域驗證重新啟動成功", - "resourceAddEntrypointsEditFile": "編輯文件:config/traefik/traefik_config.yml", - "resourceExposePortsEditFile": "編輯文件:docker-compose.yml", - "emailVerificationRequired": "需要電子郵件驗證。 請通過 {dashboardUrl}/auth/login 再次登錄以完成此步驟。 然後,回到這裡。", - "twoFactorSetupRequired": "需要設置雙因素身份驗證。 請通過 {dashboardUrl}/auth/login 再次登錄以完成此步驟。 然後,回到這裡。", + "resourceAddEntrypointsEditFile": "編輯檔案:config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "編輯檔案:docker-compose.yml", + "emailVerificationRequired": "需要電子郵件驗證。 請通過 {dashboardUrl}/auth/login 再次登入以完成此步驟。 然後,回到這裡。", + "twoFactorSetupRequired": "需要設定雙因素身分驗證。 請通過 {dashboardUrl}/auth/login 再次登入以完成此步驟。 然後,回到這裡。", "additionalSecurityRequired": "需要額外的安全", - "organizationRequiresAdditionalSteps": "這個組織需要額外的安全步驟才能訪問資源。", + "organizationRequiresAdditionalSteps": "這個組織需要額外的安全步驟才能存取資源。", "completeTheseSteps": "完成這些步驟", "enableTwoFactorAuthentication": "啟用兩步驗證", "completeSecuritySteps": "完成安全步驟", "securitySettings": "安全設定", "dangerSection": "危險區域", "dangerSectionDescription": "永久刪除與此組織相關的所有資料", - "securitySettingsDescription": "配置您組織的安全策略", - "requireTwoFactorForAllUsers": "所有用戶需要兩步驗證", - "requireTwoFactorDescription": "如果啟用,此組織的所有內部用戶必須啟用雙重身份驗證才能訪問組織。", + "securitySettingsDescription": "設定您組織的安全策略", + "requireTwoFactorForAllUsers": "所有使用者需要兩步驗證", + "requireTwoFactorDescription": "如果啟用,此組織的所有內部使用者必須啟用雙重身分驗證才能存取組織。", "requireTwoFactorDisabledDescription": "此功能需要有效的許可證(企業)或活動訂閱(SaS)", - "requireTwoFactorCannotEnableDescription": "您必須為您的帳戶啟用雙重身份驗證才能對所有用戶", + "requireTwoFactorCannotEnableDescription": "您必須為您的帳戶啟用雙重身分驗證才能對所有使用者", "maxSessionLength": "最大會話長度", - "maxSessionLengthDescription": "設置用戶會話的最長時間。此後用戶需要重新驗證。", + "maxSessionLengthDescription": "設定使用者會話的最長時間。此後使用者需要重新驗證。", "maxSessionLengthDisabledDescription": "此功能需要有效的許可證(企業)或活動訂閱(SaS)", "selectSessionLength": "選擇會話長度", "unenforced": "未執行", @@ -1929,7 +1923,7 @@ "90DaysSession": "90 天", "180DaysSession": "180天", "passwordExpiryDays": "密碼過期", - "editPasswordExpiryDescription": "設置用戶需要更改密碼之前的天數。", + "editPasswordExpiryDescription": "設定使用者需要更改密碼之前的天數。", "selectPasswordExpiry": "選擇密碼過期", "30Days": "30 天", "1Day": "1天", @@ -1939,34 +1933,34 @@ "1Year": "1 年", "subscriptionBadge": "需要訂閱", "securityPolicyChangeWarning": "安全政策更改警告", - "securityPolicyChangeDescription": "您即將更改安全政策設置。保存後,您可能需要重新認證以遵守這些政策更新。 所有不符合要求的用戶也需要重新認證。", + "securityPolicyChangeDescription": "您即將更改安全政策設定。保存後,您可能需要重新認證以遵守這些政策更新。 所有不符合要求的使用者也需要重新認證。", "securityPolicyChangeConfirmMessage": "我確認", - "securityPolicyChangeWarningText": "這將影響組織中的所有用戶", - "authPageErrorUpdateMessage": "更新身份驗證頁面設置時出錯", + "securityPolicyChangeWarningText": "這將影響組織中的所有使用者", + "authPageErrorUpdateMessage": "更新身分驗證頁面設定時出錯", "authPageErrorUpdate": "無法更新認證頁面", "authPageDomainUpdated": "驗證頁面網域更新成功", "healthCheckNotAvailable": "本地的", "rewritePath": "重寫路徑", "rewritePathDescription": "在轉發到目標之前,可以選擇重寫路徑。", - "continueToApplication": "繼續應用", + "continueToApplication": "繼續前往應用程式", "checkingInvite": "正在檢查邀請", - "setResourceHeaderAuth": "設置 ResourceHeaderAuth", - "resourceHeaderAuthRemove": "移除 Header 身份驗證", - "resourceHeaderAuthRemoveDescription": "已成功刪除 Header 身份驗證。", - "resourceErrorHeaderAuthRemove": "刪除 Header 身份驗證失敗", - "resourceErrorHeaderAuthRemoveDescription": "無法刪除資源的 Header 身份驗證。", + "setResourceHeaderAuth": "設定 ResourceHeaderAuth", + "resourceHeaderAuthRemove": "移除 Header 身分驗證", + "resourceHeaderAuthRemoveDescription": "已成功刪除 Header 身分驗證。", + "resourceErrorHeaderAuthRemove": "刪除 Header 身分驗證失敗", + "resourceErrorHeaderAuthRemoveDescription": "無法刪除資源的 Header 身分驗證。", "resourceHeaderAuthProtectionEnabled": "Header 認證已啟用", - "resourceHeaderAuthProtectionDisabled": "Header 身份驗證已禁用", + "resourceHeaderAuthProtectionDisabled": "Header 身分驗證已禁用", "headerAuthRemove": "刪除 Header 認證", - "headerAuthAdd": "添加頁首認證", - "resourceErrorHeaderAuthSetup": "設置頁首認證失敗", - "resourceErrorHeaderAuthSetupDescription": "無法設置資源的 Header 身份驗證。", - "resourceHeaderAuthSetup": "Header 認證設置成功", - "resourceHeaderAuthSetupDescription": "Header 認證已成功設置。", - "resourceHeaderAuthSetupTitle": "設置 Header 身份驗證", - "resourceHeaderAuthSetupTitleDescription": "使用 HTTP 頭身份驗證來設置基本身份驗證資訊(使用者名稱和密碼)。使用 https://username:password@resource.example.com 訪問它", - "resourceHeaderAuthSubmit": "設置 Header 身份驗證", - "actionSetResourceHeaderAuth": "設置 Header 身份驗證", + "headerAuthAdd": "新增頁首認證", + "resourceErrorHeaderAuthSetup": "設定頁首認證失敗", + "resourceErrorHeaderAuthSetupDescription": "無法設定資源的 Header 身分驗證。", + "resourceHeaderAuthSetup": "Header 認證設定成功", + "resourceHeaderAuthSetupDescription": "Header 認證已成功設定。", + "resourceHeaderAuthSetupTitle": "設定 Header 身分驗證", + "resourceHeaderAuthSetupTitleDescription": "使用 HTTP 頭身分驗證來設定基本身分驗證資訊(使用者名稱和密碼)。使用 https://username:password@resource.example.com 存取它", + "resourceHeaderAuthSubmit": "設定 Header 身分驗證", + "actionSetResourceHeaderAuth": "設定 Header 身分驗證", "enterpriseEdition": "企業版", "unlicensed": "未授權", "beta": "測試版", @@ -1990,7 +1984,7 @@ "generateLicenseKey": "生成許可證金鑰", "generateLicenseKeyForm": { "validation": { - "emailRequired": "請輸入一個有效的電子郵件地址", + "emailRequired": "請輸入一個有效的電子郵件位址", "useCaseTypeRequired": "請選擇一個使用的案例類型", "firstNameRequired": "必填名", "lastNameRequired": "姓氏是必填項", @@ -2036,11 +2030,11 @@ "alerts": { "commercialUseDisclosure": { "title": "使用情況披露", - "description": "選擇能準確反映您預定用途的許可等級。 個人許可證允許對個人、非商業性或小型商業活動免費使用軟體,年收入毛額不到 100,000 美元。 超出這些限度的任何用途,包括在企業、組織內的用途。 或其他創收環境--需要有效的企業許可證和支付適用的許可證費用。 所有用戶,不論是個人還是企業,都必須遵守寄養商業許可證條款。" + "description": "選擇能準確反映您預定用途的許可等級。 個人許可證允許對個人、非商業性或小型商業活動免費使用軟體,年收入毛額不到 100,000 美元。 超出這些限度的任何用途,包括在企業、組織內的用途。 或其他創收環境--需要有效的企業許可證和支付適用的許可證費用。 所有使用者,不論是個人還是企業,都必須遵守寄養商業許可證條款。" }, "trialPeriodInformation": { "title": "試用期資訊", - "description": "此許可證金鑰使企業特性能夠持續 7 天的評價。 在評估期過後繼續訪問付費功能需要在有效的個人或企業許可證下啟用。對於企業許可證,請聯絡 Sales@pangolin.net。" + "description": "此許可證金鑰使企業特性能夠持續 7 天的評價。 在評估期過後繼續存取付費功能需要在有效的個人或企業許可證下啟用。對於企業許可證,請聯絡 Sales@pangolin.net。" } }, "form": { @@ -2050,8 +2044,8 @@ "jobTitle": "工作頭銜:", "primaryUseQuestion": "您主要計劃使用 Pangolin 嗎?", "industryQuestion": "您的行業是什麼?", - "prospectiveUsersQuestion": "您期望有多少預期用戶?", - "prospectiveSitesQuestion": "您期望有多少站點(隧道)?", + "prospectiveUsersQuestion": "您期望有多少預期使用者?", + "prospectiveSitesQuestion": "您期望有多少站台(隧道)?", "companyName": "公司名稱", "countryOfResidence": "居住國", "stateProvinceRegion": "州/省/地區", @@ -2082,8 +2076,8 @@ "priority": "優先權", "priorityDescription": "先評估更高優先度線路。優先度 = 100 意味著自動排序(系統決定). 使用另一個數字強制執行手動優先度。", "instanceName": "實例名稱", - "pathMatchModalTitle": "配置路徑匹配", - "pathMatchModalDescription": "根據傳入請求的路徑設置匹配方式。", + "pathMatchModalTitle": "設定路徑匹配", + "pathMatchModalDescription": "根據傳入請求的路徑設定匹配方式。", "pathMatchType": "匹配類型", "pathMatchPrefix": "前綴", "pathMatchExact": "精準的", @@ -2096,7 +2090,7 @@ "pathMatchPrefixHelp": "範例: /api 匹配/api, /api/users 等。", "pathMatchExactHelp": "範例:/api 匹配僅限/api", "pathMatchRegexHelp": "例如:^/api/.* 匹配/api/why", - "pathRewriteModalTitle": "配置路徑重寫", + "pathRewriteModalTitle": "設定路徑重寫", "pathRewriteModalDescription": "在轉發到目標之前變換匹配的路徑。", "pathRewriteType": "重寫類型", "pathRewritePrefixOption": "前綴 - 替換前綴", @@ -2118,26 +2112,26 @@ "sidebarEnableEnterpriseLicense": "啟用企業許可證", "cannotbeUndone": "無法撤消。", "toConfirm": "確認", - "deleteClientQuestion": "您確定要從站點和組織中刪除客戶嗎?", - "clientMessageRemove": "一旦刪除,用戶端將無法連接到站點。", + "deleteClientQuestion": "您確定要從站台和組織中刪除客戶嗎?", + "clientMessageRemove": "一旦刪除,用戶端將無法連接到站台。", "sidebarLogs": "日誌", "request": "請求", "requests": "請求", "logs": "日誌", "logsSettingsDescription": "監視從此 orginization 中收集的日誌", - "searchLogs": "搜索日誌...", + "searchLogs": "搜尋日誌...", "action": "行動", "actor": "執行者", "timestamp": "時間戳", - "accessLogs": "訪問日誌", - "exportCsv": "導出 CSV", + "accessLogs": "存取日誌", + "exportCsv": "匯出 CSV", "exportError": "匯出 CSV 時發生未知錯誤", "exportCsvTooltip": "在時間範圍內", "actorId": "執行者 ID", "allowedByRule": "根據規則允許", "allowedNoAuth": "無認證", - "validAccessToken": "有效訪問令牌", - "validHeaderAuth": "有效的 Header 身份驗證", + "validAccessToken": "有效存取權杖", + "validHeaderAuth": "有效的 Header 身分驗證", "validPincode": "有效的 Pincode", "validPassword": "有效密碼", "validEmail": "有效的 email", @@ -2145,7 +2139,7 @@ "resourceBlocked": "資源被阻止", "droppedByRule": "被規則刪除", "noSessions": "無會話", - "temporaryRequestToken": "臨時請求令牌", + "temporaryRequestToken": "臨時請求權杖", "noMoreAuthMethods": "無有效授權", "ip": "IP", "reason": "原因", @@ -2155,7 +2149,7 @@ "location": "地點", "actionLogs": "操作日誌", "sidebarLogsRequest": "請求日誌", - "sidebarLogsAccess": "訪問日誌", + "sidebarLogsAccess": "存取日誌", "sidebarLogsAction": "操作日誌", "logRetention": "日誌保留", "logRetentionDescription": "管理不同類型的日誌為這個機構保留多長時間或禁用這些日誌", @@ -2163,8 +2157,8 @@ "requestAnalyticsDescription": "查看此組織資源的詳細請求分析", "logRetentionRequestLabel": "請求日誌保留", "logRetentionRequestDescription": "保留請求日誌的時間", - "logRetentionAccessLabel": "訪問日誌保留", - "logRetentionAccessDescription": "保留訪問日誌的時間", + "logRetentionAccessLabel": "存取日誌保留", + "logRetentionAccessDescription": "保留存取日誌的時間", "logRetentionActionLabel": "動作日誌保留", "logRetentionActionDescription": "保留操作日誌的時間", "logRetentionDisabled": "已禁用", @@ -2176,7 +2170,7 @@ "logRetentionForever": "永遠的", "logRetentionEndOfFollowingYear": "次年年底", "actionLogsDescription": "查看此機構執行的操作歷史", - "accessLogsDescription": "查看此機構資源的訪問認證請求", + "accessLogsDescription": "查看此機構資源的存取認證請求", "licenseRequiredToUse": "需要企業許可證才能使用此功能。", "certResolver": "證書解決器", "certResolverDescription": "選擇用於此資源的證書解析器。", @@ -2184,50 +2178,49 @@ "enterCustomResolver": "輸入自訂解析器", "preferWildcardCert": "喜歡通配符證書", "unverified": "未驗證", - "domainSetting": "域設置", - "domainSettingDescription": "配置您的域的設置", - "preferWildcardCertDescription": "嘗試生成通配符證書(需要正確配置的證書解析器)。", + "domainSetting": "域設定", + "domainSettingDescription": "設定您的域的設定", + "preferWildcardCertDescription": "嘗試生成通配符證書(需要正確設定的證書解析器)。", "recordName": "記錄名稱", "auto": "自動操作", "TTL": "TTL", - "howToAddRecords": "如何添加記錄", + "howToAddRecords": "如何新增記錄", "dnsRecord": "DNS 記錄", "required": "必填", - "domainSettingsUpdated": "域設置更新成功", + "domainSettingsUpdated": "域設定更新成功", "orgOrDomainIdMissing": "缺少機構或域 ID", "loadingDNSRecords": "正在載入 DNS 記錄...", - "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。請更新到最新版本以獲取最佳體驗。", + "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。請更新到最新版本以取得最佳體驗。", "client": "用戶端:", - "proxyProtocol": "代理協議設置", - "proxyProtocolDescription": "配置代理協議以保留 TCP/UDP 服務的用戶端 IP 位址。", + "proxyProtocol": "代理協議設定", + "proxyProtocolDescription": "設定代理協議以保留 TCP/UDP 服務的用戶端 IP 位址。", "enableProxyProtocol": "啟用代理協議", "proxyProtocolInfo": "為 TCP/UDP 後端保留用戶端 IP 位址", "proxyProtocolVersion": "代理協議版本", "version1": " 版本 1 (推薦)", "version2": "版本 2", - "versionDescription": "版本 1 是基於文本和廣泛支持的版本。版本 2 是二進制和更有效率但不那麼相容。", "warning": "警告", - "proxyProtocolWarning": "您的後端應用程式必須配置為接受代理協議連接。如果您的後端不支持代理協議,啟用這將會中斷所有連接。 請務必從 Traefik 配置您的後端到信任代理協議標題。", - "restarting": "正在重啟...", + "proxyProtocolWarning": "您的後端應用程式必須設定為接受代理協議連接。如果您的後端不支援代理協議,啟用這將會中斷所有連接。 請務必從 Traefik 設定您的後端到信任代理協議標題。", + "restarting": "正在重新啟動...", "manual": "手動模式", - "messageSupport": "消息支持", - "supportNotAvailableTitle": "支持不可用", - "supportNotAvailableDescription": "支持現在不可用。您可以發送電子郵件到 support@pangolin.net。", - "supportRequestSentTitle": "支持請求已發送", - "supportRequestSentDescription": "您的消息已成功發送。", - "supportRequestFailedTitle": "發送請求失敗", - "supportRequestFailedDescription": "發送您的支持請求時出錯。", + "messageSupport": "訊息支援", + "supportNotAvailableTitle": "支援不可用", + "supportNotAvailableDescription": "支援現在不可用。您可以傳送電子郵件到 support@pangolin.net。", + "supportRequestSentTitle": "支援請求已傳送", + "supportRequestSentDescription": "您的訊息已成功傳送。", + "supportRequestFailedTitle": "傳送請求失敗", + "supportRequestFailedDescription": "傳送您的支援請求時出錯。", "supportSubjectRequired": "主題是必填項", "supportSubjectMaxLength": "主題必須是 255 個或更少的字元", - "supportMessageRequired": "消息是必填項", + "supportMessageRequired": "訊息是必填項", "supportReplyTo": "回復給", "supportSubject": "議題", "supportSubjectPlaceholder": "輸入主題", "supportMessage": "留言", - "supportMessagePlaceholder": "輸入您的消息", - "supportSending": "正在發送...", - "supportSend": "發送", - "supportMessageSent": "消息已發送!", + "supportMessagePlaceholder": "輸入您的訊息", + "supportSending": "正在傳送...", + "supportSend": "傳送", + "supportMessageSent": "訊息已傳送!", "supportWillContact": "我們很快就會聯繫起來!", "selectLogRetention": "選擇保留日誌", "terms": "條款", @@ -2272,8 +2265,8 @@ "columnVisibility": "列可見性", "toggleColumn": "切換 {columnName} 列", "allColumns": "全部列", - "defaultColumns": "默認列", - "customizeView": "自訂視圖", + "defaultColumns": "預設列", + "customizeView": "自訂檢視", "viewOptions": "查看選項", "selectAll": "選擇所有", "selectNone": "沒有選擇", @@ -2324,10 +2317,10 @@ "setupUtilitySubnet": "工具子網路(進階)", "setupUtilitySubnetDescription": "此組織別名位址和 DNS 伺服器的子網路。", "siteRegenerateAndDisconnect": "重新產生並斷開連接", - "siteRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此站點的連接嗎?", - "siteRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開站點連接。站點需要使用新憑證重新啟動。", - "siteRegenerateCredentialsConfirmation": "您確定要重新產生此站點的憑證嗎?", - "siteRegenerateCredentialsWarning": "這將重新產生憑證。站點將保持連接,直到您手動重新啟動並使用新憑證。", + "siteRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此站台的連接嗎?", + "siteRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開站台連接。站台需要使用新憑證重新啟動。", + "siteRegenerateCredentialsConfirmation": "您確定要重新產生此站台的憑證嗎?", + "siteRegenerateCredentialsWarning": "這將重新產生憑證。站台將保持連接,直到您手動重新啟動並使用新憑證。", "clientRegenerateAndDisconnect": "重新產生並斷開連接", "clientRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此客戶端的連接嗎?", "clientRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開客戶端連接。客戶端需要使用新憑證重新啟動。", @@ -2355,8 +2348,8 @@ "resourceLoginPageDescription": "自訂個別資源的登入頁面", "enterConfirmation": "輸入確認", "blueprintViewDetails": "詳細資訊", - "defaultIdentityProvider": "預設身份提供者", - "defaultIdentityProviderDescription": "當選擇預設身份提供者時,使用者將自動被重新導向到該提供者進行驗證。", + "defaultIdentityProvider": "預設身分提供者", + "defaultIdentityProviderDescription": "當選擇預設身分提供者時,使用者將自動被重新導向到該提供者進行驗證。", "editInternalResourceDialogNetworkSettings": "網路設定", "editInternalResourceDialogAccessPolicy": "存取策略", "editInternalResourceDialogAddRoles": "新增角色", @@ -2374,7 +2367,7 @@ "orgAuthWhatsThis": "我在哪裡可以找到我的組織 ID?", "learnMore": "了解更多", "backToHome": "返回首頁", - "needToSignInToOrg": "需要使用您組織的身份提供者嗎?", + "needToSignInToOrg": "需要使用您組織的身分提供者嗎?", "maintenanceMode": "維護模式", "maintenanceModeDescription": "向訪客顯示維護頁面", "maintenanceModeType": "維護模式類型", @@ -2400,5 +2393,1437 @@ "maintenanceScreenTitle": "服務暫時無法使用", "maintenanceScreenMessage": "我們目前遇到技術問題。請稍後再試。", "maintenanceScreenEstimatedCompletion": "預計完成時間:", - "createInternalResourceDialogDestinationRequired": "目的地為必填欄位" + "createInternalResourceDialogDestinationRequired": "目的地為必填欄位", + "contactSalesEnable": "聯絡銷售以啟用此功能。", + "contactSalesBookDemo": "預訂演示", + "contactSalesOr": "或", + "contactSalesContactUs": "聯絡我們", + "subscriptionViolationMessage": "您的當前計劃超出了您的限制。通過移除站台、使用者或其他資源以保持在您的計劃範圍內來糾正問題。", + "trialBannerMessage": "您的試用將在 {countdown} 到期。升級以保持存取。", + "trialBannerExpired": "您的試用已到期。立即升級以恢復存取。", + "billingTrialBannerTitle": "免費試用啟用中", + "billingTrialBannerDescription": "您目前正在商用層進行免費試用。試用結束後,您的賬戶將自動回到基礎層功能和限制。可隨時升級以保持當前計劃的功能存取。", + "billingTrialBannerUpgrade": "立即升級", + "billingTrialBadge": "免費試用", + "trialActive": "免費試用中", + "trialExpired": "試用到期", + "trialHasEnded": "您的試用已結束。", + "trialDaysRemaining": "{count, plural, other {# 天剩餘}}", + "trialDaysLeftShort": "試用期剩餘 {days} 天", + "trialGoToBilling": "轉到賬單頁面", + "subscriptionViolationViewBilling": "檢視賬單", + "siteConfirmDeleteAndResources": "確認刪除站台及資源", + "siteDeleteAndResources": "刪除站台及資源", + "siteMessageRemoveAndResources": "這將永久刪除與該站台關聯的所有公共和私人資源,即使資源也與其他站台相關聯。", + "siteQuestionRemoveAndResources": "您確定要刪除此站台及所有關聯資源嗎?", + "sitesTableDeleteSite": "刪除站台", + "sitesTableDeleteSiteAndResources": "刪除站台及資源", + "approvalsBannerTitle": "批准或拒絕裝置存取", + "approvalsBannerDescription": "稽核、批准或拒絕使用者的裝置存取請求。 當需要裝置批准時,使用者必須先獲得管理員批准,然後他們的裝置才能連線到您的組織資源。", + "approvalsBannerButtonText": "瞭解更多", + "sitesTableViewPublicResources": "檢視公共資源", + "sitesTableViewPrivateResources": "檢視私有資源", + "siteInstallKubernetesDocsDescription": "有關最新的 Kubernetes 安裝資訊,請參閱docs.pangolin.net/manage/sites/install-kubernetes。", + "siteInstallAdvantechDocsDescription": "有關 Advantech 數據機安裝說明,請參閱docs.pangolin.net/manage/sites/install-advantech。", + "siteRestartTitle": "重新啟動站台", + "siteRestartDescription": "重新啟動此站台的WireGuard隧道。此操作將暫時中斷連線。", + "siteRestartBody": "如果站台隧道無法正常工作,並且您希望在不重新啟動主機的情況下強制重新連線,請使用此選項。", + "siteRestartButton": "重新啟動站台", + "siteRestartDialogMessage": "確定要重新啟動{name}的WireGuard隧道嗎?站台將暫時斷開連線。", + "siteRestartWarning": "隧道重新啟動時,站台將暫時斷開連線。", + "siteRestarted": "站台已重新啟動", + "siteRestartedDescription": "WireGuard隧道已重新啟動。", + "siteErrorRestart": "重新啟動站台失敗", + "siteErrorRestartDescription": "重新啟動站台時發生錯誤。", + "siteResourcesTab": "資源", + "siteResourcesNoneOnSite": "此站台尚無公開或私人資源。", + "siteResourcesSectionPublic": "公共資源", + "siteResourcesSectionPrivate": "私有資源", + "siteResourcesSectionPublicDescription": "通過域名或埠公開的資源。", + "siteResourcesSectionPrivateDescription": "通過站台可在您的私有網路上存取的資源。", + "siteResourcesViewAllPublic": "檢視所有資源", + "siteResourcesViewAllPrivate": "檢視所有資源", + "siteResourcesDialogDescription": "此站台的公開和私有資源概覽。", + "siteResourcesShowMore": "顯示更多", + "siteResourcesPermissionDenied": "您無權列出這些資源。", + "siteResourcesEmptyPublic": "尚無針對該站台的公共資源。", + "siteResourcesEmptyPrivate": "尚無與此站台關聯的私有資源。", + "siteResourcesHowToAccess": "如何存取", + "siteResourcesTargetsOnSite": "此站台上的目標", + "shareDelete": "刪除共享連結", + "shareDeleteConfirm": "確認刪除共享連結", + "shareQuestionRemove": "您確定要刪除這個共享連結嗎?", + "shareMessageRemove": "刪除後,該連結將不再可用,使用它的任何人將失去對資源的存取許可權。", + "sharePathOptional": "路徑(可選)", + "sharePathDescription": "認證後,連結將把使用者重定向到此路徑。", + "machineSearch": "搜尋機", + "machinesSearch": "搜尋機器客戶端...", + "machineNotFound": "未找到任何機", + "userDeviceSearch": "搜尋使用者裝置", + "userDevicesSearch": "搜尋使用者裝置...", + "resourcePoliciesBannerTitle": "重用身分驗證和存取規則", + "resourcePoliciesBannerDescription": "共享資源策略可讓您定義身分驗證方法和存取規則,然後將其套用到多個公共資源。當您更新策略時,每個連結的資源都會自動繼承更改。", + "resourcePoliciesBannerButtonText": "瞭解更多", + "resourcePoliciesTitle": "管理公共資源策略", + "resourcePoliciesAttachedResourcesColumnTitle": "資源", + "resourcePoliciesAttachedResources": "{count} 個資源", + "resourcePoliciesAttachedResourcesCount": "{count, plural, other {# 個資源}}", + "resourcePoliciesAttachedResourcesEmpty": "沒有資源", + "resourcePoliciesDescription": "建立和管理身分驗證策略以控制對公共資源的存取", + "resourcePoliciesSearch": "搜尋策略……", + "resourcePoliciesAdd": "新增策略", + "resourcePoliciesDefaultBadgeText": "預設策略", + "resourcePoliciesCreate": "建立公共資源策略", + "resourcePoliciesCreateDescription": "按照以下步驟建立新策略", + "resourcePolicyName": "策略名稱", + "resourcePolicyNameDescription": "給此策略命名,以便在您的資源中識別它", + "resourcePolicyNamePlaceholder": "例如:內部存取策略", + "resourcePoliciesSeeAll": "檢視所有策略", + "resourcePolicyAuthMethodAdd": "新增身分驗證方法", + "resourcePolicyOtpEmailAdd": "新增 OTP 電子郵件", + "resourcePolicyRulesAdd": "新增規則", + "resourcePolicyAuthMethodsDescription": "通過額外的認證方法允許存取資源", + "resourcePolicyUsersRolesDescription": "設定哪些使用者和角色可以存取關聯的資源", + "rulesResourcePolicyDescription": "設定規則以控制與此策略關聯的存取資源", + "resourcePolicyMessageRemove": "一旦刪除,資源策略將不再可存取。所有與資源關聯的資源將取消關聯,並且沒有身分驗證。", + "resourcePolicyQuestionRemove": "您確定要從組織中刪除資源策略嗎?", + "resourceRawDescriptionCloud": "正在使用埠號使用 TCP/UDP 代理請求。需要站台連線到遠端節點。", + "resourcePublicCreate": "建立公共資源", + "resourcePublicCreateDescription": "按照以下步驟建立一個可通過網頁瀏覽器存取的新公共資源", + "resourceCreateGeneralDescription": "設定基本資源設定,包括名稱和類型", + "resourceCreateGeneral": "概覽", + "resourceDomainDescription": "資源將在此完全限定的域名上提供。", + "resourcePortDescription": "在 Pangolin 例項或節點上資源可存取的外部埠。", + "configure": "設定", + "back": "後退", + "resourcePolicyDelete": "刪除資源策略", + "resourcePolicyDeleteConfirm": "確認刪除資源策略", + "labelDelete": "刪除標籤", + "labelAdd": "新增標籤", + "labelCreateSuccessMessage": "標籤建立成功", + "labelDuplicateError": "標籤重複", + "labelDuplicateErrorDescription": "已存在具有該名稱的標籤。", + "labelEditSuccessMessage": "標籤修改成功", + "labelNameField": "標籤名稱", + "labelColorField": "標籤顏色", + "labelPlaceholder": "例:homelab", + "labelCreate": "建立標籤", + "createLabelDialogTitle": "建立標籤", + "createLabelDialogDescription": "建立一個可以附加到此組織的新標籤", + "labelEdit": "編輯標籤", + "editLabelDialogTitle": "更新標籤", + "editLabelDialogDescription": "編輯一個可以附加到此組織的標籤", + "labelDeleteConfirm": "確認刪除標籤", + "labelErrorDelete": "刪除標籤失敗", + "labelMessageRemove": "此操作是永久性的。所有用此標籤標記的網站、資源和客戶端將取消標記。", + "labelQuestionRemove": "您確定要將標籤從組織中移除嗎?", + "resourcePolicySettingDescription": "設定此公共資源策略上的設定", + "resourcePolicySetting": "{policyName} 設定", + "deleteAccount": "刪除帳戶", + "deleteAccountDescription": "永久刪除您的帳戶、您擁有的所有組織以及這些組織中的所有資料。此操作無法撤消。", + "deleteAccountButton": "刪除帳戶", + "deleteAccountConfirmTitle": "刪除帳戶", + "deleteAccountConfirmMessage": "這將永久擦除您的帳戶、您擁有的所有組織以及這些組織中的所有資料。這不能撤消。", + "deleteAccountConfirmString": "刪除帳戶", + "deleteAccountSuccess": "賬戶已刪除", + "deleteAccountSuccessMessage": "您的帳戶已被刪除。", + "deleteAccountError": "刪除帳戶失敗", + "deleteAccountPreviewAccount": "您的帳戶", + "deleteAccountPreviewOrgs": "您擁有的組織 (和所有資料)", + "accessUserManage": "管理使用者", + "accessUsersRoleFilterCount": "{count, plural, other {# 角色}}", + "accessUsersRoleFilterClear": "清除角色過濾器", + "accessApprovalsManage": "管理批准", + "accessApprovalsDescription": "檢視和管理待審批的組織存取許可權", + "provisioningKeysTitle": "預配金鑰", + "provisioningKeysManage": "管理預配金鑰", + "provisioningKeysDescription": "置備金鑰用於驗證您組織的自動站台設定。", + "provisioningManage": "預配", + "provisioningDescription": "管理預配金鑰,並稽核待批准的站台。", + "pendingSites": "待審批站台", + "siteApproveSuccess": "站台及相關資源已成功審批", + "siteApproveError": "批准站台出錯", + "siteReject": "拒絕站台", + "siteQuestionReject": "您確定要拒絕此站台嗎?", + "siteMessageReject": "這將永久刪除站台及所有尚未處理的相關資源。", + "siteConfirmReject": "確認拒絕站台", + "siteRejectSuccess": "站台已被成功拒絕", + "siteRejectError": "拒絕站台時出錯", + "provisioningKeys": "置備鍵", + "searchProvisioningKeys": "搜尋配備金鑰...", + "provisioningKeysAdd": "生成預配金鑰", + "provisioningKeysErrorDelete": "刪除預配鍵時出錯", + "provisioningKeysErrorDeleteMessage": "刪除預配鍵時出錯", + "provisioningKeysQuestionRemove": "您確定要從組織中刪除此預配鍵嗎?", + "provisioningKeysMessageRemove": "一旦移除,金鑰不能再用於站台預配。", + "provisioningKeysDeleteConfirm": "確認刪除置備鍵", + "provisioningKeysDelete": "刪除置備鍵", + "provisioningKeysCreate": "生成預配金鑰", + "provisioningKeysCreateDescription": "為組織生成一個新的預置金鑰", + "provisioningKeysSeeAll": "檢視所有預配鍵", + "provisioningKeysSave": "儲存預配鍵", + "provisioningKeysSaveDescription": "您只能看到一次。複製它到一個安全的地方。", + "provisioningKeysErrorCreate": "建立預配鍵時出錯", + "provisioningKeysList": "新預配金鑰", + "provisioningKeysMaxBatchSize": "最大批次大小", + "provisioningKeysUnlimitedBatchSize": "無限批次大小(無限制)", + "provisioningKeysMaxBatchUnlimited": "無限制", + "provisioningKeysMaxBatchSizeInvalid": "輸入一個有效的最大批處理大小(1-1,000,000)。", + "provisioningKeysValidUntil": "有效期至", + "provisioningKeysValidUntilHint": "留空為無過期。", + "provisioningKeysValidUntilInvalid": "輸入一個有效的日期和時間。", + "provisioningKeysNumUsed": "使用的時間", + "provisioningKeysLastUsed": "上次使用", + "provisioningKeysNoExpiry": "沒有過期", + "provisioningKeysNeverUsed": "永不過期", + "provisioningKeysEdit": "編輯置備鍵", + "provisioningKeysEditDescription": "更新此金鑰的最大批次大小和過期時間。", + "provisioningKeysApproveNewSites": "批准新站台", + "provisioningKeysApproveNewSitesDescription": "自動批准使用此金鑰註冊的節點。", + "provisioningKeysUpdateError": "更新預配鍵時出錯", + "provisioningKeysUpdated": "置備金鑰已更新", + "provisioningKeysUpdatedDescription": "您的更改已儲存。", + "provisioningKeysBannerTitle": "站台預配金鑰", + "provisioningKeysBannerDescription": "生成預配金鑰,並將其與 Newt 聯結器配合使用,即可在首次啟動時自動建立站台,無需為每個站台單獨設定憑據。", + "provisioningKeysBannerButtonText": "瞭解更多", + "pendingSitesBannerTitle": "待審批站台", + "pendingSitesBannerDescription": "使用預配金鑰連線的網站會在這裡以供稽核。", + "pendingSitesBannerButtonText": "瞭解更多", + "userQuestionOrgRemoveSelf": "你確定要將自己從這個組織中移除嗎?", + "userMessageOrgRemoveSelf": "你將立即失去存取許可權。管理員稍後可以再次邀請你,但你需要接受新的邀請。", + "userRemoveOrgConfirmSelf": "確認刪除我自己", + "userRemoveOrgSelf": "將自己從組織中移除", + "userRemoveOrgSelfWarning": "你將立即失去對此組織的存取許可權。", + "userRemoveOrgConfirmPhraseSelf": "從組織中移除我自己", + "accessRoleRequired": "需要角色", + "removeOwnAdminRoleConfirmTitle": "移除你的管理員許可權?", + "removeOwnAdminRoleConfirmDescription": "儲存後,你將不再擁有該組織的管理員許可權。如果需要,其他管理員可以恢復存取。", + "removeOwnAdminRoleConfirmButton": "移除我的管理員存取許可權", + "removeOwnAdminRoleConfirmPhrase": "移除我的管理員存取", + "ownerMustRetainAdminRole": "組織所有者必須保留至少一個管理員角色。", + "selectApprovalState": "選擇審批狀態", + "filterByApprovalState": "按批准狀態過濾", + "approvalListEmpty": "無批准", + "approvalState": "審批狀態", + "approvalLoadMore": "載入更多", + "loadingApprovals": "正在載入批准", + "approve": "批准", + "approved": "已批准", + "denied": "被拒絕", + "deniedApproval": "拒絕批准", + "all": "所有", + "deny": "拒絕", + "viewDetails": "檢視詳情", + "requestingNewDeviceApproval": "請求了一個新裝置", + "autoProvisionSettings": "自動提供設定", + "singleRolePerUserPlanNotice": "您的計劃僅支援每個使用者一個角色。", + "singleRolePerUserEditionNotice": "此版本僅支援每個使用者一個角色。", + "proxyMultiSiteRoundRobinNodeHelp": "輪詢路由在未連線到相同節點的站台之間將不起作用,但故障轉移會生效。", + "targetTargetsCleared": "目標已清除", + "targetTargetsClearedDescription": "所有目標已從此資源中移除", + "rulesErrorValidation": "規則無效", + "rulesErrorValidationRuleDescription": "規則 {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "選擇有效的匹配類型(路徑、IP、CIDR、國家、地區或 ASN)。", + "rulesErrorValueRequired": "為此規則輸入一個值。", + "rulesErrorInvalidCountry": "國家無效", + "rulesErrorInvalidCountryDescription": "選擇一個有效的國家。", + "rulesErrorInvalidAsn": "ASN 無效", + "rulesErrorInvalidAsnDescription": "輸入有效的 ASN(例如,AS15169)。", + "rulesReorderDragHandle": "拖動以重新排序規則優先順序", + "policyErrorCreate": "建立策略時出錯", + "policyErrorCreateDescription": "建立策略時發生錯誤", + "policyErrorCreateMessageDescription": "發生意外錯誤", + "policyErrorUpdate": "更新策略時出錯", + "policyErrorUpdateDescription": "更新策略時發生錯誤", + "policyErrorUpdateMessageDescription": "發生意外錯誤", + "policyCreatedSuccess": "資源策略建立成功", + "policyUpdatedSuccess": "資源策略更新成功", + "authMethodsSave": "儲存設定", + "policyAuthStackTitle": "身分驗證", + "policyAuthStackDescription": "控制哪些身分驗證方法需由使用者執行才能存取資源", + "policyAuthOrLogicTitle": "多種身分驗證方法處於啟用狀態", + "policyAuthOrLogicBanner": "存取者可以使用下列任意啟用的方法進行身分驗證。無需完成全部過程。", + "policyAuthMethodActive": "啟用", + "policyAuthMethodOff": "關閉", + "policyAuthSsoTitle": "平臺單點登入 SSO", + "policyAuthSsoDescription": "要求通過您組織的身分提供者登入", + "policyAuthSsoSummary": "{idp} · {users} 個使用者, {roles} 個角色", + "policyAuthSsoDefaultIdp": "預設提供者", + "policyAuthAddDefaultIdentityProvider": "新增預設身分提供者", + "policyAuthOtherMethodsTitle": "其他方法", + "policyAuthOtherMethodsDescription": "存取者可以使用以下備用或聯合平臺 SSO 的方法", + "policyAuthPasscodeTitle": "密碼", + "policyAuthPasscodeDescription": "要求使用共享字母數字密碼以訪資源問", + "policyAuthPasscodeSummary": "密碼已設定", + "policyAuthPincodeTitle": "PIN 碼", + "policyAuthPincodeDescription": "要求使用短數字程式碼以存取資源", + "policyAuthPincodeSummary": "6 位數字 PIN 碼已設定", + "policyAuthEmailTitle": "電子郵件白名單", + "policyAuthEmailDescription": "允許列出的電子郵件位址使用一次性密碼", + "policyAuthEmailSummary": "允許 {count} 個位址", + "policyAuthEmailOtpCallout": "啟用電子郵件白名單將在登入時向存取者的電子郵件傳送一次性密碼。", + "policyAuthHeaderAuthTitle": "基礎標題認證", + "policyAuthHeaderAuthDescription": "在每次請求上驗證自訂 HTTP 標頭名稱和值", + "policyAuthHeaderAuthSummary": "標頭已設定", + "policyAuthHeaderName": "使用者名稱", + "policyAuthHeaderValue": "密碼", + "policyAuthSetPasscode": "設定密碼", + "policyAuthSetPincode": "設定 PIN 碼", + "policyAuthSetEmailWhitelist": "設定電子郵件白名單", + "policyAuthSetHeaderAuth": "設定基礎標題認證", + "policyAccessRulesTitle": "存取規則", + "policyAccessRulesEnableDescription": "啟用後,規則將按下降順序進行評估,直到某條規則評估結果為真。", + "policyAccessRulesFirstMatch": "規則將自上而下進行評估。首次匹配的規則決定結果。", + "policyAccessRulesHowItWorks": "規則通過路徑、IP 位址、位置或其他標準匹配請求。每個規則都會套用操作:繞過身分驗證、阻止存取或通過身分驗證。如果沒有規則匹配,流量將繼續通過身分驗證。", + "policyAccessRulesFallthroughOff": "停用規則後,所有流量將通過身分驗證。", + "policyAccessRulesFallthroughOn": "沒有規則匹配時,流量將通過身分驗證。", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", + "rulesSave": "儲存規則", + "newtVersion": "版本", + "resourcePolicyAuthMethodsEmpty": "無身分驗證方法", + "resourcePolicyOtpEmpty": "沒有一次性密碼", + "resourcePolicyReadOnly": "此策略是隻讀的", + "resourcePolicyReadOnlyDescription": "此資源策略跨多個資源共享,您無法在此頁面上編輯。", + "editSharedPolicy": "編輯共享策略", + "resourcePolicyTypeSave": "儲存資源類型", + "resourcePolicySelect": "選擇資源策略", + "resourcePolicySelectError": "選擇資源策略", + "resourcePolicyNotFound": "找不到策略", + "resourcePolicySearch": "搜尋策略", + "resourcePolicyRulesEmpty": "無身分驗證規則", + "resourcePolicySelectTitle": "資源存取策略", + "resourcePolicySelectDescription": "選擇用於認證的資源策略類型", + "resourcePolicyTypeLabel": "策略類型", + "resourcePolicyLabel": "資源策略", + "resourcePolicyInline": "內聯資源策略", + "resourcePolicyInlineDescription": "僅限於此資源的存取策略", + "resourcePolicyShared": "共享資源策略", + "resourcePolicySharedDescription": "此資源使用共享策略。", + "sharedPolicy": "共享策略", + "sharedPolicyNoneDescription": "此資源有自己的策略。", + "resourceSharedPolicyOwnDescription": "此資源具有自己的身分驗證和存取規則控制。", + "resourceSharedPolicyInheritedDescription": "此資源繼承自{policyName}。", + "resourceSharedPolicyAuthenticationNotice": "此資源使用共享策略。一些身分驗證設定可以在此資源上編輯以新增到策略。要更改基礎策略,您必須編輯{policyName}。", + "resourceSharedPolicyRulesNotice": "此資源正在使用一個共享策略。某些存取規則可以在此資源上編輯。要更改基礎策略,您必須編輯{policyName}。", + "resourceGeneralDetailsSubsection": "資源詳情", + "resourceGeneralDetailsSubsectionDescription": "設定資源的顯示名稱、識別符號和公共存取域名。", + "resourceGeneralDetailsSubsectionPortDescription": "設定資源的顯示名稱、識別符號和公共埠。", + "resourceGeneralPublicAddressSubsection": "公共位址", + "resourceGeneralPublicAddressSubsectionDescription": "設定使用者如何存取該資源。", + "resourceGeneralAuthenticationAccessSubsection": "身分驗證與存取", + "resourceGeneralAuthenticationAccessSubsectionDescription": "選擇該資源是使用其自己的策略還是從共享策略繼承。", + "accessRoleEdit": "編輯角色", + "accessRoleEditDescription": "編輯角色資訊。", + "accessRoleUpdateSubmit": "更新角色", + "accessRoleUpdated": "角色已更新", + "accessRoleUpdatedDescription": "角色已成功更新。", + "accessApprovalUpdated": "審批已處理", + "accessApprovalApprovedDescription": "將審批請求決定設定為已批准。", + "accessApprovalDeniedDescription": "設定審批請求決定被拒絕。", + "accessRoleErrorUpdate": "更新角色失敗", + "accessRoleErrorUpdateDescription": "更新角色時出錯。", + "accessApprovalErrorUpdate": "處理稽核失敗", + "accessApprovalErrorUpdateDescription": "處理批准時出錯。", + "network": "網路", + "idpGlobalModeBanner": "此伺服器上停用了每個組織的身分提供者(Idps)。 它正在使用全域性IdP(所有組織共享)。在 管理面板中管理全域性IdP。 要啟用每個組織的 IdP,請編輯伺服器設定並將 IdP 模式設定為 org。 請參閱文件。 如果您想要繼續使用全域性IdP並使其從組織設定中消失,請在設定中將模式設定為全域性模式。", + "idpGlobalModeBannerUpgradeRequired": "此伺服器上停用了每個組織的身分提供者(Idps)。它正在使用全域性身分提供者(所有組織共享)。 在 管理面板管理全域性身分。要使用每個組織的身分提供者,您必須升級到企業版本。", + "idpGlobalModeBannerLicenseRequired": "此伺服器上停用了每個組織的身分提供者(Idps)。它正在使用全域性身分提供者(所有組織共享)。 在 管理面板管理全域性身分。要使用每個組織的身分提供者,需要企業許可證。", + "idpAutoProvisionConfigureAfterCreate": "您可以在建立身分提供者後設定自動設定設定。", + "labels": "標籤", + "orgLabelsDescription": "管理此組織的標籤。", + "addLabels": "新增標籤", + "siteLabelsTab": "標籤", + "siteLabelsDescription": "管理與此網站相關的標籤。", + "labelsNotFound": "未找到標籤。", + "labelsEmptyCreateHint": "在上方輸入以建立標籤。", + "labelSearch": "搜尋標籤", + "labelSearchOrCreate": "搜尋或建立標籤", + "accessLabelFilterCount": "{count, plural, other {# 標籤}}", + "labelOverflowCount": "+{count, plural, other {# 標籤}}", + "accessLabelFilterClear": "清除標籤過濾器", + "accessFilterClear": "清除篩選器", + "selectColor": "選擇顏色", + "createNewLabel": "建立新的組織標籤 \"{label}\"", + "orgIdMaxLength": "組織 ID 必須至少 32 個字元", + "actionRemoveUserRole": "移除使用者角色", + "actionGetDomain": "取得域", + "actionCreateOrgDomain": "建立域", + "actionUpdateOrgDomain": "更新域", + "actionDeleteOrgDomain": "刪除域", + "actionGetDNSRecords": "取得 DNS 記錄", + "actionRestartOrgDomain": "重新啟動域", + "actionCreateOrgWideLauncherView": "建立組織範圍的啟動器檢視", + "actionApproveSite": "批准站台", + "actionRejectSite": "拒絕站台", + "actionResetSiteBandwidth": "重置組織頻寬", + "actionGetResourcePolicy": "取得資源策略", + "actionUpdateResourcePolicy": "更新資源策略", + "actionSetResourcePolicyUsers": "設定資源策略使用者", + "actionSetResourcePolicyRoles": "設定資源策略角色", + "actionSetResourcePolicyPassword": "設定資源策略密碼", + "actionSetResourcePolicyPincode": "設定資源策略密碼", + "actionSetResourcePolicyHeaderAuth": "設定資源策略頭部身分驗證", + "actionSetResourcePolicyWhitelist": "設定資源策略郵件白名單", + "actionSetResourcePolicyRules": "設定資源策略規則", + "actionArchiveClient": "歸檔客戶端", + "actionUnarchiveClient": "取消歸檔客戶端", + "actionBlockClient": "遮蔽客戶端", + "actionUnblockClient": "解除遮蔽客戶端", + "actionCreateSiteProvisioningKey": "建立站台預配金鑰", + "actionListSiteProvisioningKeys": "列出站台預配金鑰", + "actionUpdateSiteProvisioningKey": "更新站台預配金鑰", + "actionDeleteSiteProvisioningKey": "刪除站台預配金鑰", + "search": "搜尋…", + "searchPlaceholder": "搜尋...", + "emptySearchOptions": "未找到選項", + "idpLastUsed": "上次使用", + "commandPaletteTitle": "命令面板", + "commandPaletteDescription": "搜尋頁、組織、資源和操作", + "commandPaletteSearchPlaceholder": "搜尋頁面、資源、操作……", + "commandPaletteNoResults": "未找到任何結果。", + "commandPaletteSearching": "搜尋中……", + "commandPaletteNavigation": "導航", + "commandPaletteOrganizations": "組織", + "commandPaletteSites": "網站", + "commandPaletteResources": "資源", + "commandPaletteUsers": "使用者", + "commandPaletteClients": "機器客戶端", + "commandPaletteActions": "操作", + "commandPaletteCreateSite": "建立網站", + "commandPaletteCreateProxyResource": "建立公共資源", + "commandPaletteCreatePrivateResource": "建立私人資源", + "commandPaletteCreateUser": "建立使用者", + "commandPaletteCreateApiKey": "建立 API 金鑰", + "commandPaletteCreateMachineClient": "建立機器客戶端", + "commandPaletteCreateAlertRule": "建立警報規則", + "commandPaletteCreateIdentityProvider": "建立身分提供者", + "commandPaletteToggleTheme": "切換主題", + "commandPaletteChooseOrganization": "選擇組織", + "commandPaletteShortcutMac": "⌘K", + "commandPaletteShortcutWindows": "Ctrl K", + "sidebarApprovals": "審批請求", + "sidebarPolicies": "共享策略", + "sidebarResourcePolicies": "公共資源", + "sidebarTeam": "團隊", + "sidebarProvisioning": "預配", + "sidebarAlerting": "告警", + "sidebarHealthChecks": "健康檢查", + "sidebarManagement": "管理", + "sidebarBillingAndLicenses": "帳單和許可證", + "commandSites": "網站", + "commandActionModeInfo": "輸入 \">\" 開啟操作模式", + "commandResources": "資源", + "commandProxyResources": "公共資源", + "commandClientResources": "私有資源", + "commandClients": "客戶端", + "commandUserDevices": "使用者裝置", + "commandMachineClients": "機器客戶端", + "commandDomains": "域", + "commandRemoteExitNodes": "遠端節點", + "commandTeam": "團隊", + "commandUsers": "使用者", + "commandRoles": "角色", + "commandInvitations": "邀請", + "commandPolicies": "共享策略", + "commandResourcePolicies": "公共資源政策", + "commandIdentityProviders": "身分提供者", + "commandApprovals": "審批請求", + "commandShareableLinks": "可共享連結", + "commandOrganization": "組織", + "commandLogsAndAnalytics": "日誌與分析", + "commandLogsAnalytics": "分析", + "commandLogsRequest": "HTTP 請求日誌", + "commandLogsAccess": "身分驗證日誌", + "commandLogsAction": "管理員操作日誌", + "commandLogsConnection": "網路日誌", + "commandLogsStreaming": "事件流", + "commandManagement": "管理", + "commandAlerting": "警報", + "commandProvisioning": "設定", + "commandBluePrints": "藍圖", + "commandApiKeys": "API 金鑰", + "commandBillingAndLicenses": "計費與許可證", + "commandBilling": "計費", + "commandEnterpriseLicenses": "許可證", + "commandSettings": "設定", + "commandLauncher": "啟動器", + "commandResourceLauncher": "資源啟動器", + "commandSearchResults": "搜尋結果", + "alertingTitle": "告警", + "alertingDescription": "定義通知的來源、觸發器和操作", + "alertingRules": "告警規則", + "alertingSearchRules": "搜尋規則…", + "alertingAddRule": "建立規則", + "alertingColumnSource": "來源", + "alertingColumnTrigger": "觸發", + "alertingColumnActions": "操作", + "alertingColumnEnabled": "已啟用", + "alertingDeleteQuestion": "請確認您要刪除此告警規則。", + "alertingDeleteRule": "刪除告警規則", + "alertingRuleDeleted": "告警規則已刪除", + "alertingRuleSaved": "告警規則已儲存", + "alertingRuleSavedCreatedDescription": "您的新告警規則已建立。您可以在此頁面繼續編輯它。", + "alertingRuleSavedUpdatedDescription": "對此告警規則的更改已儲存。", + "alertingEditRule": "編輯告警規則", + "alertingCreateRule": "建立告警規則", + "alertingRuleCredenzaDescription": "選擇要監視的內容、何時觸發以及如何通知", + "alertingRuleNamePlaceholder": "生產站台故障", + "alertingRuleEnabled": "規則已啟用", + "alertingSectionSource": "來源", + "alertingSourceType": "來源類型", + "alertingSourceSite": "站台", + "alertingSourceHealthCheck": "健康檢查", + "alertingPickSites": "站台", + "alertingPickHealthChecks": "健康檢查", + "alertingPickResources": "資源", + "alertingAllSites": "所有站台", + "alertingAllSitesDescription": "任何站台的告警觸發", + "alertingSpecificSites": "特定站台", + "alertingSpecificSitesDescription": "選擇要監視的特定站台", + "alertingAllHealthChecks": "所有健康檢查", + "alertingAllHealthChecksDescription": "任何健康檢查的告警觸發", + "alertingSpecificHealthChecks": "特定健康檢查", + "alertingSpecificHealthChecksDescription": "選擇要監視的特定健康檢查", + "alertingAllResources": "所有資源", + "alertingAllResourcesDescription": "任何資源的告警觸發", + "alertingSpecificResources": "特定資源", + "alertingSpecificResourcesDescription": "選擇要監視的特定資源", + "alertingSelectResources": "選擇資源…", + "alertingResourcesSelected": "{count} 個資源已選擇", + "alertingResourcesEmpty": "在前 10 個結果中沒有帶目標的資源。", + "alertingSectionTrigger": "觸發", + "alertingTrigger": "何時告警", + "alertingTriggerSiteOnline": "站台線上", + "alertingTriggerSiteOffline": "站台離線", + "alertingTriggerSiteToggle": "站台狀態變更", + "alertingTriggerHcHealthy": "健康檢查正常", + "alertingTriggerHcUnhealthy": "健康檢查不正常", + "alertingTriggerHcToggle": "健康檢查狀態變更", + "alertingTriggerResourceHealthy": "資源正常", + "alertingTriggerResourceUnhealthy": "資源不正常", + "alertingTriggerResourceDegraded": "資源降級", + "alertingSearchHealthChecks": "搜尋健康檢查…", + "alertingHealthChecksEmpty": "無可用健康檢查。", + "alertingTriggerResourceToggle": "資源狀態變更", + "alertingSourceResource": "資源", + "alertingSectionActions": "操作", + "alertingAddAction": "新增操作", + "alertingActionNotify": "電子郵件", + "alertingActionNotifyDescription": "向使用者或角色傳送電子郵件通知", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "傳送 HTTP 請求到自訂終端", + "alertingExternalIntegration": "外部整合", + "alertingExternalPagerDutyDescription": "將告警傳送給 PagerDuty 以進行事件管理", + "alertingExternalOpsgenieDescription": "將告警路由到 Opsgenie 進行電話值班管理", + "alertingExternalServiceNowDescription": "從告警事件建立 ServiceNow 事件", + "alertingExternalIncidentIoDescription": "從告警事件觸發 Incident.io 工作流程", + "alertingActionType": "操作類型", + "alertingNotifyUsers": "使用者", + "alertingNotifyRoles": "角色", + "alertingNotifyEmails": "電子郵件位址", + "alertingEmailPlaceholder": "新增電子郵件並按Enter鍵", + "alertingWebhookMethod": "HTTP 方法", + "alertingWebhookSecret": "簽名金鑰(可選)", + "alertingWebhookSecretPlaceholder": "HMAC 金鑰", + "alertingWebhookHeaders": "標頭", + "alertingAddHeader": "新增標頭", + "alertingSelectSites": "選擇站台…", + "alertingSitesSelected": "{count} 個站台已選擇", + "alertingSelectHealthChecks": "選擇健康檢查…", + "alertingHealthChecksSelected": "{count} 個健康檢查已選擇", + "alertingNoHealthChecks": "沒有啟用健康檢查的目標", + "alertingHealthCheckStub": "健康檢查來源選擇尚未設定 - 你仍然可以設定觸發器和操作。", + "alertingSelectUsers": "選擇使用者…", + "alertingUsersSelected": "{count} 個使用者已選擇", + "alertingSelectRoles": "選擇角色…", + "alertingRolesSelected": "{count} 個角色已選擇", + "alertingSummarySites": "站台 ({count})", + "alertingSummaryAllSites": "所有站台", + "alertingSummaryHealthChecks": "健康檢查 ({count})", + "alertingSummaryAllHealthChecks": "所有健康檢查", + "alertingSummaryResources": "資源 ({count})", + "alertingSummaryAllResources": "所有資源", + "alertingErrorNameRequired": "輸入名稱", + "alertingErrorActionsMin": "新增至少一個操作", + "alertingErrorPickSites": "至少選擇一個站台", + "alertingErrorPickHealthChecks": "至少選擇一個健康檢查", + "alertingErrorPickResources": "至少選擇一個資源", + "alertingErrorTriggerSite": "選擇站台觸發器", + "alertingErrorTriggerHealth": "選擇健康檢查觸發器", + "alertingErrorTriggerResource": "選擇資源觸發器", + "alertingErrorNotifyRecipients": "選擇使用者、角色或至少一個電子郵件", + "alertingConfigureSource": "設定來源", + "alertingConfigureTrigger": "設定觸發器", + "alertingConfigureActions": "設定操作", + "alertingBackToRules": "返回規則", + "alertingRuleCooldown": "冷卻時間(秒)", + "alertingRuleCooldownDescription": "相同規則間隔重複告警的最小時間。設定為 0 固定觸發。", + "alertingDraftBadge": "草稿 - 儲存以儲存此規則", + "alertingSidebarHint": "點選畫布上的步驟在此處編輯。", + "alertingGraphCanvasTitle": "規則流程", + "alertingGraphCanvasDescription": "源、觸發器和操作的視覺概況。選擇一個節點,在面板上進行編輯。", + "alertingNodeNotConfigured": "尚未設定", + "alertingNodeActionsCount": "{count, plural, other {# 操作}}", + "alertingNodeRoleSource": "來源", + "alertingNodeRoleTrigger": "觸發", + "alertingNodeRoleAction": "行為", + "alertingTabRules": "告警規則", + "alertingTabHealthChecks": "健康檢查", + "alertingRulesBannerTitle": "取得通知", + "alertingRulesBannerDescription": "每條規則都連線要監視的物件(站台、健康檢查或資源),觸發時間(例如離線或不健康),以及如何通過電子郵件、Webhooks 或整合將通知傳送給團隊。使用此列表建立、啟用和管理這些規則。", + "alertingHealthChecksBannerTitle": "資源與健康監控", + "alertingHealthChecksBannerDescription": "通過 HTTP 或 TCP 檢查目標狀態,並在服務異常或恢復時傳送通知。資源中設定的健康檢查也會顯示在這裡。", + "standaloneHcTableTitle": "健康檢查", + "standaloneHcSearchPlaceholder": "搜尋健康檢查…", + "standaloneHcAddButton": "建立健康檢查", + "standaloneHcCreateTitle": "建立健康檢查", + "standaloneHcEditTitle": "編輯健康檢查", + "standaloneHcDescription": "設定 HTTP 或 TCP 健康檢查以用於告警規則。", + "standaloneHcNameLabel": "名稱", + "standaloneHcNamePlaceholder": "我的 HTTP 監視器", + "standaloneHcDeleteTitle": "刪除健康檢查", + "standaloneHcDeleteQuestion": "請確認您要刪除此健康檢查。", + "standaloneHcDeleted": "健康檢查已刪除", + "standaloneHcSaved": "健康檢查已儲存", + "standaloneHcColumnHealth": "健康", + "standaloneHcColumnMode": "模式", + "standaloneHcColumnTarget": "目標", + "standaloneHcHealthStateHealthy": "健康", + "standaloneHcHealthStateUnhealthy": "不健康", + "standaloneHcHealthStateUnknown": "未知", + "standaloneHcFilterAnySite": "所有站台", + "standaloneHcFilterAnyResource": "所有資源", + "standaloneHcFilterMode": "模式", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "健康", + "standaloneHcFilterEnabled": "已啟用", + "standaloneHcFilterEnabledOn": "已啟用", + "standaloneHcFilterEnabledOff": "已停用", + "standaloneHcFilterSiteIdFallback": "站台 {id}", + "standaloneHcFilterResourceIdFallback": "資源 {id}", + "blueprintsLog": "藍圖日誌", + "newtAutoUpdate": "啟用站台自動更新", + "newtAutoUpdateDescription": "啟用後,站台聯結器將自動下載最新版本並重新啟動。可以針對每個站台進行覆蓋。", + "siteAutoUpdate": "站台自動更新", + "siteAutoUpdateLabel": "啟用自動更新", + "siteAutoUpdateDescription": "啟用後,該站台的聯結器將自動下載最新版本並重新啟動。", + "siteAutoUpdateOrgDefault": "組織預設設定:{state}", + "siteAutoUpdateOverriding": "覆蓋組織設定", + "siteAutoUpdateResetToOrg": "重置為組織預設設定", + "siteAutoUpdateEnabled": "已啟用", + "siteAutoUpdateDisabled": "已停用", + "certificateStatusAutoRefreshHint": "狀態自動重新整理。", + "loadingEllipsis": "載入中……", + "loadingAnalytics": "載入分析", + "pendingApproval": "等待批准", + "pangolinNodeUpdateAvailableInfo": "新版本的 Pangolin Node 已可用。請更新到最新版本以獲得最佳體驗。", + "domainPickerRemoteExitNodeWarning": "當站台連線到遠端退出節點時不支援所提供的域。為了資源可在遠端節點上使用,請使用自訂域名。", + "billingSites": "站台", + "billingOrganizations": "球隊", + "billingPublicResources": "公共資源", + "billingPrivateResources": "私有資源", + "billingMachineClients": "機器客戶端", + "billingSInfo": "您可以使用多少站台", + "billingPublicResourcesInfo": "您可以使用多少公共資源", + "billingPrivateResourcesInfo": "您可以使用多少私有資源", + "billingMachineClientsInfo": "您可以使用多少機器客戶端", + "billingLicenseKeys": "許可證金鑰", + "billingLicenseKeysDescription": "管理您的許可證金鑰訂閱", + "billingLicenseSubscription": "許可訂閱", + "billingInactive": "未啟用", + "billingLicenseItem": "許可證專案", + "billingQuantity": "數量", + "billingTotal": "總計", + "billingModifyLicenses": "修改許可訂閱", + "primary": "主要的", + "billingYourPlan": "您的計劃", + "billingViewOrModifyPlan": "檢視或修改您當前的計劃", + "billingViewPlanDetails": "檢視計劃詳細資訊", + "billingUsageAndLimits": "用法和限制", + "billingViewUsageAndLimits": "檢視您的計劃限制和當前使用情況", + "billingCurrentUsage": "當前使用情況", + "billingMaximumLimits": "最大限制", + "billingRemoteNodes": "遠端節點", + "billingUnlimited": "無限制", + "billingPaidLicenseKeys": "付費許可證金鑰", + "billingManageLicenseSubscription": "管理您對付費的自託管許可證金鑰的訂閱", + "billingCurrentKeys": "當前金鑰", + "billingModifyCurrentPlan": "修改當前計劃", + "billingManageLicenseSubscriptionDescription": "管理付費的自託管許可證金鑰訂閱並下載發票。", + "billingConfirmUpgrade": "確認升級", + "billingConfirmDowngrade": "確認降級", + "billingConfirmUpgradeDescription": "您即將升級您的計劃。請檢查下面的新限額和定價。", + "billingConfirmDowngradeDescription": "您即將降級計劃。請檢查下面的新限額和定價。", + "billingPlanIncludes": "計劃包含", + "billingProcessing": "正在處理...", + "billingConfirmUpgradeButton": "確認升級", + "billingConfirmDowngradeButton": "確認降級", + "billingLimitViolationWarning": "超出新計劃限制", + "billingLimitViolationDescription": "您當前的使用量超過了此計劃的限制。降級後,所有操作都將被停用,直到您在新的限制範圍內減少使用量。 請檢視以下當前超出限制的特性:", + "billingFeatureLossWarning": "功能可用通知", + "billingFeatureLossDescription": "如果降級,新計劃中不可用的功能將被自動停用。一些設定和設定可能會丟失。 請檢視定價矩陣以瞭解哪些功能將不再可用。", + "billingUsageExceedsLimit": "當前使用量 ({current}) 超出限制 ({limit})", + "billingPastDueTitle": "過去到期的付款", + "billingPastDueDescription": "您的付款已過期。請更新您的付款方法以繼續使用您當前的計劃功能。 如果不解決,您的訂閱將被取消,您將被恢復到免費等級。", + "billingUnpaidTitle": "訂閱未付款", + "billingUnpaidDescription": "您的訂閱未付,您已恢復到免費等級。請更新您的付款方法以恢復您的訂閱。", + "billingIncompleteTitle": "付款不完成", + "billingIncompleteDescription": "您的付款不完整。請完成付款過程以啟用您的訂閱。", + "billingIncompleteExpiredTitle": "付款已過期", + "billingIncompleteExpiredDescription": "您的付款尚未完成且已過期。您已恢復到免費級別。請再次訂閱以恢復對已支付功能的存取。", + "billingManageSubscription": "管理您的訂閱", + "billingResolvePaymentIssue": "請在升級或降級之前解決您的付款問題", + "selectLabels": "選擇標籤", + "healthCheckDisabledStateDescription": "停用後,站台不會進行健康檢查,狀態將被視為未知。", + "requireDeviceApproval": "需要裝置批准", + "requireDeviceApprovalDescription": "具有此角色的使用者需要管理員批准的新裝置才能連線和存取資源。", + "sshSettings": "SSH 設定", + "sshAccess": "SSH 存取", + "rdpSettings": "RDP 設定", + "vncSettings": "VNC 設定", + "sshServer": "SSH 伺服器", + "rdpServer": "RDP 伺服器", + "vncServer": "VNC 伺服器", + "sshServerDescription": "設定身分驗證方法、守護程式位置和伺服器目標", + "rdpServerDescription": "設定 RDP 伺服器的目標和埠", + "vncServerDescription": "設定 VNC 伺服器的目標和埠", + "sshServerMode": "模式", + "sshServerModeStandard": "標準 SSH 伺服器", + "sshServerModePangolin": "Pangolin SSH", + "sshServerModeStandardDescription": "將命令通過網路路由到 SSH 伺服器,例如 OpenSSH。", + "sshServerModeNative": "本地 SSH 伺服器", + "sshServerModeNativeDescription": "通過站台聯結器直接在主機上執行命令。無需網路設定。", + "sshAuthenticationMethod": "身分驗證方法", + "sshAuthMethodManual": "手動身分驗證", + "sshAuthMethodManualDescription": "需要現有的主機憑據。繞過自動設定。", + "sshAuthMethodAutomated": "自動設定", + "sshAuthMethodAutomatedDescription": "在主機上自動建立使用者、組和sudo許可權。", + "sshAuthDaemonLocation": "認證守護程式位置", + "sshDaemonLocationSiteDescription": "在託管站台聯結器的機器上本地執行。", + "sshDaemonLocationRemote": "在遠端主機上", + "sshDaemonLocationRemoteDescription": "在同一網路的獨立目標機器上執行。", + "sshDaemonDisclaimer": "在完成本設定之前,請確保您的目標主機已經正確設定以執行身分驗證守護程式,否則設定將失敗。", + "sshDaemonPort": "守護程式埠", + "sshServerDestination": "伺服器目標", + "sshServerDestinationDescription": "設定 SSH 伺服器的目的地", + "destination": "目標", + "destinationRequired": "需要目的地。", + "domainRequired": "需要域。", + "proxyPortRequired": "需要埠。", + "invalidPathConfiguration": "路徑設定無效。", + "invalidRewritePathConfiguration": "重寫路徑設定無效。", + "bgTargetMultiSiteDisclaimer": "選擇多個站台可實現高可用性的彈性路由和故障轉移。", + "roleAllowSsh": "允許 SSH", + "roleAllowSshAllow": "允許", + "roleAllowSshDisallow": "不允許", + "roleAllowSshDescription": "允許具有此角色的使用者通過 SSH 連線到資源。停用時,角色不能使用 SSH 存取。", + "sshSudoMode": "Sudo 存取", + "sshSudoModeNone": "無", + "sshSudoModeNoneDescription": "使用者不能用sudo執行命令。", + "sshSudoModeFull": "完整 Sudo 許可權", + "sshSudoModeFullDescription": "使用者可以用 sudo 執行任何命令。", + "sshSudoModeCommands": "命令", + "sshSudoModeCommandsDescription": "使用者只能用 sudo 執行指定的命令。", + "sshSudo": "允許Sudo", + "sshSudoCommands": "可用 Sudo 命令", + "sshSudoCommandsDescription": "使用者可以使用 sudo 執行的命令列表,以逗號、空格或新行分隔。必須使用絕對路徑。", + "sshCreateHomeDir": "建立主目錄", + "sshUnixGroups": "Unix 組", + "sshUnixGroupsDescription": "在目標主機上將使用者新增到的 Unix 組,以逗號、空格或新行分隔。", + "roleTextFieldPlaceholder": "輸入值,或放入 .txt 或 .csv 檔案", + "roleTextImportTitle": "從檔案匯入", + "roleTextImportDescription": "將 {fileName} 匯入到 {fieldLabel}。", + "roleTextImportSkipHeader": "跳過第一行(標題)", + "roleTextImportOverride": "替換現有", + "roleTextImportAppend": "附加到現有", + "roleTextImportMode": "匯入模式", + "roleTextImportPreview": "預覽", + "roleTextImportItemCount": "{count, plural, =0 {沒有可匯入的專案} one {1 個可匯入專案} other {# 個可匯入專案}}", + "roleTextImportTotalCount": "{existing} 個現有 + {imported} 個匯入 = {total} 個總計", + "roleTextImportConfirm": "匯入", + "roleTextImportInvalidFile": "不支援的檔案類型", + "roleTextImportInvalidFileDescription": "僅支援 .txt 和 .csv 檔案。", + "roleTextImportEmpty": "檔案中未找到專案", + "roleTextImportEmptyDescription": "檔案不包含任何可匯入的專案。", + "healthCheckMode": "檢查模式", + "healthCheckStrategy": "策略", + "healthCheckModeDescription": "TCP 模式僅驗證連線性。HTTP 模式驗證 HTTP 響應。", + "healthyThreshold": "正常閾值", + "healthyThresholdDescription": "標記為正常之前所需的連續成功次數。", + "unhealthyThreshold": "不正常閾值", + "unhealthyThresholdDescription": "標記為不正常之前所需的連續失敗次數。", + "healthCheckHealthyThresholdMin": "健康閾值至少為 1", + "healthCheckUnhealthyThresholdMin": "不健康閾值至少為 1", + "domainPickerWildcard": "萬用字元", + "domainPickerWildcardPaidOnly": "萬用字元子域是付費功能。請升級以使用此功能。", + "resourcesTableAliasAddress": "別名位址", + "resourcesTableAliasAddressInfo": "此位址是組織實用子網的一部分。它用來使用內部DNS解析來解析別名記錄。", + "resourcesTableUnhealthy": "不健康", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", + "editInternalResourceDialogScheme": "方案", + "editInternalResourceDialogEnableSsl": "啟用 TLS", + "editInternalResourceDialogEnableSslDescription": "為目標的安全 HTTPS 連線啟用 SSL/TLS 加密。", + "privateResourceGeneralDescription": "設定名稱、識別符號和其他一般資源設定。", + "privateResourceCreatePageSeeAll": "檢視所有私人資源", + "privateResourceAllowIcmpPing": "允許 ICMP Ping", + "privateResourceNetworkAccess": "網路存取", + "privateResourceNetworkAccessDescription": "控制此資源的 TCP/UDP 埠存取以及是否允許 ICMP ping。", + "hostSettings": "主機設定", + "cidrSettings": "CIDR 設定", + "multiSitesSelectorSitesCount": "{count, plural, other {# 個網站}}", + "labelsSelectorLabelsCount": "{count, plural, other {# 標籤}}", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", + "scheme": "方案", + "createInternalResourceDialogScheme": "方案", + "createInternalResourceDialogEnableSsl": "啟用 TLS", + "createInternalResourceDialogEnableSslDescription": "為目標的安全 HTTPS 連線啟用 SSL/TLS 加密。", + "internalResourceAliasLocalWarning": "以 .local 結尾的別名可能會因某些網路上的 mDNS 而導致解析問題。", + "internalResourceDownstreamSchemeRequired": "HTTP 資源需要方案", + "internalResourceHttpPortRequired": "HTTP 資源需要目的埠", + "remoteExitNodeNetworkingTitle": "網路設定", + "remoteExitNodeNetworkingDescription": "設定此遠端出口節點如何路由流量以及哪些站台優先通過其連線。高階功能可用於回程網路設定。", + "remoteExitNodeNetworkingSave": "儲存設定", + "remoteExitNodeNetworkingSaveSuccessTitle": "網路設定已儲存", + "remoteExitNodeNetworkingSaveSuccessDescription": "網路設定已成功更新。", + "remoteExitNodeNetworkingSaveError": "儲存網路設定失敗", + "remoteExitNodeNetworkingSubnetsTitle": "遠端子網", + "remoteExitNodeNetworkingSubnetsDescription": "定義此遠端出口節點將路由流量的CIDR範圍。輸入有效的CIDR(例如10.0.0.0/8)並按Enter鍵新增。", + "remoteExitNodeNetworkingSubnetsPlaceholder": "新增CIDR範圍(例如10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "無法載入子網", + "remoteExitNodeNetworkingLabelsTitle": "首選標籤", + "remoteExitNodeNetworkingLabelsDescription": "擁有這些標籤的站台將強制通過此遠端出口節點連線。", + "remoteExitNodeNetworkingLabelsButtonText": "選擇標籤……", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "搜尋標籤……", + "remoteExitNodeNetworkingLabelsLoadError": "無法載入標籤", + "countryIsNot": "不在國家", + "region": "地區", + "selectRegion": "選擇區域", + "searchRegions": "搜尋區域...", + "noRegionFound": "未找到區域。", + "rulesMatchRegion": "選擇一個區域國家組", + "rulesErrorInvalidRegion": "無效區域", + "rulesErrorInvalidRegionDescription": "請選擇一個有效的區域。", + "regionAfrica": "非洲", + "regionNorthernAfrica": "B. 北非地區", + "regionEasternAfrica": "東部非洲", + "regionMiddleAfrica": "中東", + "regionSouthernAfrica": "D. 南 非", + "regionWesternAfrica": "D. 西部非洲", + "regionAmericas": "Americas", + "regionCaribbean": "加勒比", + "regionCentralAmerica": "中美洲:", + "regionSouthAmerica": "南 非", + "regionNorthernAmerica": "北美洲:", + "regionAsia": "亞洲", + "regionCentralAsia": "B. 亞 洲", + "regionEasternAsia": "東亞", + "regionSouthEasternAsia": "D. 東南亞區域", + "regionSouthernAsia": "D. 亞 洲", + "regionWesternAsia": "西亞", + "regionEurope": "歐洲", + "regionEasternEurope": "D. 歐 洲", + "regionNorthernEurope": "北歐洲", + "regionSouthernEurope": "南歐洲", + "regionWesternEurope": "西歐洲", + "regionOceania": "Oceania", + "regionAustraliaAndNewZealand": "澳大利亞和紐西蘭", + "regionMelanesia": "Melanesia", + "regionMicronesia": "Micronesia", + "regionPolynesia": "Polynesia", + "roleMappingModeFixedRoles": "固定角色", + "roleMappingModeMappingBuilder": "對映構建器", + "roleMappingModeRawExpression": "原始表示式", + "roleMappingFixedRolesPlaceholderSelect": "選擇一個或多個角色", + "roleMappingFixedRolesPlaceholderFreeform": "輸入角色名稱 (每個組織確切匹配)", + "roleMappingFixedRolesDescriptionSameForAll": "將相同的角色分配給每個自動配備的使用者。", + "roleMappingFixedRolesDescriptionDefaultPolicy": "對於預設策略,每個提供使用者的組織中存在的角色名稱類型。名稱必須完全匹配。", + "roleMappingClaimPath": "認領路徑", + "roleMappingClaimPathPlaceholder": "組", + "roleMappingClaimPathDescription": "包含源值的 token 有效負載路徑 (例如組)。", + "roleMappingMatchValue": "匹配值", + "roleMappingAssignRoles": "分配角色", + "roleMappingAddMappingRule": "新增對映規則", + "roleMappingRawExpressionResultDescription": "表示式必須值為字串或字串。", + "roleMappingRawExpressionResultDescriptionSingleRole": "表示式必須計算到字串(單個角色名稱)。", + "roleMappingMatchValuePlaceholder": "匹配值(例如: 管理員)", + "roleMappingAssignRolesPlaceholderFreeform": "輸入角色名稱 (每個組織確切)", + "roleMappingBuilderFreeformRowHint": "角色名稱必須匹配每個目標組織的角色。", + "roleMappingRemoveRule": "刪除", + "utilitySubnet": "實用程式子網", + "brandingLogoURLOrPath": "徽標URL或路徑", + "brandingLogoPathDescription": "輸入網址或本地路徑。", + "brandingLogoURLDescription": "請在您的徽標圖片中輸入一個可公開存取的 URL。", + "domainPickerFreeDomainsPaidFeature": "提供的域名是付費功能。訂閱即可將域名包含在您的計劃中-無需自帶域名。", + "domainPickerManual": "手動", + "mustUpgradeToUse": "您必須升級您的訂閱才能使用此功能。", + "subscriptionRequiredTierToUse": "此功能需要 {tier} 或更高級別。", + "upgradeToTierToUse": "升級到 {tier} 或更高級別以使用此功能。", + "subscriptionTierTier1": "首頁", + "subscriptionTierTier2": "團隊", + "subscriptionTierTier3": "業務", + "subscriptionTierEnterprise": "企業", + "sessionExpired": "會話已過期", + "sessionExpiredReauthRequired": "根據貴組織的安全策略,您的會話已過期。請重新認證以繼續。", + "reauthenticate": "重新認證", + "newPricingLicenseForm": { + "title": "取得許可證", + "description": "選擇一個計劃,告訴我們你計劃如何使用 Pangolin。", + "chooseTier": "選擇您的計劃", + "viewPricingLink": "檢視價格、特徵和限制", + "tiers": { + "starter": { + "title": "啟動器", + "description": "企業特徵,25個使用者,25個站台和社群支援。" + }, + "scale": { + "title": "縮放比例", + "description": "企業功能,50個使用者,100個站台,以及優先支援。" + } + }, + "personalUseOnly": "僅限個人使用(免費許可 - 無需結賬)", + "buttons": { + "continueToCheckout": "繼續簽出" + }, + "toasts": { + "checkoutError": { + "title": "簽出錯誤", + "description": "無法啟動結帳。請重試。" + } + } + }, + "view": "檢視", + "configManaged": "設定已管理", + "connectedClient": "已連線客戶端", + "logRetentionConnectionLabel": "連線日誌保留", + "logRetentionConnectionDescription": "保留連線日誌的時間", + "connectionLogs": "連線日誌", + "connectionLogsDescription": "檢視此機構隧道的連線日誌", + "sidebarLogsConnection": "連線日誌", + "sidebarLogsStreaming": "事件流", + "sourceAddress": "源位址", + "destinationAddress": "目的位址", + "duration": "期限", + "ossEnterpriseEditionRequired": "需要 Enterprise Edition 才能使用此功能。 此功能也可在 Pangolin Cloud上取得。 預訂演示或POC 試用。", + "updateAvailableInfo": "有新版本可用。請更新到最新版本以獲得最佳體驗。", + "version1Description": "基於文字和廣泛支援。確保伺服器傳輸新增到動態設定。", + "version2Description": "二進位制且更有效率但相容性較低。確保伺服器傳輸新增到動態設定。", + "deviceCodeValidating": "正在驗證裝置程式碼...", + "deviceCodeVerifying": "正在驗證裝置授權...", + "loginSelectAuthenticationMethod": "選擇要繼續的身分驗證方法。", + "envFile": "環境檔案", + "serviceFile": "服務檔案", + "enableAndStart": "啟用並啟動", + "internalResourceFormMultiSiteRoutingHelp": "選擇多個站台可以實現高可用性的彈性路由和故障轉移。", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "瞭解更多", + "createInternalResourceDialogHttpConfiguration": "HTTP 設定", + "createInternalResourceDialogHttpConfigurationDescription": "選擇客戶將使用的域名通過 HTTP 或 HTTPS 存取此資源。", + "editInternalResourceDialogHttpConfiguration": "HTTP 設定", + "editInternalResourceDialogHttpConfigurationDescription": "選擇客戶將使用的域名通過 HTTP 或 HTTPS 存取此資源。", + "internalResourceAuthDaemonStrategy": "SSH 認證守護程式位置", + "internalResourceAuthDaemonStrategyDescription": "選擇 SSH 身分驗證守護程式在哪裡執行:站台(新建) 或遠端主機。", + "internalResourceAuthDaemonDescription": "SSH 身分驗證守護程式處理此資源的 SSH 金鑰簽名和PAM 身分驗證。 選擇它是在站台(新建)還是在單獨的遠端主機上執行。請參閱 文件。", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "選擇策略", + "internalResourceAuthDaemonStrategyLabel": "地點", + "internalResourceAuthDaemonSite": "在站台", + "internalResourceAuthDaemonSiteDescription": "認證守護程式在站台上執行(新建)。", + "internalResourceAuthDaemonRemote": "遠端主機", + "internalResourceAuthDaemonRemoteDescription": "認證守護程式執行在不是站台的主機上。", + "internalResourceAuthDaemonPort": "守護程式埠(可選)", + "enableMaintenanceModeDescription": "啟用後,存取者將看到維護頁面而不是您的資源。", + "maintenancePageContentSubsection": "頁面內容", + "maintenancePageContentSubsectionDescription": "自訂維護頁面上顯示的內容", + "privateMaintenanceScreenTitle": "私有佔位符介面", + "privateMaintenanceScreenMessage": "此域名正在私有資源上使用。請連線 Pangolin 客戶端以存取此資源。", + "privateMaintenanceScreenSteps": "連線後,如果您仍然看到此訊息,說明您的瀏覽器的DNS快取可能仍指向舊位址。解決方法:完全關閉並重新開啟此分頁或瀏覽器,然後返回此頁面。", + "available": "可用", + "disabledResourceDescription": "停用後,所有人都不可存取此資源。", + "archived": "已存檔", + "noArchivedDevices": "未找到存檔裝置", + "deviceArchived": "裝置已存檔", + "deviceArchivedDescription": "裝置已成功歸檔。", + "errorArchivingDevice": "錯誤存檔裝置", + "failedToArchiveDevice": "歸檔裝置失敗", + "deviceQuestionArchive": "您確定要存檔此裝置嗎?", + "deviceMessageArchive": "裝置將被存檔並從活動裝置列表中刪除。", + "deviceArchiveConfirm": "歸檔裝置", + "archiveDevice": "歸檔裝置", + "archive": "存檔", + "deviceUnarchived": "裝置未存檔", + "deviceUnarchivedDescription": "裝置已成功解除歸檔。", + "errorUnarchivingDevice": "解除安裝裝置時出錯", + "failedToUnarchiveDevice": "取消歸檔裝置失敗", + "unarchive": "取消存檔", + "archiveClient": "歸檔客戶端", + "archiveClientQuestion": "您確定要存檔此客戶端嗎?", + "archiveClientMessage": "客戶端將被存檔並從您活躍的客戶端列表中刪除。", + "archiveClientConfirm": "歸檔客戶端", + "blockClient": "遮蔽客戶端", + "blockClientQuestion": "您確定要遮蔽此客戶端?", + "blockClientMessage": "如果當前連線,裝置將被迫斷開連線。您可以稍後取消遮蔽裝置。", + "blockClientConfirm": "遮蔽客戶端", + "active": "已啟用", + "usernameOrEmail": "使用者名稱或電子郵件", + "selectYourOrganization": "選擇您的組織", + "signInTo": "登入到", + "signInWithPassword": "使用密碼繼續", + "noAuthMethodsAvailable": "該組織沒有可用的身分驗證方法。", + "enterPassword": "輸入您的密碼", + "enterMfaCode": "從您的身分驗證程式中輸入程式碼", + "securityKeyRequired": "請使用您的安全金鑰登入。", + "needToUseAnotherAccount": "需要使用不同的帳戶?", + "loginLegalDisclaimer": "點選下面的按鈕,您確認您已經閱讀了,理解, 並同意 服務條款隱私政策。", + "termsOfService": "服務條款", + "privacyPolicy": "隱私政策", + "userNotFoundWithUsername": "找不到該使用者名稱。", + "verify": "驗證", + "signIn": "登入", + "forgotPassword": "忘記密碼?", + "orgSignInTip": "如果您以前已經登入,您可以在上面輸入您的使用者名稱或電子郵件來驗證您的組織身分提供者。這很容易!", + "continueAnyway": "仍然繼續", + "dontShowAgain": "不再顯示", + "orgSignInNotice": "您知道嗎?", + "signupOrgNotice": "試圖登入?", + "signupOrgTip": "您是否試圖通過您的組織的身分提供者登入?", + "signupOrgLink": "使用您的組織登入或註冊", + "verifyEmailLogInWithDifferentAccount": "使用不同的帳戶", + "logIn": "登入", + "deviceInformation": "裝置資訊", + "deviceInformationDescription": "關於裝置和代理的資訊", + "deviceSecurity": "裝置安全", + "deviceSecurityDescription": "裝置安全態勢資訊", + "platform": "平臺", + "macosVersion": "macOS 版本", + "windowsVersion": "Windows 版本", + "iosVersion": "iOS 版本", + "androidVersion": "Android 版本", + "osVersion": "作業系統版本", + "kernelVersion": "核心版本", + "deviceModel": "裝置模型", + "serialNumber": "序列號", + "hostname": "Hostname", + "firstSeen": "第一次檢視", + "lastSeen": "上次檢視時間", + "biometricsEnabled": "生物計已啟用", + "diskEncrypted": "磁碟加密", + "firewallEnabled": "防火牆已啟用", + "autoUpdatesEnabled": "啟用自動更新", + "tpmAvailable": "TPM 可用", + "windowsAntivirusEnabled": "抗病毒已啟用", + "macosSipEnabled": "系統完整性保護 (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "防火牆隱形模式", + "linuxAppArmorEnabled": "AppArmor", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "檢視裝置資訊和設定", + "devicePendingApprovalDescription": "此裝置正在等待批准", + "deviceBlockedDescription": "此裝置目前已被遮蔽。除非解除遮蔽,否則無法連線到任何資源。", + "unblockClient": "解除遮蔽客戶端", + "unblockClientDescription": "裝置已解除阻止", + "unarchiveClient": "取消歸檔客戶端", + "unarchiveClientDescription": "裝置已被取消存檔", + "block": "封禁", + "unblock": "取消遮蔽", + "deviceActions": "裝置操作", + "deviceActionsDescription": "管理裝置狀態和存取許可權", + "devicePendingApprovalBannerDescription": "此裝置正在等待批准。在批准之前,它將無法連線到資源。", + "connected": "已連線", + "disconnected": "斷開連線", + "approvalsEmptyStateTitle": "裝置批准未啟用", + "approvalsEmptyStateDescription": "在使用者連線新裝置之前,允許裝置批准角色,需要管理員批准。", + "approvalsEmptyStateStep1Title": "轉到角色", + "approvalsEmptyStateStep1Description": "導航到您組織的角色設定來設定裝置批准。", + "approvalsEmptyStateStep2Title": "啟用裝置批准", + "approvalsEmptyStateStep2Description": "編輯角色並啟用“需要裝置審批”選項。具有此角色的使用者需要管理員批准新裝置。", + "approvalsEmptyStatePreviewDescription": "預覽:如果啟用,待處理裝置請求將出現在這裡供稽核", + "approvalsEmptyStateButtonText": "管理角色", + "domainErrorTitle": "我們在驗證您的域名時遇到了問題", + "idpAdminAutoProvisionPoliciesTabHint": "在 自動供應設定 選項卡上設定角色對映和組織策略。", + "streamingTitle": "事件流", + "streamingDescription": "即時將事件從您的組織流到外部目的地。", + "streamingUnnamedDestination": "未命名目標", + "streamingNoUrlConfigured": "未設定URL", + "streamingAddDestination": "新增目標", + "streamingHttpWebhookTitle": "HTTP Webhook", + "streamingHttpWebhookDescription": "將事件傳送到任意HTTP端點並靈活驗證和範本。", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "將事件串流到 S3 相容的物件儲存桶。即將推出。", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "直接轉發事件到您的Datadog 帳戶。即將推出。", + "streamingTypePickerDescription": "選擇要開始的目標類型。", + "streamingLastSyncError": "最後一次同步時發生錯誤", + "streamingUnexpectedError": "發生意外錯誤.", + "streamingFailedToUpdate": "更新目標失敗", + "streamingDeletedSuccess": "目標刪除成功", + "streamingFailedToDelete": "刪除目標失敗", + "streamingDeleteTitle": "刪除目標", + "streamingDeleteButtonText": "刪除目標", + "streamingDeleteDialogAreYouSure": "您確定要刪除嗎?", + "streamingDeleteDialogThisDestination": "這個目標", + "streamingDeleteDialogPermanentlyRemoved": "? 所有設定將被永久刪除。", + "httpDestEditTitle": "編輯目標", + "httpDestAddTitle": "新增 HTTP 目標", + "httpDestEditDescription": "更新此 HTTP 事件流媒體目的地的設定。", + "httpDestAddDescription": "設定新的 HTTP 端點來接收您的組織事件。", + "S3DestEditTitle": "編輯目的地", + "S3DestAddTitle": "新增 S3 目的地", + "S3DestEditDescription": "更新此 S3 事件流目的地的設定。", + "S3DestAddDescription": "設定一個新的 Amazon S3(或相容 S3 的)儲存桶以接收您的組織事件。", + "s3DestTabSettings": "設定", + "s3DestTabFormat": "格式", + "s3DestNameLabel": "名稱", + "s3DestNamePlaceholder": "我的 S3 目的地", + "s3DestAccessKeyIdLabel": "AWS 存取金鑰 ID", + "s3DestSecretAccessKeyLabel": "AWS 秘密存取金鑰", + "s3DestSecretAccessKeyPlaceholder": "您的 AWS 金鑰", + "s3DestRegionLabel": "AWS 地區", + "s3DestBucketLabel": "儲存桶名稱", + "s3DestPrefixLabel": "金鑰字首(可選)", + "s3DestPrefixDescription": "每個物件金鑰前加的可選路徑字首。物件儲存在 {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}。", + "s3DestEndpointLabel": "自訂端點(可選)", + "s3DestEndpointDescription": "替代 S3 端點用於 MinIO 或 Cloudflare R2 等相容 S3 的儲存。標準 AWS S3 留空。", + "s3DestGzipLabel": "Gzip 壓縮", + "s3DestGzipDescription": "使用 gzip 壓縮每個上傳的物件。減少儲存成本和上傳大小。", + "s3DestFormatTitle": "檔案格式", + "s3DestFormatDescription": "事件在每個上傳物件內的序列化方式。", + "s3DestFormatJsonArrayDescription": "每個物件是事件記錄的 JSON 陣列。相容大多數分析工具。", + "s3DestFormatNdjsonDescription": "每個物件每行包含一個 JSON 記錄(換行分隔的 JSON)。相容 Athena、BigQuery 和 Spark。", + "s3DestFormatCsvTitle": "CSV", + "s3DestFormatCsvDescription": "每個物件是帶有標題行的 RFC-4180 CSV 檔案。列名來自事件資料欄位。", + "s3DestSaveChanges": "儲存更改", + "s3DestCreateDestination": "建立目的地", + "s3DestUpdatedSuccess": "目的地更新成功", + "s3DestCreatedSuccess": "目的地建立成功", + "s3DestUpdateFailed": "更新目的地失敗", + "s3DestCreateFailed": "建立目的地失敗", + "datadogDestEditTitle": "編輯目的地", + "datadogDestAddTitle": "新增 Datadog 目的地", + "datadogDestEditDescription": "更新此 Datadog 事件流目的地的設定。", + "datadogDestAddDescription": "設定新的 Datadog 終端以接收您的組織事件。", + "httpDestTabSettings": "設定", + "httpDestTabHeaders": "信頭", + "httpDestTabBody": "正文內容", + "httpDestTabLogs": "日誌", + "httpDestNamePlaceholder": "我的 HTTP 目標", + "httpDestUrlLabel": "目標網址", + "httpDestUrlErrorHttpRequired": "URL 必須使用 http 或 https", + "httpDestUrlErrorHttpsRequired": "雲端部署需要HTTPS", + "httpDestUrlErrorInvalid": "輸入一個有效的 URL (例如,https://example.com/webhook)", + "httpDestAuthTitle": "認證", + "httpDestAuthDescription": "選擇如何驗證您的端點的請求。", + "httpDestAuthNoneTitle": "無身分驗證", + "httpDestAuthNoneDescription": "在沒有授權頭的情況下傳送請求。", + "httpDestAuthBearerTitle": "持有者權杖", + "httpDestAuthBearerDescription": "在每個請求中新增授權:Bearer “” 頭。", + "httpDestAuthBearerPlaceholder": "您的 API 金鑰或權杖", + "httpDestAuthBasicTitle": "基本認證", + "httpDestAuthBasicDescription": "新增一個Authorization: Basic \"<憑據>\" 標頭。 以使用者名稱:密碼形式提供憑據。", + "httpDestAuthBasicPlaceholder": "使用者名稱:密碼", + "httpDestAuthCustomTitle": "自訂標題", + "httpDestAuthCustomDescription": "指定自訂 HTTP 頭名稱和身分驗證值 (例如,X-API 鍵)。", + "httpDestAuthCustomHeaderNamePlaceholder": "標題名稱(例如X-API-鍵)", + "httpDestAuthCustomHeaderValuePlaceholder": "頁首值", + "httpDestCustomHeadersTitle": "自訂 HTTP 頭", + "httpDestCustomHeadersDescription": "向每個輸出請求新增自訂標題。用於靜態權杖或自訂內容類型。預設情況下,內容類型:應用程式/json已傳送。", + "httpDestNoHeadersConfigured": "未設定自訂頭。單擊\"新增頭\"以新增一個。", + "httpDestHeaderNamePlaceholder": "標題名稱", + "httpDestHeaderValuePlaceholder": "值", + "httpDestAddHeader": "新增標題", + "httpDestBodyTemplateTitle": "自訂實體範本", + "httpDestBodyTemplateDescription": "控制傳送到您的端點的 JSON 有效載荷結構。如果停用,將為每個事件傳送一個 JSON 預設物件。", + "httpDestEnableBodyTemplate": "啟用自訂實體範本", + "httpDestBodyTemplateLabel": "身體範本 (JSON)", + "httpDestBodyTemplateHint": "將範本變數用於您有效載荷中的參考事件欄位。", + "httpDestPayloadFormatTitle": "有效載荷格式", + "httpDestPayloadFormatDescription": "事件如何序列化為每個請求實體。", + "httpDestFormatJsonArrayTitle": "JSON 陣列", + "httpDestFormatJsonArrayDescription": "每批一個請求,實體是一個 JSON 陣列。與大多數通用的 Web 鉤子和資料相容。", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "每批有一個請求,物體是換行符限制的 JSON --每行一個物件,不是外部陣列。 Sluk HEC、Elastic / OpenSearch和Grafana Loki所需。", + "httpDestFormatSingleTitle": "每個請求一個事件", + "httpDestFormatSingleDescription": "為每個事件單獨傳送一個 HTTP POST。僅用於無法處理批次的端點。", + "httpDestLogTypesTitle": "日誌類型", + "httpDestLogTypesDescription": "選擇轉發到此目的地的日誌類型。只有啟用的日誌類型才會被連續使用。", + "httpDestAccessLogsTitle": "存取日誌", + "httpDestAccessLogsDescription": "資源存取嘗試,包括已驗證和拒絕的請求。", + "httpDestActionLogsTitle": "操作日誌", + "httpDestActionLogsDescription": "組織內部使用者採取的行政行動。", + "httpDestConnectionLogsTitle": "連線日誌", + "httpDestConnectionLogsDescription": "站台和隧道連線事件,包括連線和斷開連線。", + "httpDestRequestLogsTitle": "請求日誌", + "httpDestRequestLogsDescription": "HTTP 請求代理資源日誌,包括方法、路徑和響應程式碼。", + "httpDestSaveChanges": "儲存更改", + "httpDestCreateDestination": "建立目標", + "httpDestUpdatedSuccess": "目標已成功更新", + "httpDestCreatedSuccess": "目標建立成功", + "httpDestUpdateFailed": "更新目標失敗", + "httpDestCreateFailed": "建立目標失敗", + "followRedirects": "遵循重定向", + "followRedirectsDescription": "自動跟蹤請求的 HTTP 重定向。", + "alertingErrorWebhookUrl": "請輸入有效的 Webhook URL。", + "healthCheckStrategyHttp": "驗證連線並檢查 HTTP 響應狀態。", + "healthCheckStrategyTcp": "只驗證 TCP 連線性,不檢查響應。", + "healthCheckStrategySnmp": "進行 SNMP get 請求以檢查網路裝置和基礎架構的健康狀況。", + "healthCheckStrategyIcmp": "使用 ICMP 回顯請求(ping)檢查資源是否可達並響應。", + "healthCheckTabStrategy": "策略", + "healthCheckTabConnection": "連線", + "healthCheckTabAdvanced": "高階", + "healthCheckStrategyNotAvailable": "此策略不可用。請聯絡銷售以啟用此功能。", + "uptime30d": "正常執行時間(30天)", + "idpAddActionCreateNew": "建立新的身分提供者", + "idpAddActionImportFromOrg": "從另一個組織匯入", + "idpImportDialogTitle": "匯入身分提供者", + "idpImportDialogDescription": "從您是管理員的組織中選擇一個身分提供者。它將關聯到本組織。", + "idpImportSearchPlaceholder": "按組織或提供者名稱搜尋……", + "idpImportEmpty": "未找到身分提供者。", + "idpImportedDescription": "身分提供者已成功匯入。", + "idpDeleteGlobalQuestion": "您確定要永久刪除此身分提供者嗎?", + "idpDeleteGlobalDescription": "這將永久刪除與其關聯的所有組織中的身分提供者。", + "idpUnassociateTitle": "取消關聯身分提供者", + "idpUnassociateQuestion": "您確定要將此身分提供者從此組織中取消關聯嗎?", + "idpUnassociateDescription": "與此身分提供者關聯的所有使用者將從該組織中移除,但身分提供者仍會繼續存在於關聯的其他組織中。", + "idpUnassociateConfirm": "確認取消關聯身分提供者", + "idpConfirmDeleteAndRemoveMeFromOrg": "刪除並將我從組織中移除", + "idpUnassociateAndRemoveMeFromOrg": "解除關聯並將我從組織中移除", + "idpUnassociateWarning": "此操作無法對該組織撤銷。", + "idpUnassociatedDescription": "身分提供者已成功從該組織中取消關聯", + "idpUnassociateMenu": "取消關聯", + "idpDeleteAllOrgsMenu": "刪除", + "publicIpEndpoint": "終端", + "lastTriggeredAt": "最後觸發", + "reject": "拒絕", + "uptimeDaysAgo": "{count} 天前", + "uptimeToday": "今天", + "uptimeNoDataAvailable": "暫無資料", + "uptimeSuffix": "正常執行時間", + "uptimeDowntimeSuffix": "停機時間", + "uptimeTooltipUptimeLabel": "正常執行", + "uptimeTooltipDowntimeLabel": "停機", + "uptimeOngoing": "正在進行", + "uptimeNoMonitoringData": "無監控資料", + "uptimeNoData": "無資料", + "uptimeMiniBarDown": "停機", + "uptimeSectionTitle": "正常執行時間", + "uptimeSectionDescription": "過去 {days} 天的可用性", + "uptimeAddAlert": "新增警報", + "uptimeViewAlerts": "檢視警報", + "uptimeCreateEmailAlert": "建立電子郵件警報", + "uptimeAlertDescriptionSite": "當此站台下線或恢復上線時,將通過電子郵件通知您。", + "uptimeAlertDescriptionResource": "當此資源下線或恢復上線時,將通過電子郵件通知您。", + "uptimeAlertNamePlaceholder": "警報名稱", + "uptimeAdditionalEmails": "附加電子郵件", + "uptimeCreateAlert": "建立警報", + "uptimeAlertNoRecipients": "無收件人", + "uptimeAlertNoRecipientsDescription": "請至少新增一個使用者、角色或電子郵件進行通知。", + "uptimeAlertCreated": "警報已建立", + "uptimeAlertCreatedDescription": "狀態變化時將通知您。", + "uptimeAlertCreateFailed": "建立警報失敗", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "關鍵字", + "webhookHeaderValuePlaceholder": "值", + "alertLabel": "警報", + "domainPickerWildcardSubdomainNotAllowed": "不允許使用萬用字元子域。", + "domainPickerWildcardCertWarning": "萬用字元資源可能需要額外設定才能正常工作。", + "domainPickerWildcardCertWarningLink": "瞭解更多", + "health": "健康", + "domainPendingErrorTitle": "驗證問題", + "memberPortalTitle": "資源", + "memberPortalDescription": "您在此組織中可以存取的資源", + "memberPortalSortBy": "排序依據……", + "memberPortalSortNameAsc": "名稱 A-Z", + "memberPortalSortNameDesc": "名稱 Z-A", + "memberPortalSortDomainAsc": "域名 A-Z", + "memberPortalSortDomainDesc": "域名 Z-A", + "memberPortalSortEnabledFirst": "啟用優先", + "memberPortalSortDisabledFirst": "停用優先", + "memberPortalRefresh": "重新整理", + "memberPortalRefreshResources": "重新整理資源", + "memberPortalFailedToLoad": "載入資源失敗", + "memberPortalFailedToLoadDescription": "載入資源失敗。請檢查您的連線並再試一次。", + "memberPortalUnableToLoad": "無法載入資源", + "memberPortalTryAgain": "再試一次", + "memberPortalNoResourcesFound": "找不到資源", + "memberPortalNoResourcesAvailable": "無可用資源", + "memberPortalNoResourcesMatchSearch": "沒有與\"{query}\"匹配的資源。嘗試調整您的搜尋詞或清除搜尋以檢視所有資源。", + "memberPortalNoResourcesAccess": "您尚無存取任何資源的許可權。請聯絡您的管理員取得所需資源的存取許可權。", + "memberPortalClearSearch": "清除搜尋", + "memberPortalPublicResources": "公共資源", + "memberPortalPublicResourcesDescription": "通過瀏覽器可存取的網路應用程式和服務", + "memberPortalCopiedToClipboard": "已複製到剪貼簿", + "memberPortalCopiedUrlDescription": "資源 URL 已複製到您的剪貼簿。", + "memberPortalOpenResource": "開啟資源", + "memberPortalPrivateResources": "私有資源", + "memberPortalPrivateResourcesDescription": "通過客戶端可存取的內部網路資源", + "memberPortalResourceDetails": "資源詳情", + "memberPortalMode": "模式", + "memberPortalDestination": "目標", + "memberPortalAlias": "別名", + "memberPortalCopiedAliasDescription": "資源別名已複製到您的剪貼簿。", + "memberPortalCopiedDestinationDescription": "資源目的地已複製到您的剪貼簿。", + "memberPortalRequiresClientConnection": "需要客戶端連線", + "memberPortalAuthMethods": "身分驗證方法", + "memberPortalSso": "單一登入 (SSO)", + "memberPortalPasswordProtected": "密碼保護", + "memberPortalPinCode": "PIN 碼", + "memberPortalEmailWhitelist": "電子郵件白名單", + "memberPortalResourceDisabled": "資源已停用", + "memberPortalShowingResources": "顯示 {start}-{end} 共 {total} 個資源", + "resourceLauncherTitle": "資源啟動器", + "resourceSidebarLauncherTitle": "啟動器", + "resourceLauncherDescription": "檢視所有可用資源,並從一箇中央集中存取它們", + "resourceLauncherSearchPlaceholder": "搜尋您的資源……", + "resourceLauncherDefaultView": "預設", + "resourceLauncherSaveView": "儲存檢視", + "resourceLauncherSaveToCurrentView": "儲存至當前檢視", + "resourceLauncherSaveDefaultPersonal": "儲存到我的", + "resourceLauncherResetView": "重置檢視", + "resourceLauncherResetSystemDefault": "重置為系統預設", + "resourceLauncherSystemDefaultRestored": "系統預設已恢復", + "resourceLauncherSystemDefaultRestoredDescription": "已將預設檢視重置為原始設定。", + "resourceLauncherSaveAsNewView": "另存為新檢視", + "resourceLauncherSaveAsNewViewDescription": "為此檢視命名,以便儲存您當前的過濾器和佈局。", + "resourceLauncherSaveForEveryone": "為所有人儲存", + "resourceLauncherSaveForEveryoneDescription": "與所有組織成員共享此檢視。如果未選中,此檢視僅對您可見。", + "resourceLauncherMakePersonal": "建立個人", + "resourceLauncherFilter": "篩選", + "resourceLauncherFilterWithCount": "篩選,已套用{count}個", + "resourceLauncherSort": "排序", + "resourceLauncherSortAscending": "按升序排序", + "resourceLauncherSortDescending": "按降序排序", + "resourceLauncherSettings": "設定", + "resourceLauncherGroupBy": "按組", + "resourceLauncherGroupBySite": "站台", + "resourceLauncherGroupByLabel": "標籤", + "resourceLauncherGroupByNone": "無", + "resourceLauncherLayout": "佈局", + "resourceLauncherLayoutGrid": "網格", + "resourceLauncherLayoutList": "列表", + "resourceLauncherShowLabels": "顯示標籤", + "resourceLauncherShowSiteTags": "顯示站台標籤", + "resourceLauncherShowRecents": "顯示最近使用", + "resourceLauncherDeleteView": "刪除檢視", + "resourceLauncherDeleteViewTitle": "刪除檢視", + "resourceLauncherDeleteViewQuestion": "您確定要刪除此啟動器檢視嗎?", + "resourceLauncherDeleteViewConfirm": "刪除檢視", + "resourceLauncherViewAsAdmin": "以管理員身分檢視", + "resourceLauncherResourceDetailsDescription": "此資源的連線資訊和狀態。", + "resourceLauncherResourceDetails": "資源詳情", + "resourceLauncherAuthMethodsDescription": "此資源啟用的身分驗證方法。", + "resourceLauncherPrivateClientRequired": "請在您的裝置上連線客戶端以私密存取此資源。", + "resourceLauncherPrivateClientRequiredTitle": "需要客戶端連線", + "resourceLauncherDownloadClient": "下載客戶端", + "resourceLauncherFailedToLoadDetails": "無法載入資源詳細資訊。您可能已經沒有許可權存取此資源。", + "resourceLauncherNoPortRestrictions": "無埠限制", + "resourceLauncherTcp": "TCP", + "resourceLauncherUdp": "UDP", + "resourceLauncherUnlabeled": "未標記", + "resourceLauncherNoSite": "無站台", + "resourceLauncherNoResourcesInGroup": "此組中沒有資源", + "resourceLauncherEmptyStateTitle": "沒有可用資源", + "resourceLauncherEmptyStateDescription": "您還沒有存取任何資源。請聯絡您的管理員以請求存取。", + "resourceLauncherEmptyStateNoResultsTitle": "未找到資源", + "resourceLauncherEmptyStateNoResultsDescription": "沒有資源與您當前的搜尋或過濾器匹配。嘗試調整它們以找到您想要的內容。", + "resourceLauncherEmptyStateNoResultsWithQuery": "沒有資源匹配\"{query}\"。嘗試調整您的搜尋或清除過濾器以檢視所有資源。", + "resourceLauncherSearchFirstTitle": "搜尋或篩選以瀏覽", + "resourceLauncherSearchFirstDescription": "您可以存取許多資源。使用搜尋或按站台或標籤篩選以查詢所需內容。", + "resourceLauncherSiteGroupingDisabled": "在此規模下無法進行站台分組。通過站台進行篩選以分組更小的集合。", + "resourceLauncherLabelGroupingDisabled": "在此規模下無法進行標籤分組。", + "resourceLauncherCompactModeHint": "顯示簡化列表以便快速瀏覽。使用搜尋或篩選器縮小結果範圍。", + "resourceLauncherCompactGroupingHint": "套用站台或標籤篩選器以啟用分組。", + "resourceLauncherCopiedToClipboard": "已複製到剪貼簿", + "resourceLauncherCopiedAccessDescription": "資源存取許可權已複製到剪貼簿。", + "resourceLauncherViewNamePlaceholder": "檢視名稱", + "resourceLauncherViewNameLabel": "檢視名稱", + "resourceLauncherViewSaved": "檢視已儲存", + "resourceLauncherViewSavedDescription": "您的啟動器檢視已儲存。", + "resourceLauncherViewSaveFailed": "儲存檢視失敗", + "resourceLauncherViewSaveFailedDescription": "無法儲存啟動器檢視。請再試一次。", + "resourceLauncherViewDeleted": "檢視已刪除", + "resourceLauncherViewDeletedDescription": "啟動器檢視已刪除。", + "resourceLauncherViewDeleteFailed": "刪除檢視失敗", + "resourceLauncherViewDeleteFailedDescription": "無法刪除啟動器檢視。請再試一次。", + "memberPortalPrevious": "上一頁", + "memberPortalNext": "下一頁", + "httpSettings": "HTTP 設定", + "tcpSettings": "TCP 設定", + "udpSettings": "UDP 設定", + "sshTitle": "SSH", + "sshConnectingDescription": "正在建立安全連線…", + "sshConnecting": "正在連線…", + "sshInitializing": "初始化中…", + "sshSignInTitle": "登入 SSH", + "sshSignInDescription": "輸入您的 SSH 憑據以進行連線", + "sshPasswordTab": "密碼", + "sshPrivateKeyTab": "私鑰", + "sshPrivateKeyField": "私鑰", + "sshPrivateKeyDisclaimer": "您的私鑰不會被 Pangolin 儲存或顯示。或者,您可以使用短期證書,使用您現有的 Pangolin 身分無縫認證。", + "sshLearnMore": "瞭解更多", + "sshPrivateKeyFile": "私鑰檔案", + "sshAuthenticate": "連線", + "sshTerminate": "終止", + "sshPoweredBy": "支持者", + "sshErrorNoTarget": "未指定目標", + "sshErrorWebSocket": "WebSocket 連線失敗", + "sshErrorAuthFailed": "身分驗證失敗", + "sshErrorConnectionClosed": "認證完成前連線已關閉", + "sitePangolinSshDescription": "允許對該站台的資源進行 SSH 存取。可稍後更改。", + "browserGatewayNoResourceForDomain": "未找到該域的資源", + "browserGatewayNoTarget": "沒有目標", + "browserGatewayConnect": "連線", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "簽署 SSH 金鑰以進行 PAM 推送身分驗證失敗。您以使用者身分登入了嗎?", + "sshTerminalError": "錯誤: {error}", + "sshConnectionClosedCode": "連線關閉 (程式碼 {code})", + "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "sshPrivateKeyRequired": "需要私鑰", + "vncTitle": "VNC", + "vncSignInDescription": "輸入您的VNC憑據以連線", + "vncUsernameOptional": "使用者名稱(可選)", + "vncPasswordOptional": "密碼 (可選)", + "vncNoResourceTarget": "沒有可用的資源目標", + "vncFailedToLoadNovnc": "載入 noVNC 失敗", + "vncAuthFailedStatus": "狀態 {status}", + "vncPasteClipboard": "貼上剪貼簿", + "rdpTitle": "RDP", + "rdpSignInTitle": "登入到遠端桌面", + "rdpSignInDescription": "輸入 Windows 憑據以連線", + "rdpLoadingModule": "載入模組中……", + "rdpFailedToLoadModule": "載入 RDP 模組失敗", + "rdpNotReady": "未準備好", + "rdpModuleInitializing": "RDP 模組仍在初始化中", + "rdpDownloadingFiles": "正在從遠端下載 {count} 個檔案...", + "rdpDownloadFailed": "下載失敗: {fileName}", + "rdpUploaded": "已上傳: {fileName}", + "rdpNoConnectionTarget": "沒有可用的連線目標", + "rdpConnectionFailed": "連線失敗", + "rdpFit": "適合", + "rdpFull": "完整", + "rdpReal": "真實", + "rdpMeta": "後設資料", + "rdpUploadFiles": "上傳檔案", + "rdpFilesReadyToPaste": "檔案準備好貼上", + "rdpFilesReadyToPasteDescription": "已將 {count} 個檔案複製到遠端剪貼簿——在遠端桌面上按 Ctrl+V 進行貼上", + "rdpUploadFailed": "上傳失敗", + "rdpUnicodeKeyboardMode": "Unicode 鍵盤模式", + "sessionToolbarShow": "顯示工具欄", + "sessionToolbarHide": "隱藏工具欄", + "actionUpdateSiteApprovals": "更新站台審批", + "shareAssociateUserOptional": "關聯使用者(選填)", + "shareAssociateUserDescription": "設定後,使用此連結的請求會在存取記錄與身分標頭中歸屬於該使用者。若該使用者離開組織,連結將被移除。", + "userSelect": "選擇使用者", + "usersNotFound": "未找到使用者", + "sharePersistSession": "首次使用後保持工作階段", + "sharePersistSessionDescription": "啟用後,首次透過查詢參數或標頭帶有此權杖的請求會設定工作階段 cookie,之後的請求便不需再帶權杖。若 API 用戶端應在每個請求中都帶權杖,請保持關閉。", + "domainPickerNoDomainsAvailableTitle": "沒有可用的網域", + "domainPickerNoDomainsAvailableDescription": "您尚未設定任何網域。請建立網域以繼續。", + "domainPickerNoDomainsAvailableAction": "前往網域" } From 832e382888448ba2758feb554284e3178c2338fd Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 23 Jul 2026 22:00:31 +0100 Subject: [PATCH 018/678] =?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 019/678] =?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 020/678] =?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 021/678] =?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 022/678] =?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 023/678] 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 024/678] 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 025/678] 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 026/678] 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 027/678] 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 028/678] 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 029/678] =?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 030/678] 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 031/678] =?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 032/678] =?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 033/678] =?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 034/678] =?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 035/678] 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 036/678] 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 037/678] 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 192542629f52d047af6b699025011624f03c6029 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:59:23 +0000 Subject: [PATCH 038/678] Bump engine.io from 6.6.7 to 6.6.9 Bumps [engine.io](https://github.com/socketio/socket.io) from 6.6.7 to 6.6.9. - [Release notes](https://github.com/socketio/socket.io/releases) - [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md) - [Commits](https://github.com/socketio/socket.io/compare/engine.io@6.6.7...engine.io@6.6.9) --- updated-dependencies: - dependency-name: engine.io dependency-version: 6.6.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 30 ++++-------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 975d49ed8..bcaa8e8cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11109,9 +11109,9 @@ } }, "node_modules/engine.io": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz", - "integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==", + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", "dev": true, "license": "MIT", "dependencies": { @@ -11124,7 +11124,7 @@ "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.18.3" + "ws": "~8.21.0" }, "engines": { "node": ">=10.2.0" @@ -11187,28 +11187,6 @@ "node": ">= 0.6" } }, - "node_modules/engine.io/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/enhanced-resolve": { "version": "5.21.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", From 263976bf419b481b77dc2ad86134fc723f38cb97 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Jul 2026 18:13:40 -0400 Subject: [PATCH 039/678] 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 040/678] 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 041/678] 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 042/678] 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 043/678] 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 044/678] 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 045/678] 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 046/678] 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 047/678] 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 048/678] 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 049/678] 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 050/678] 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 051/678] 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 052/678] 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 053/678] 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 cb3f0b49a8a8216dda33f17f3fa06e0d892c93c0 Mon Sep 17 00:00:00 2001 From: Shubham Singh Date: Thu, 30 Jul 2026 14:29:25 +0530 Subject: [PATCH 054/678] Fix regional locale detection --- src/i18n/detectLocale.test.ts | 74 +++++++++++++++++++++++++++++++++++ src/i18n/detectLocale.ts | 48 +++++++++++++++++++++++ src/services/locale.ts | 8 +--- 3 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 src/i18n/detectLocale.test.ts create mode 100644 src/i18n/detectLocale.ts diff --git a/src/i18n/detectLocale.test.ts b/src/i18n/detectLocale.test.ts new file mode 100644 index 000000000..ba0ae047b --- /dev/null +++ b/src/i18n/detectLocale.test.ts @@ -0,0 +1,74 @@ +import { assertEquals } from "@test/assert"; +import { detectLocale } from "./detectLocale"; + +function runTests() { + assertEquals( + detectLocale("zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7,ja;q=0.6"), + "zh-TW", + "An exact regional match should take precedence over a language fallback" + ); + assertEquals( + detectLocale("ZH-tw"), + "zh-TW", + "Locale matching should be case-insensitive" + ); + assertEquals( + detectLocale(" zh-TW ; q=1 , zh-CN;q=0.8 "), + "zh-TW", + "Whitespace and quality parameters should not prevent an exact match" + ); + assertEquals( + detectLocale("zh-CN,zh-TW;q=0.9"), + "zh-CN", + "Simplified Chinese should still match exactly" + ); + assertEquals( + detectLocale("zh"), + "zh-CN", + "A generic Chinese preference should retain the existing fallback" + ); + assertEquals( + detectLocale("en-GB,en;q=0.9"), + "en-US", + "An unsupported region should fall back to a supported locale for the language" + ); + assertEquals( + detectLocale("ja-JP,zh-TW;q=0.9"), + "zh-TW", + "The next preference should be used when a language is unsupported" + ); + assertEquals( + detectLocale("zh-CN;q=0.5,zh-TW;q=0.9"), + "zh-TW", + "Preferences should be evaluated by quality" + ); + assertEquals( + detectLocale("zh-TW;q=0,fr-FR;q=0.8"), + "fr-FR", + "Locales with zero quality should be excluded" + ); + assertEquals( + detectLocale("*,de-DE;q=0.8"), + "de-DE", + "A wildcard should not obscure a supported preference" + ); + assertEquals( + detectLocale("ja-JP"), + undefined, + "An unsupported language should not match" + ); + assertEquals( + detectLocale(""), + undefined, + "An empty Accept-Language header should not match" + ); + + console.log("All locale detection tests passed!"); +} + +try { + runTests(); +} catch (error) { + console.error("Locale detection test failed:", error); + process.exit(1); +} diff --git a/src/i18n/detectLocale.ts b/src/i18n/detectLocale.ts new file mode 100644 index 000000000..1588db61d --- /dev/null +++ b/src/i18n/detectLocale.ts @@ -0,0 +1,48 @@ +import { Locale, locales } from "./config"; + +export function detectLocale(acceptLanguage: string): Locale | undefined { + const browserLocales = acceptLanguage + .split(",") + .map((entry, index) => { + const [locale, ...parameters] = entry.trim().split(";"); + const qualityParameter = parameters.find((parameter) => + parameter.trim().toLowerCase().startsWith("q=") + ); + const quality = qualityParameter + ? Number(qualityParameter.trim().slice(2)) + : 1; + + return { + locale: locale.trim().toLowerCase(), + quality, + index + }; + }) + .filter( + ({ locale, quality }) => + locale && locale !== "*" && quality > 0 && quality <= 1 + ) + .sort( + (left, right) => + right.quality - left.quality || left.index - right.index + ); + + for (const { locale: browserLocale } of browserLocales) { + const exactMatch = locales.find( + (locale) => locale.toLowerCase() === browserLocale + ); + if (exactMatch) { + return exactMatch; + } + + const browserLanguage = browserLocale.split("-")[0]; + const languageMatch = locales.find( + (locale) => locale.split("-")[0].toLowerCase() === browserLanguage + ); + if (languageMatch) { + return languageMatch; + } + } + + return undefined; +} diff --git a/src/services/locale.ts b/src/services/locale.ts index 2d3e1d21f..ebea903cd 100644 --- a/src/services/locale.ts +++ b/src/services/locale.ts @@ -2,6 +2,7 @@ import { cookies, headers } from "next/headers"; import { Locale, defaultLocale, locales } from "@/i18n/config"; +import { detectLocale } from "@/i18n/detectLocale"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; @@ -47,12 +48,7 @@ export async function getUserLocale(): Promise { const acceptLang = headerList.get("accept-language"); if (acceptLang) { - const browserLang = acceptLang.split(",")[0]; - const matched = locales.find((locale) => - browserLang - .toLowerCase() - .startsWith(locale.split("-")[0].toLowerCase()) - ); + const matched = detectLocale(acceptLang); if (matched) { return matched; } From b0e274f5a9f8879f01f036b91dc4b90f0769d0f3 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 30 Jul 2026 11:14:34 -0400 Subject: [PATCH 055/678] 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 056/678] =?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 057/678] =?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 058/678] =?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 059/678] =?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 060/678] =?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 061/678] =?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 062/678] 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 063/678] 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 064/678] 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 065/678] 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 066/678] 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 067/678] 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 d374b4f66eaba13fe7dd9d9fb88ec92a705fd94a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:45:48 +0000 Subject: [PATCH 068/678] Bump ip-address from 10.2.0 to 10.4.0 Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0. - [Release notes](https://github.com/beaugunderson/ip-address/releases) - [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0) --- updated-dependencies: - dependency-name: ip-address dependency-version: 10.4.0 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 975d49ed8..8321d2747 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13111,9 +13111,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" From 152d2fb1d67c71c8e11f18de955b12e02c4e2d17 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 05:33:39 +0000 Subject: [PATCH 069/678] Bump brace-expansion Bumps and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together. Updates `brace-expansion` from 5.0.6 to 5.0.9 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.9) Updates `brace-expansion` from 1.1.14 to 1.1.18 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.9) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 5.0.9 dependency-type: indirect - dependency-name: brace-expansion dependency-version: 1.1.18 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 975d49ed8..3f6389582 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9504,15 +9504,15 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -11645,9 +11645,9 @@ "license": "MIT" }, "node_modules/eslint-config-next/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { From f079714cafb4f2307a1fac8d9c6105a005cea58f Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 4 Aug 2026 10:07:52 -0400 Subject: [PATCH 070/678] 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 071/678] 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 072/678] 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 073/678] 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 074/678] 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 075/678] 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 076/678] 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 077/678] 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 078/678] 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 079/678] 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 080/678] 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 081/678] 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 082/678] 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 083/678] 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 084/678] 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 085/678] 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 086/678] 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 087/678] 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 088/678] 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 089/678] 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 090/678] 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 091/678] 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 092/678] 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 093/678] 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 094/678] 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 095/678] 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 096/678] 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 097/678] 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 098/678] 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 099/678] 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 100/678] 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 101/678] 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 102/678] 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 103/678] =?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 104/678] 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 112/678] 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 113/678] 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 b4463f0e1ab727d3b9d156e2790e82346479eb05 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:42:16 +0000 Subject: [PATCH 114/678] Bump socket.io-parser from 4.2.6 to 4.2.7 Bumps [socket.io-parser](https://github.com/socketio/socket.io) from 4.2.6 to 4.2.7. - [Release notes](https://github.com/socketio/socket.io/releases) - [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md) - [Commits](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.6...socket.io-parser@4.2.7) --- updated-dependencies: - dependency-name: socket.io-parser dependency-version: 4.2.7 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 975d49ed8..f7eac18ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17072,9 +17072,9 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", - "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", "dev": true, "license": "MIT", "dependencies": { From 425a99e5eef1395831a6d1cf4887a3674806a98b Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 5 Aug 2026 09:59:41 -0400 Subject: [PATCH 115/678] 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 116/678] 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 117/678] 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 118/678] 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 119/678] 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 120/678] 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 121/678] 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 4677a0d5010603cbd58078c8805e4b9f72846f45 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:18:41 +0000 Subject: [PATCH 122/678] Bump fast-uri from 3.1.4 to 3.1.5 Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.5 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 975d49ed8..3387ad9e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12169,9 +12169,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { From 2e9bd501721d8d4fb7a6cc62ad6cae6a38ad2455 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 5 Aug 2026 15:17:50 -0400 Subject: [PATCH 123/678] 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 124/678] 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 125/678] 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 126/678] 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 127/678] 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 128/678] 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 129/678] 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 130/678] 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 131/678] 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 136/678] 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 137/678] 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 138/678] 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 139/678] 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 140/678] 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 141/678] 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 142/678] 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 143/678] 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 144/678] 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 145/678] 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 146/678] 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 147/678] 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 148/678] 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 149/678] 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 150/678] 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 151/678] 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 152/678] 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 153/678] 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 154/678] =?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 155/678] 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 156/678] 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 157/678] 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 158/678] =?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 159/678] =?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 160/678] 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 161/678] 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 162/678] 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 163/678] 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 164/678] 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 165/678] 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 166/678] 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 167/678] 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 168/678] 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 169/678] 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 170/678] 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 171/678] 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 172/678] 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 173/678] 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 174/678] 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 175/678] 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 176/678] 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 177/678] 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 178/678] 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 179/678] 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 180/678] 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 181/678] 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 182/678] 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 183/678] 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 184/678] 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 185/678] 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 186/678] 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 187/678] 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 188/678] 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 189/678] 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 190/678] 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 191/678] 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 192/678] 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 193/678] 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 194/678] 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 195/678] 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 196/678] 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 197/678] 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 198/678] 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 199/678] 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 200/678] =?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 201/678] 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 202/678] 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 204/678] 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 205/678] 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 206/678] 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 20:19:18 +0000 Subject: [PATCH 207/678] Bump js-yaml from 4.3.0 to 4.3.1 Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.0 to 4.3.1. - [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.3.0...4.3.1) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.3.1 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 975d49ed8..91d149bb0 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.3.0", + "js-yaml": "4.3.1", "jsonwebtoken": "9.0.3", "lucide-react": "1.17.0", "maxmind": "5.0.6", @@ -13633,9 +13633,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 058314d9f..e00e1d982 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.3.0", + "js-yaml": "4.3.1", "jsonwebtoken": "9.0.3", "lucide-react": "1.17.0", "maxmind": "5.0.6", From 21032bc22b3e923de212a324c08cb29f94b9ee4f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 11 Aug 2026 22:25:26 +0200 Subject: [PATCH 208/678] =?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 209/678] 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 210/678] 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 211/678] 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 212/678] 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 213/678] 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 81430ba3d33a5b5743e7609113a366ca8a6f6e92 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:54:36 +0000 Subject: [PATCH 214/678] Bump nanoid from 3.3.12 to 3.3.18 Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.12 to 3.3.18. - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/3.3.18/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/3.3.12...3.3.18) --- updated-dependencies: - dependency-name: nanoid dependency-version: 3.3.18 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 975d49ed8..1522a578c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14537,9 +14537,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", From 923371e5b44cccd32c997232925530f8cc764b30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:57:16 +0000 Subject: [PATCH 215/678] Bump postcss from 8.5.15 to 8.5.23 Bumps [postcss](https://github.com/postcss/postcss) from 8.5.15 to 8.5.23. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.15...8.5.23) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.23 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 975d49ed8..f1bda2d12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -146,7 +146,7 @@ "esbuild-node-externals": "1.22.0", "eslint": "10.4.0", "eslint-config-next": "16.2.6", - "postcss": "8.5.15", + "postcss": "8.5.23", "prettier": "3.8.3", "react-email": "6.5.0", "tailwindcss": "4.3.0", @@ -14537,9 +14537,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -15660,9 +15660,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -15679,7 +15679,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/package.json b/package.json index 058314d9f..fb53bc928 100644 --- a/package.json +++ b/package.json @@ -169,7 +169,7 @@ "esbuild-node-externals": "1.22.0", "eslint": "10.4.0", "eslint-config-next": "16.2.6", - "postcss": "8.5.15", + "postcss": "8.5.23", "prettier": "3.8.3", "react-email": "6.5.0", "tailwindcss": "4.3.0", @@ -181,6 +181,6 @@ "overrides": { "esbuild": "0.28.1", "dompurify": "3.4.0", - "postcss": "8.5.15" + "postcss": "8.5.23" } } From 295e38d2af6314b25daf5e264dc9e594c1a97165 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 12 Aug 2026 09:11:10 -0400 Subject: [PATCH 216/678] 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 217/678] 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 218/678] 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 219/678] 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 220/678] 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 221/678] 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 222/678] 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 223/678] 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 224/678] 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 225/678] 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 226/678] 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 227/678] =?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 228/678] =?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 229/678] 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 230/678] 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 231/678] =?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 232/678] 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 241/678] 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 242/678] 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 243/678] 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 244/678] 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 245/678] 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 246/678] 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 247/678] =?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 248/678] =?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 249/678] =?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 253/678] 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 254/678] 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( + "virtualApiKeysName" + )} + + + + + + + )} + /> - ( - - - {t( - "virtualApiKeysDescriptionOptional" - )} - - - - - - - )} - /> - -
- - {t( - "virtualApiKeysAssociateUserOptional" - )} - - - - - - - - - -

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

-
- -
-
- { - setAllResources( - val as boolean - ); - if (val) { - setSelectedResources( - [] - ); - } - }} - className="mt-0.5" + ( + + + {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 266/678] 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 267/678] 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 268/678] 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 272/678] 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 273/678] 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 274/678] 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 275/678] 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 276/678] 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 277/678] 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 278/678] 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 279/678] 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 280/678] 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 281/678] =?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 282/678] 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 283/678] 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 284/678] 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 285/678] 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 286/678] 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 668a04bcd27b65abffb74e3a1b3d90432afa5f2b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 14 Aug 2026 21:52:16 +0200 Subject: [PATCH 287/678] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20IP=20column=20fil?= =?UTF-8?q?tering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routers/auditLogs/queryRequestAuditLog.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index 7f4a0ec16..24116aa31 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -81,7 +81,27 @@ export const queryAccessAuditLogsQuery = z.strictObject({ .optional() .default("0") .transform(Number) - .pipe(z.int().nonnegative()) + .pipe(z.int().nonnegative()), + ips: 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())) + .optional() + .catch([]) + .openapi({ + type: "array", + description: "Filter by IP adresses" + }) }); export const queryRequestAuditLogsParams = z.object({ From a3dfb30a428bf8d6825ddb7b4747d8d3490bc66d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 14 Aug 2026 16:51:10 -0400 Subject: [PATCH 288/678] 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 289/678] 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 290/678] 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 813c3abe54c9d76e5eb4179a614e3b9862984e09 Mon Sep 17 00:00:00 2001 From: Shlee Date: Sun, 16 Aug 2026 22:03:00 +0930 Subject: [PATCH 291/678] Update requestPasswordReset.ts --- server/routers/auth/requestPasswordReset.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index aca435c7b..be9769f23 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -49,7 +49,12 @@ export async function requestPasswordReset( const existingUser = await db .select() .from(users) - .where(eq(users.email, email)); + .where( + and( + eq(users.email, email), + eq(users.type, UserType.Internal) + ) + ); if (!existingUser || !existingUser.length) { await randomDelay(2000); From 4edd2e4d321e2734019c0b6c98841a86770952d9 Mon Sep 17 00:00:00 2001 From: Shlee Date: Sun, 16 Aug 2026 22:21:37 +0930 Subject: [PATCH 292/678] Update requestPasswordReset.ts --- server/routers/auth/requestPasswordReset.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index be9769f23..13a9283ff 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib/response"; import { db } from "@server/db"; import { passwordResetTokens, users } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import { alphabet, generateRandomString, sha256 } from "oslo/crypto"; import { createDate } from "oslo"; import logger from "@server/logger"; From eb8ad6a181b085990f0b64e6c9f9bbf4e249b3b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:34:14 +0000 Subject: [PATCH 293/678] Bump the github-actions-dependencies group across 1 directory with 2 updates Bumps the github-actions-dependencies group with 2 updates in the / directory: [docker/login-action](https://github.com/docker/login-action) and [actions/stale](https://github.com/actions/stale). Updates `docker/login-action` from 4.5.1 to 4.6.0 - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/abd2ef45e78c5afb21d64d4ca52ee8550d9572c7...dbcb813823bdd20940b903addbd779551569679f) Updates `actions/stale` from 10.4.0 to 11.0.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/1e223db275d687790206a7acac4d1a11bd6fe629...4391f3da665fdf50b6810c1a66712fb9ba21aa93) --- updated-dependencies: - dependency-name: actions/stale dependency-version: 11.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions-dependencies - dependency-name: docker/login-action dependency-version: 4.6.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 | 8 ++++---- .github/workflows/stale-bot.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 558a946ce..fc26f1e5f 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -77,7 +77,7 @@ jobs: fi - name: Log in to Docker Hub - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} @@ -149,7 +149,7 @@ jobs: fi - name: Log in to Docker Hub - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} @@ -204,7 +204,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Log in to Docker Hub - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} @@ -407,7 +407,7 @@ jobs: shell: bash - name: Login to GitHub Container Registry (for cosign) - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index 965dcfa06..4f44a38f2 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@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: days-before-stale: 14 days-before-close: 14 From b51aecf45a609f2c97977bf3a64a3dcd8908c972 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 16:33:05 -0400 Subject: [PATCH 294/678] 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() {