mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-21 11:42:37 +02:00
@@ -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(<scopeColumn>, 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.
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
aiBudgets,
|
||||
aiModels,
|
||||
aiProviders,
|
||||
db,
|
||||
resources,
|
||||
roles,
|
||||
siteResources,
|
||||
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, isNull } 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(),
|
||||
virtualApiKeyId: z.string().nonempty().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<any> {
|
||||
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,
|
||||
virtualApiKeyId,
|
||||
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`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
: modelId !== undefined
|
||||
? eq(aiBudgets.modelId, modelId)
|
||||
: resourceId !== undefined
|
||||
? eq(aiBudgets.resourceId, resourceId)
|
||||
: siteResourceId !== undefined
|
||||
? eq(aiBudgets.siteResourceId, siteResourceId)
|
||||
: roleId !== undefined
|
||||
? eq(aiBudgets.roleId, 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 })
|
||||
.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();
|
||||
const [budget] = await db
|
||||
.insert(aiBudgets)
|
||||
.values({
|
||||
orgId,
|
||||
providerId: providerId ?? null,
|
||||
modelId: modelId ?? null,
|
||||
resourceId: resourceId ?? null,
|
||||
siteResourceId: siteResourceId ?? null,
|
||||
roleId: roleId ?? null,
|
||||
virtualApiKeyId: virtualApiKeyId ?? null,
|
||||
amount,
|
||||
unit,
|
||||
period,
|
||||
enforcement,
|
||||
enabled: enabled ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return response<CreateOrEditAiBudgetResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<any> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<any> {
|
||||
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<GetAiBudgetResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from "./createAiBudget";
|
||||
export * from "./listAiBudgets";
|
||||
export * from "./listAiBudgetsForProvider";
|
||||
export * from "./listAiBudgetsForModel";
|
||||
export * from "./listAiBudgetsForResource";
|
||||
export * from "./listAiBudgetsForSiteResource";
|
||||
export * from "./listAiBudgetsForRole";
|
||||
export * from "./listAiBudgetsForVirtualApiKey";
|
||||
export * from "./getAiBudget";
|
||||
export * from "./updateAiBudget";
|
||||
export * from "./deleteAiBudget";
|
||||
export * from "./types";
|
||||
@@ -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<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>()
|
||||
.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<any> {
|
||||
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<ListAiBudgetsResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<any> {
|
||||
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.budgetId));
|
||||
|
||||
return response<ListAiBudgetsByScopeResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<any> {
|
||||
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.budgetId));
|
||||
|
||||
return response<ListAiBudgetsByScopeResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<any> {
|
||||
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.budgetId));
|
||||
|
||||
return response<ListAiBudgetsByScopeResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<any> {
|
||||
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.budgetId));
|
||||
|
||||
return response<ListAiBudgetsByScopeResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<any> {
|
||||
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.budgetId));
|
||||
|
||||
return response<ListAiBudgetsByScopeResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<any> {
|
||||
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<ListAiBudgetsByScopeResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AiBudget } from "@server/db";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
|
||||
export type ListAiBudgetsResponse = PaginatedResponse<{
|
||||
budgets: AiBudget[];
|
||||
}>;
|
||||
|
||||
export type ListAiBudgetsByScopeResponse = {
|
||||
budgets: AiBudget[];
|
||||
};
|
||||
|
||||
export type GetAiBudgetResponse = {
|
||||
budget: AiBudget;
|
||||
};
|
||||
|
||||
export type CreateOrEditAiBudgetResponse = {
|
||||
budget: AiBudget;
|
||||
};
|
||||
@@ -0,0 +1,384 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
aiBudgets,
|
||||
aiModels,
|
||||
aiProviders,
|
||||
db,
|
||||
resources,
|
||||
roles,
|
||||
siteResources,
|
||||
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, isNull, 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(),
|
||||
virtualApiKeyId: z.string().nonempty().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<any> {
|
||||
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 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;
|
||||
|
||||
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(),
|
||||
virtualApiKeyId: z.string().nullable().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => refineBudgetScopeFields(data, ctx))
|
||||
.safeParse({
|
||||
providerId: nextProviderId,
|
||||
modelId: nextModelId,
|
||||
resourceId: nextResourceId,
|
||||
siteResourceId: nextSiteResourceId,
|
||||
roleId: nextRoleId,
|
||||
virtualApiKeyId: nextVirtualApiKeyId
|
||||
});
|
||||
|
||||
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`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
: nextModelId !== null
|
||||
? eq(aiBudgets.modelId, nextModelId)
|
||||
: nextResourceId !== null
|
||||
? eq(aiBudgets.resourceId, nextResourceId)
|
||||
: nextSiteResourceId !== null
|
||||
? eq(aiBudgets.siteResourceId, nextSiteResourceId)
|
||||
: nextRoleId !== null
|
||||
? eq(aiBudgets.roleId, nextRoleId)
|
||||
: 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 })
|
||||
.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<typeof aiBudgets.$inferInsert> = {
|
||||
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.virtualApiKeyId !== undefined) {
|
||||
updateData.virtualApiKeyId = body.virtualApiKeyId;
|
||||
}
|
||||
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<CreateOrEditAiBudgetResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
virtualApiKeyId?: string | null;
|
||||
},
|
||||
ctx: z.RefinementCtx
|
||||
) {
|
||||
const scopeFields = [
|
||||
data.providerId,
|
||||
data.modelId,
|
||||
data.resourceId,
|
||||
data.siteResourceId,
|
||||
data.roleId,
|
||||
data.virtualApiKeyId
|
||||
];
|
||||
|
||||
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, roleId, or virtualApiKeyId may be set on a budget",
|
||||
path: ["providerId"]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { handleAiGatewayProxy } from "./pipeline";
|
||||
export { createAiGatewayRouter } from "./createAiGatewayRouter";
|
||||
@@ -0,0 +1,275 @@
|
||||
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";
|
||||
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.
|
||||
const AI_SESSION_LOG_MAX_BODY_CHARS = 200_000;
|
||||
|
||||
type AiSessionLogInsert = InferInsertModel<typeof aiSessionLog>;
|
||||
|
||||
// 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<number> {
|
||||
// check cache first
|
||||
const cached = await cache.get<number>(`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: {
|
||||
sessionId: string;
|
||||
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;
|
||||
virtualApiKeyId: 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 ?? "");
|
||||
|
||||
// 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);
|
||||
logger.warn(
|
||||
`AI session log buffer exceeded max size (${MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries`
|
||||
);
|
||||
}
|
||||
|
||||
sessionLogBuffer.push({
|
||||
sessionId: data.sessionId,
|
||||
orgId: sanitizeString(data.orgId),
|
||||
providerId: data.provider.providerId,
|
||||
capability: data.capability,
|
||||
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),
|
||||
responseBody: sanitizeString(responseBodyText.value),
|
||||
normalizedRequest: normalizedRequestText
|
||||
? sanitizeString(normalizedRequestText.value)
|
||||
: undefined,
|
||||
normalizedResponse: normalizedResponseText
|
||||
? sanitizeString(normalizedResponseText.value)
|
||||
: undefined,
|
||||
truncated:
|
||||
requestBodyText.truncated ||
|
||||
responseBodyText.truncated ||
|
||||
(normalizedRequestText?.truncated ?? false) ||
|
||||
(normalizedResponseText?.truncated ?? false),
|
||||
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 });
|
||||
}
|
||||
})();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { Request, Response } from "express";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
AiBudget,
|
||||
AiProvider,
|
||||
db,
|
||||
exitNodes,
|
||||
sites,
|
||||
targetHealthCheck,
|
||||
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 {
|
||||
AI_CAPABILITY_DEFS,
|
||||
type AiCapability
|
||||
} from "@server/lib/aiCapabilities";
|
||||
import { buildAiCapabilityErrorBody } from "@server/lib/aiGatewayAuthError";
|
||||
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";
|
||||
import {
|
||||
AI_GATEWAY_TRUST_HEADER,
|
||||
AI_GATEWAY_RESOURCE_TYPE_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
|
||||
// almost immediately without needing explicit cache invalidation.
|
||||
const PROVIDER_TARGETS_TTL_SEC = 7;
|
||||
|
||||
// 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";
|
||||
|
||||
// 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",
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"content-length",
|
||||
"accept-encoding",
|
||||
AI_GATEWAY_TRUST_HEADER.toLowerCase(),
|
||||
AI_GATEWAY_RESOURCE_TYPE_HEADER.toLowerCase()
|
||||
]);
|
||||
|
||||
type ResolvedProviderTarget = {
|
||||
targetId: number;
|
||||
// "<scheme>://<site exitNodeSubnet host>:<internalPort>", passed to
|
||||
// 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;
|
||||
};
|
||||
|
||||
async function fetchProviderTargets(
|
||||
providerId: number
|
||||
): Promise<ResolvedProviderTarget[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
targetId: targets.targetId,
|
||||
ip: targets.ip,
|
||||
internalPort: targets.internalPort,
|
||||
port: targets.port,
|
||||
method: targets.method,
|
||||
exitNodeSubnet: sites.exitNodeSubnet,
|
||||
reachableAt: exitNodes.reachableAt,
|
||||
hcHealth: targetHealthCheck.hcHealth
|
||||
})
|
||||
.from(targets)
|
||||
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||
.innerJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
|
||||
.leftJoin(
|
||||
targetHealthCheck,
|
||||
eq(targetHealthCheck.targetId, targets.targetId)
|
||||
)
|
||||
.where(
|
||||
and(eq(targets.providerId, providerId), eq(targets.enabled, true))
|
||||
);
|
||||
|
||||
const resolved: ResolvedProviderTarget[] = [];
|
||||
for (const row of rows) {
|
||||
// Sites not yet connected to an exit node (no subnet assigned) or
|
||||
// whose exit node has no known HTTP address can't be routed to.
|
||||
if (!row.exitNodeSubnet || !row.reachableAt) {
|
||||
continue;
|
||||
}
|
||||
// A target with an active health check that's currently failing is
|
||||
// taken out of rotation. No health check (null) or "unknown" (check
|
||||
// hasn't run yet / hcEnabled is off) still routes normally, matching
|
||||
// the convention in getTraefikConfig.ts's target selection.
|
||||
if (row.hcHealth === "unhealthy") {
|
||||
continue;
|
||||
}
|
||||
const host = row.exitNodeSubnet.split("/")[0];
|
||||
const port = row.internalPort ?? row.port;
|
||||
const scheme = row.method?.toLowerCase() ?? "https";
|
||||
resolved.push({
|
||||
targetId: row.targetId,
|
||||
destination: `${scheme}://${host}:${port}`,
|
||||
hostHeader: row.ip,
|
||||
gerbilBaseUrl: row.reachableAt
|
||||
});
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function getProviderTargets(
|
||||
providerId: number
|
||||
): Promise<ResolvedProviderTarget[]> {
|
||||
const cacheKey = `aiGateway:providerTargets:${providerId}`;
|
||||
const cached = localCache.get<ResolvedProviderTarget[]>(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const resolved = await fetchProviderTargets(providerId);
|
||||
localCache.set(cacheKey, resolved, PROVIDER_TARGETS_TTL_SEC);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Round-robin cursor per provider. Process-local and unpersisted - fine
|
||||
// since it only needs to spread load across targets, not guarantee a
|
||||
// perfectly even distribution across restarts or multiple server instances.
|
||||
const roundRobinCursors = new Map<number, number>();
|
||||
|
||||
function pickTarget(
|
||||
providerId: number,
|
||||
providerTargets: ResolvedProviderTarget[]
|
||||
): ResolvedProviderTarget {
|
||||
const cursor = roundRobinCursors.get(providerId) ?? 0;
|
||||
roundRobinCursors.set(providerId, cursor + 1);
|
||||
return providerTargets[cursor % providerTargets.length];
|
||||
}
|
||||
|
||||
function pathFromRequest(req: Request): string {
|
||||
// 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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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,
|
||||
res: Response,
|
||||
provider: AiProvider,
|
||||
requestUser: RequestUser | null,
|
||||
capability: AiCapability,
|
||||
ctx: {
|
||||
orgId: string | null;
|
||||
resourceId: number | null;
|
||||
siteResourceId: number | null;
|
||||
requestedModel: string | undefined;
|
||||
budgets: AiBudget[];
|
||||
virtualApiKeyId: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const providerTargets = await getProviderTargets(provider.providerId);
|
||||
if (providerTargets.length === 0) {
|
||||
res.status(HttpCode.INTERNAL_SERVER_ERROR).json(
|
||||
buildAiCapabilityErrorBody(
|
||||
capability,
|
||||
"internal",
|
||||
"AI provider has no reachable site targets configured",
|
||||
HttpCode.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = pickTarget(provider.providerId, providerTargets);
|
||||
const gerbilUrl = `${target.gerbilBaseUrl.replace(/\/+$/, "")}/router${pathFromRequest(req)}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (SKIP_HEADERS.has(key.toLowerCase()) || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
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(
|
||||
buildAiCapabilityErrorBody(
|
||||
capability,
|
||||
"internal",
|
||||
"AI provider has no API key configured",
|
||||
HttpCode.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const secret = config.getRawConfig().server.secret!;
|
||||
apiKey = decrypt(provider.apiKey, secret);
|
||||
}
|
||||
applyAiProviderCustomHeaders(
|
||||
headers,
|
||||
provider.headers,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
applyAiProviderAuthHeaders(headers, authType, apiKey);
|
||||
applyRequestUserHeaders(headers, requestUser);
|
||||
|
||||
headers[PANGOLIN_DEST_HEADER] = target.destination;
|
||||
headers[PANGOLIN_HOST_HEADER] = target.hostHeader;
|
||||
|
||||
// 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,
|
||||
targetId: target.targetId,
|
||||
destination: target.destination,
|
||||
hostHeader: target.hostHeader,
|
||||
url: gerbilUrl,
|
||||
headers,
|
||||
body: outboundBody
|
||||
});
|
||||
|
||||
// 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,
|
||||
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,
|
||||
targetId: target.targetId,
|
||||
error: fetchError,
|
||||
cause:
|
||||
fetchError instanceof Error
|
||||
? (fetchError as Error & { cause?: unknown }).cause
|
||||
: undefined
|
||||
});
|
||||
res.status(HttpCode.BAD_GATEWAY).json(
|
||||
buildAiCapabilityErrorBody(
|
||||
capability,
|
||||
"internal",
|
||||
"Failed to reach AI provider target",
|
||||
HttpCode.BAD_GATEWAY
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isStream = AI_CAPABILITY_DEFS[capability].isStreaming(
|
||||
req,
|
||||
upstreamRes.headers.get("content-type") || ""
|
||||
);
|
||||
|
||||
const { fullText, aborted } = await streamAiGatewayResponse({
|
||||
res,
|
||||
upstreamRes,
|
||||
isStream,
|
||||
injectedUsageOurselves,
|
||||
abortController,
|
||||
onClientClose
|
||||
});
|
||||
|
||||
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,
|
||||
virtualApiKeyId: ctx.virtualApiKeyId,
|
||||
budgets: ctx.budgets
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
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 { modelListTypeSchema } from "@server/lib/aiInferenceResource";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
modelKey: z.string().nonempty(),
|
||||
name: z.string().nonempty(),
|
||||
enabled: z.boolean().optional(),
|
||||
listType: modelListTypeSchema.optional().default("allow")
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/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<any> {
|
||||
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 { providerId } = parsedParams.data;
|
||||
const { modelKey, name, enabled, listType } = parsedBody.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 [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,
|
||||
listType,
|
||||
enabled: enabled ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return response<CreateOrEditAiModelResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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 {
|
||||
resolveAiProviderCreateFields,
|
||||
resolveCapabilitiesForCreate,
|
||||
serializeAiProviderHeaders
|
||||
} from "@server/lib/aiProviderDefaults";
|
||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
||||
import {
|
||||
aiAuthTypeSchema,
|
||||
aiCapabilitiesSchema,
|
||||
aiProviderHeadersSchema,
|
||||
aiProviderTypeSchema,
|
||||
aiRoutingModeSchema,
|
||||
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()
|
||||
});
|
||||
|
||||
const bodySchema = z
|
||||
.strictObject({
|
||||
name: z.string().nonempty(),
|
||||
type: aiProviderTypeSchema,
|
||||
upstreamUrl: z.url().optional().nullable(),
|
||||
apiKey: z.string().optional(),
|
||||
authType: aiAuthTypeSchema.optional(),
|
||||
routingMode: aiRoutingModeSchema.optional(),
|
||||
capabilities: aiCapabilitiesSchema.optional(),
|
||||
headers: aiProviderHeadersSchema,
|
||||
skipTlsVerification: z.boolean().optional(),
|
||||
enabled: z.boolean().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
refineProviderUpstreamFields(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<any> {
|
||||
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,
|
||||
routingMode,
|
||||
capabilities,
|
||||
headers,
|
||||
skipTlsVerification,
|
||||
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 resolved = resolveAiProviderCreateFields({
|
||||
type,
|
||||
upstreamUrl,
|
||||
authType,
|
||||
routingMode
|
||||
});
|
||||
const resolvedCapabilities = resolveCapabilitiesForCreate({
|
||||
type,
|
||||
capabilities
|
||||
});
|
||||
|
||||
if (resolvedCapabilities.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"At least one capability is required"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const niceId = await getUniqueProviderName(orgId);
|
||||
|
||||
const [provider] = await db
|
||||
.insert(aiProviders)
|
||||
.values({
|
||||
orgId,
|
||||
name,
|
||||
niceId,
|
||||
type,
|
||||
upstreamUrl: resolved.upstreamUrl,
|
||||
apiKey: encryptedApiKey,
|
||||
apiKeyLastChars,
|
||||
authType: resolved.authType,
|
||||
routingMode: resolved.routingMode,
|
||||
capabilities: serializeCapabilities(resolvedCapabilities),
|
||||
headers: serializeAiProviderHeaders(headers, key),
|
||||
skipTlsVerification: skipTlsVerification ?? false,
|
||||
enabled: enabled ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return response<CreateOrEditAiProviderResponse>(res, {
|
||||
data: {
|
||||
provider: toPublicAiProvider(provider, { includeApiKey: true })
|
||||
},
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
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 { eq } from "drizzle-orm";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
modelId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/ai-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<any> {
|
||||
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 [existing] = await db
|
||||
.select({ modelId: aiModels.modelId })
|
||||
.from(aiModels)
|
||||
.where(eq(aiModels.modelId, modelId))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI model with ID ${modelId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.delete(aiModels).where(eq(aiModels.modelId, modelId));
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/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<any> {
|
||||
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 [existing] = await db
|
||||
.select({ providerId: aiProviders.providerId })
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId));
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
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 { eq } from "drizzle-orm";
|
||||
import type { GetAiModelResponse } from "@server/routers/aiProvider/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
modelId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/ai-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<any> {
|
||||
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 [model] =
|
||||
req.aiModel && req.aiModel.modelId === modelId
|
||||
? [req.aiModel]
|
||||
: await db
|
||||
.select()
|
||||
.from(aiModels)
|
||||
.where(eq(aiModels.modelId, modelId))
|
||||
.limit(1);
|
||||
|
||||
if (!model) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI model with ID ${modelId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetAiModelResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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 { 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
|
||||
.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: 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: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function getAiProvider(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { providerId, niceId, orgId } = parsedParams.data;
|
||||
|
||||
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 || niceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetAiProviderResponse>(res, {
|
||||
data: {
|
||||
provider: toPublicAiProvider(provider, { includeApiKey: true })
|
||||
},
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export * from "./createAiProvider";
|
||||
export * from "./listAiProviders";
|
||||
export * from "./getAiProvider";
|
||||
export * from "./updateAiProvider";
|
||||
export * from "./deleteAiProvider";
|
||||
export * from "./createAiModel";
|
||||
export * from "./listAiModels";
|
||||
export * from "./listCatalogModels";
|
||||
export * from "./listCatalogModelsByType";
|
||||
export * from "./getAiModel";
|
||||
export * from "./updateAiModel";
|
||||
export * from "./deleteAiModel";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,160 @@
|
||||
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({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const listSchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>()
|
||||
.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: "/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<any> {
|
||||
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({ providerId: aiProviders.providerId })
|
||||
.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 { 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<ListAiModelsResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>()
|
||||
.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<any> {
|
||||
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<ListAiProvidersResponse>(res, {
|
||||
data: {
|
||||
providers: rows.map((row) => toPublicAiProvider(row)),
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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 { listCatalogModelsForType } 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<any> {
|
||||
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 models = listCatalogModelsForType(
|
||||
provider.type as AiProviderType,
|
||||
parsedQuery.data.query
|
||||
);
|
||||
|
||||
return response<ListCatalogModelsResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<any> {
|
||||
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<ListCatalogModelsResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { AiModel, AiProvider } from "@server/db";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import {
|
||||
parseAiProviderHeaders,
|
||||
type AiProviderAuthType,
|
||||
type AiProviderHeader
|
||||
} 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<
|
||||
AiProvider,
|
||||
"apiKey" | "capabilities" | "headers"
|
||||
> & {
|
||||
apiKey?: string | null;
|
||||
capabilities: AiCapability[];
|
||||
headers: AiProviderHeader[] | null;
|
||||
effectiveUpstreamUrl: string | null;
|
||||
effectiveAuthType: AiProviderAuthType;
|
||||
};
|
||||
|
||||
export type ListAiProvidersResponse = PaginatedResponse<{
|
||||
providers: AiProviderPublic[];
|
||||
}>;
|
||||
|
||||
export type GetAiProviderResponse = {
|
||||
provider: AiProviderPublic;
|
||||
};
|
||||
|
||||
export type CreateOrEditAiProviderResponse = {
|
||||
provider: AiProviderPublic;
|
||||
};
|
||||
|
||||
export type ListAiModelsResponse = PaginatedResponse<{
|
||||
models: AiModel[];
|
||||
}>;
|
||||
|
||||
export type ListCatalogModelsResponse = {
|
||||
models: { model: string }[];
|
||||
};
|
||||
|
||||
export type GetAiModelResponse = {
|
||||
model: AiModel;
|
||||
};
|
||||
|
||||
export type CreateOrEditAiModelResponse = {
|
||||
model: AiModel;
|
||||
};
|
||||
|
||||
export function toPublicAiProvider(
|
||||
provider: AiProvider,
|
||||
options?: { includeApiKey?: boolean }
|
||||
): AiProviderPublic {
|
||||
const {
|
||||
apiKey: encryptedApiKey,
|
||||
capabilities: rawCapabilities,
|
||||
headers: rawHeaders,
|
||||
...rest
|
||||
} = provider;
|
||||
|
||||
let apiKey: string | null | undefined;
|
||||
if (options?.includeApiKey) {
|
||||
if (encryptedApiKey) {
|
||||
apiKey = decrypt(
|
||||
encryptedApiKey,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
} else {
|
||||
apiKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
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, 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()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
modelKey: z.string().nonempty().optional(),
|
||||
name: z.string().nonempty().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
listType: modelListTypeSchema.optional()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/ai-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<any> {
|
||||
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 { modelId } = parsedParams.data;
|
||||
const body = parsedBody.data;
|
||||
|
||||
const [existing] =
|
||||
req.aiModel && req.aiModel.modelId === modelId
|
||||
? [req.aiModel]
|
||||
: await db
|
||||
.select()
|
||||
.from(aiModels)
|
||||
.where(eq(aiModels.modelId, modelId))
|
||||
.limit(1);
|
||||
|
||||
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, existing.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<typeof aiModels.$inferInsert> = {
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
if (body.modelKey !== undefined) {
|
||||
updateData.modelKey = body.modelKey;
|
||||
}
|
||||
if (body.name !== undefined) {
|
||||
updateData.name = body.name;
|
||||
}
|
||||
if (body.enabled !== undefined) {
|
||||
updateData.enabled = body.enabled;
|
||||
}
|
||||
if (body.listType !== undefined) {
|
||||
updateData.listType = body.listType;
|
||||
}
|
||||
|
||||
const [model] = await db
|
||||
.update(aiModels)
|
||||
.set(updateData)
|
||||
.where(eq(aiModels.modelId, modelId))
|
||||
.returning();
|
||||
|
||||
return response<CreateOrEditAiModelResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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, 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";
|
||||
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
||||
import {
|
||||
aiAuthTypeSchema,
|
||||
aiCapabilitiesSchema,
|
||||
aiProviderHeadersSchema,
|
||||
aiProviderTypeSchema,
|
||||
aiRoutingModeSchema,
|
||||
refineProviderUpstreamFields
|
||||
} from "@server/routers/aiProvider/validation";
|
||||
import {
|
||||
serializeAiProviderHeaders,
|
||||
type AiProviderAuthType,
|
||||
type AiProviderRoutingMode,
|
||||
type AiProviderType
|
||||
} from "@server/lib/aiProviderDefaults";
|
||||
import {
|
||||
parseCapabilities,
|
||||
serializeCapabilities
|
||||
} from "@server/lib/aiCapabilities";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
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(),
|
||||
routingMode: aiRoutingModeSchema.optional(),
|
||||
capabilities: aiCapabilitiesSchema.optional(),
|
||||
headers: aiProviderHeadersSchema,
|
||||
skipTlsVerification: z.boolean().optional(),
|
||||
enabled: z.boolean().optional()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/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<any> {
|
||||
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 { providerId } = parsedParams.data;
|
||||
const body = parsedBody.data;
|
||||
|
||||
const [existing] =
|
||||
req.aiProvider && req.aiProvider.providerId === providerId
|
||||
? [req.aiProvider]
|
||||
: await db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
: existing.upstreamUrl;
|
||||
const nextAuthType: AiProviderAuthType =
|
||||
body.authType !== undefined
|
||||
? body.authType
|
||||
: (existing.authType as AiProviderAuthType);
|
||||
|
||||
const nextCapabilities =
|
||||
body.capabilities !== undefined
|
||||
? body.capabilities
|
||||
: parseCapabilities(existing.capabilities);
|
||||
|
||||
const validation = z
|
||||
.object({
|
||||
type: aiProviderTypeSchema,
|
||||
upstreamUrl: z.string().nullable().optional(),
|
||||
authType: aiAuthTypeSchema,
|
||||
routingMode: aiRoutingModeSchema.optional(),
|
||||
capabilities: aiCapabilitiesSchema.optional()
|
||||
})
|
||||
.superRefine((data, ctx) => refineProviderUpstreamFields(data, ctx))
|
||||
.safeParse({
|
||||
type: providerType,
|
||||
upstreamUrl: nextUpstreamUrl,
|
||||
authType: nextAuthType,
|
||||
routingMode: nextRoutingMode,
|
||||
capabilities: nextCapabilities
|
||||
});
|
||||
|
||||
if (!validation.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(validation.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const updateData: Partial<typeof aiProviders.$inferInsert> = {
|
||||
updatedAt: Date.now(),
|
||||
routingMode: nextRoutingMode
|
||||
};
|
||||
|
||||
if (body.name !== undefined) {
|
||||
updateData.name = body.name;
|
||||
}
|
||||
if (body.niceId !== undefined) {
|
||||
updateData.niceId = body.niceId;
|
||||
}
|
||||
if (body.skipTlsVerification !== undefined) {
|
||||
updateData.skipTlsVerification = body.skipTlsVerification;
|
||||
}
|
||||
if (body.enabled !== undefined) {
|
||||
updateData.enabled = body.enabled;
|
||||
}
|
||||
if (nextRoutingMode === "target") {
|
||||
updateData.upstreamUrl = null;
|
||||
} else if (body.upstreamUrl !== undefined) {
|
||||
updateData.upstreamUrl = body.upstreamUrl;
|
||||
}
|
||||
if (body.authType !== undefined) {
|
||||
updateData.authType = body.authType;
|
||||
}
|
||||
if (body.capabilities !== undefined) {
|
||||
updateData.capabilities = serializeCapabilities(body.capabilities);
|
||||
}
|
||||
|
||||
if (body.apiKey !== undefined) {
|
||||
const key = config.getRawConfig().server.secret!;
|
||||
updateData.apiKey = encrypt(body.apiKey, key);
|
||||
updateData.apiKeyLastChars = body.apiKey.slice(-4);
|
||||
}
|
||||
|
||||
if (body.headers !== undefined) {
|
||||
const key = config.getRawConfig().server.secret!;
|
||||
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)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.returning();
|
||||
|
||||
return response<CreateOrEditAiProviderResponse>(res, {
|
||||
data: {
|
||||
provider: toPublicAiProvider(provider, { includeApiKey: true })
|
||||
},
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AI_PROVIDER_AUTH_TYPES,
|
||||
providerRequiresUpstreamUrl,
|
||||
type AiProviderAuthType,
|
||||
type AiProviderRoutingMode,
|
||||
type AiProviderType
|
||||
} from "@server/lib/aiProviderDefaults";
|
||||
import { AI_CAPABILITIES } from "@server/lib/aiCapabilities";
|
||||
|
||||
export const aiProviderTypeSchema = z.enum([
|
||||
"openai",
|
||||
"anthropic",
|
||||
"googleGemini",
|
||||
"vertexAi",
|
||||
"bedrock",
|
||||
"microsoftFoundry",
|
||||
"openRouter",
|
||||
"vercelAiGateway",
|
||||
"custom"
|
||||
]);
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
upstreamUrl?: string | null;
|
||||
authType?: AiProviderAuthType | null;
|
||||
routingMode?: AiProviderRoutingMode | null;
|
||||
capabilities?: z.infer<typeof aiCapabilitiesSchema> | null;
|
||||
},
|
||||
ctx: z.RefinementCtx
|
||||
) {
|
||||
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`,
|
||||
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"]
|
||||
});
|
||||
}
|
||||
} 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"]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -124,3 +124,26 @@ export interface AlertContext {
|
||||
/** Human-readable context data included in emails and webhook payloads */
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type EmailAlertAction = {
|
||||
type: "email";
|
||||
userIds?: string[];
|
||||
roleIds?: number[];
|
||||
emails?: string[];
|
||||
};
|
||||
|
||||
export type WebhookAlertAction = {
|
||||
type: "webhook";
|
||||
webhookUrl: string;
|
||||
enabled: boolean;
|
||||
config?: string | undefined;
|
||||
};
|
||||
|
||||
export 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<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
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(),
|
||||
virtualApiKeyId: z.string().optional()
|
||||
});
|
||||
|
||||
export const aiUsageAnalyticsParams = z.object({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
export const aiUsageAnalyticsCombined = aiUsageAnalyticsFiltersQuery.merge(
|
||||
aiUsageAnalyticsParams
|
||||
);
|
||||
|
||||
export type AiUsageAnalyticsQuery = z.infer<typeof aiUsageAnalyticsCombined>;
|
||||
|
||||
// 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<string[] | undefined> {
|
||||
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,
|
||||
data.virtualApiKeyId
|
||||
? eq(aiUsageRecords.virtualApiKeyId, data.virtualApiKeyId)
|
||||
: 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<string>`DATE_TRUNC('day', TO_TIMESTAMP(${aiUsageRecords.createdAt} / 1000.0))`
|
||||
: sql<string>`DATE(${aiUsageRecords.createdAt} / 1000, 'unixepoch')`;
|
||||
}
|
||||
|
||||
export type DailyMetricRow<K extends string> = {
|
||||
day: string;
|
||||
key: K;
|
||||
value: number;
|
||||
};
|
||||
|
||||
// Ranks dimension keys by total value and returns the top N.
|
||||
export function pickTopNKeys<K extends string>(
|
||||
totals: Map<K, number>,
|
||||
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<K extends string>(
|
||||
rows: DailyMetricRow<K>[],
|
||||
topKeys: K[]
|
||||
): DayValueRow[] {
|
||||
const topSet = new Set<string>(topKeys);
|
||||
const byDay = new Map<string, Record<string, number>>();
|
||||
|
||||
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 }));
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { z } from "zod";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
queryAiSessionLogsQuery,
|
||||
queryAiSessionLogsParams,
|
||||
queryAiSession,
|
||||
countAiSessionQuery
|
||||
} from "./queryAiSessionLog";
|
||||
import { generateCSV } from "./generateCSV";
|
||||
|
||||
const MAX_EXPORT_LIMIT = 50_000;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/logs/ai/export",
|
||||
description: "Export the AI gateway session log for an organization as CSV",
|
||||
tags: [OpenAPITags.Logs],
|
||||
request: {
|
||||
query: queryAiSessionLogsQuery.omit({
|
||||
limit: true,
|
||||
offset: true
|
||||
}),
|
||||
params: queryAiSessionLogsParams
|
||||
},
|
||||
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 exportAiSessionLogs(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = queryAiSessionLogsQuery.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = queryAiSessionLogsParams.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||
|
||||
const [{ count }] = await countAiSessionQuery(data);
|
||||
if (count > MAX_EXPORT_LIMIT) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Export limit exceeded. Your selection contains ${count} rows, but the maximum is ${MAX_EXPORT_LIMIT} rows. Please select a shorter time range to reduce the data.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = queryAiSession(data);
|
||||
|
||||
const log = await baseQuery.limit(MAX_EXPORT_LIMIT);
|
||||
|
||||
const csvData = generateCSV(log);
|
||||
|
||||
res.setHeader("Content-Type", "text/csv");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="ai-session-logs-${data.orgId}-${Date.now()}.csv"`
|
||||
);
|
||||
|
||||
return res.send(csvData);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
export * from "./queryRequestAuditLog";
|
||||
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";
|
||||
export * from "./queryAiUsageVirtualApiKeys";
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
import {
|
||||
logsDb,
|
||||
aiSessionLog,
|
||||
aiProviders,
|
||||
aiUsageRecords,
|
||||
resources,
|
||||
siteResources,
|
||||
users,
|
||||
virtualApiKeys,
|
||||
db,
|
||||
primaryDb
|
||||
} from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { eq, gt, lt, and, count, desc, inArray, isNull, or } 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 { QueryAiSessionLogResponse } from "@server/routers/auditLogs/types";
|
||||
import { AI_CAPABILITIES } from "@server/lib/aiCapabilities";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
|
||||
export const queryAiSessionLogsQuery = z.strictObject({
|
||||
// iso string just validate its a parseable date
|
||||
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())
|
||||
.optional()
|
||||
.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(),
|
||||
capability: z.enum(AI_CAPABILITIES).optional(),
|
||||
resourceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(Number)
|
||||
.pipe(z.int().positive())
|
||||
.optional(),
|
||||
actor: z.string().optional(),
|
||||
virtualApiKeyId: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
isStream: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.transform((val) => (typeof val === "string" ? val === "true" : val))
|
||||
.optional(),
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
});
|
||||
|
||||
export const queryAiSessionLogsParams = z.object({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
export const queryAiSessionLogsCombined = queryAiSessionLogsQuery.merge(
|
||||
queryAiSessionLogsParams
|
||||
);
|
||||
type Q = z.infer<typeof queryAiSessionLogsCombined>;
|
||||
|
||||
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
function getWhere(data: Q) {
|
||||
return and(
|
||||
gt(aiSessionLog.createdAt, data.timeStart),
|
||||
lt(aiSessionLog.createdAt, data.timeEnd),
|
||||
eq(aiSessionLog.orgId, data.orgId),
|
||||
data.providerId
|
||||
? eq(aiSessionLog.providerId, data.providerId)
|
||||
: undefined,
|
||||
data.capability
|
||||
? eq(aiSessionLog.capability, data.capability)
|
||||
: undefined,
|
||||
data.resourceId
|
||||
? or(
|
||||
eq(aiSessionLog.resourceId, data.resourceId),
|
||||
eq(aiSessionLog.siteResourceId, data.resourceId)
|
||||
)
|
||||
: undefined,
|
||||
data.actor ? eq(aiSessionLog.userId, data.actor) : undefined,
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
export function queryAiSession(data: Q) {
|
||||
return logsDb
|
||||
.select({
|
||||
id: aiSessionLog.id,
|
||||
sessionId: aiSessionLog.sessionId,
|
||||
orgId: aiSessionLog.orgId,
|
||||
providerId: aiSessionLog.providerId,
|
||||
capability: aiSessionLog.capability,
|
||||
resourceId: aiSessionLog.resourceId,
|
||||
siteResourceId: aiSessionLog.siteResourceId,
|
||||
userId: aiSessionLog.userId,
|
||||
virtualApiKeyId: aiSessionLog.virtualApiKeyId,
|
||||
requestedModel: aiSessionLog.requestedModel,
|
||||
isStream: aiSessionLog.isStream,
|
||||
requestBody: aiSessionLog.requestBody,
|
||||
responseBody: aiSessionLog.responseBody,
|
||||
normalizedRequest: aiSessionLog.normalizedRequest,
|
||||
normalizedResponse: aiSessionLog.normalizedResponse,
|
||||
truncated: aiSessionLog.truncated,
|
||||
statusCode: aiSessionLog.statusCode,
|
||||
createdAt: aiSessionLog.createdAt
|
||||
})
|
||||
.from(aiSessionLog)
|
||||
.where(getWhere(data))
|
||||
.orderBy(desc(aiSessionLog.createdAt));
|
||||
}
|
||||
|
||||
async function enrichWithDetails(
|
||||
logs: Awaited<ReturnType<typeof queryAiSession>>
|
||||
) {
|
||||
const providerIds = [...new Set(logs.map((log) => log.providerId))];
|
||||
|
||||
const resourceIds = logs
|
||||
.map((log) => log.resourceId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
const siteResourceIds = logs
|
||||
.filter((log) => log.resourceId == null && log.siteResourceId != null)
|
||||
.map((log) => log.siteResourceId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
const userIds = [
|
||||
...new Set(
|
||||
logs
|
||||
.map((log) => log.userId)
|
||||
.filter((id): id is string => id !== null && id !== undefined)
|
||||
)
|
||||
];
|
||||
|
||||
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 }
|
||||
>();
|
||||
if (providerIds.length > 0) {
|
||||
const providerDetails = await primaryDb
|
||||
.select({
|
||||
providerId: aiProviders.providerId,
|
||||
name: aiProviders.name,
|
||||
type: aiProviders.type
|
||||
})
|
||||
.from(aiProviders)
|
||||
.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 });
|
||||
}
|
||||
}
|
||||
|
||||
const resourceMap = new Map<
|
||||
number,
|
||||
{ name: string | null; niceId: string | null }
|
||||
>();
|
||||
if (resourceIds.length > 0) {
|
||||
const resourceDetails = await primaryDb
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
niceId: resources.niceId
|
||||
})
|
||||
.from(resources)
|
||||
.where(inArray(resources.resourceId, resourceIds));
|
||||
|
||||
for (const r of resourceDetails) {
|
||||
resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId });
|
||||
}
|
||||
}
|
||||
|
||||
const siteResourceMap = new Map<
|
||||
number,
|
||||
{ name: string | null; niceId: string | null }
|
||||
>();
|
||||
if (siteResourceIds.length > 0) {
|
||||
const siteResourceDetails = await primaryDb
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
name: siteResources.name,
|
||||
niceId: siteResources.niceId
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||
|
||||
for (const r of siteResourceDetails) {
|
||||
siteResourceMap.set(r.siteResourceId, {
|
||||
name: r.name,
|
||||
niceId: r.niceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const userMap = new Map<string, string | null>();
|
||||
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) {
|
||||
userMap.set(u.userId, u.email);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
{
|
||||
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 = log.providerId
|
||||
? providerMap.get(log.providerId)
|
||||
: null;
|
||||
|
||||
let resourceId = log.resourceId;
|
||||
let resourceName: string | null = null;
|
||||
let resourceNiceId: string | null = null;
|
||||
let resourceType: "public" | "site" | null = null;
|
||||
if (log.resourceId != null) {
|
||||
const details = resourceMap.get(log.resourceId);
|
||||
resourceName = details?.name ?? null;
|
||||
resourceNiceId = details?.niceId ?? null;
|
||||
resourceType = "public";
|
||||
} else if (log.siteResourceId != null) {
|
||||
const details = siteResourceMap.get(log.siteResourceId);
|
||||
resourceId = log.siteResourceId;
|
||||
resourceName = details?.name ?? null;
|
||||
resourceNiceId = details?.niceId ?? null;
|
||||
resourceType = "site";
|
||||
}
|
||||
|
||||
return {
|
||||
...log,
|
||||
resourceId,
|
||||
resourceType,
|
||||
providerName: provider?.name ?? null,
|
||||
providerType: provider?.type ?? null,
|
||||
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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function countAiSessionQuery(data: Q) {
|
||||
return logsDb
|
||||
.select({ count: count() })
|
||||
.from(aiSessionLog)
|
||||
.where(getWhere(data));
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/logs/ai",
|
||||
description: "Query the AI gateway session log for an organization",
|
||||
tags: [OpenAPITags.Logs],
|
||||
request: {
|
||||
query: queryAiSessionLogsQuery,
|
||||
params: queryAiSessionLogsParams
|
||||
},
|
||||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function queryUniqueFilterAttributes(
|
||||
timeStart: number,
|
||||
timeEnd: number,
|
||||
orgId: string
|
||||
) {
|
||||
const baseConditions = and(
|
||||
gt(aiSessionLog.createdAt, timeStart),
|
||||
lt(aiSessionLog.createdAt, timeEnd),
|
||||
eq(aiSessionLog.orgId, orgId)
|
||||
);
|
||||
|
||||
const DISTINCT_LIMIT = 500;
|
||||
|
||||
const [
|
||||
uniqueProviders,
|
||||
uniqueUsers,
|
||||
uniqueResources,
|
||||
uniqueSiteResources,
|
||||
uniqueModels,
|
||||
uniqueVirtualApiKeys
|
||||
] = await Promise.all([
|
||||
logsDb
|
||||
.selectDistinct({ id: aiSessionLog.providerId })
|
||||
.from(aiSessionLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
.selectDistinct({ userId: aiSessionLog.userId })
|
||||
.from(aiSessionLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
.selectDistinct({ id: aiSessionLog.resourceId })
|
||||
.from(aiSessionLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
.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),
|
||||
logsDb
|
||||
.selectDistinct({ id: aiSessionLog.virtualApiKeyId })
|
||||
.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);
|
||||
|
||||
let providers: Array<{ id: number; name: string | null }> = [];
|
||||
if (providerIds.length > 0) {
|
||||
const providerDetails = await primaryDb
|
||||
.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 userIds = uniqueUsers
|
||||
.map((row) => row.userId)
|
||||
.filter((id): id is string => id !== null);
|
||||
|
||||
let userList: Array<{ id: string; email: 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 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 primaryDb
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name
|
||||
})
|
||||
.from(resources)
|
||||
.where(inArray(resources.resourceId, resourceIds));
|
||||
|
||||
resourcesWithNames = [
|
||||
...resourcesWithNames,
|
||||
...resourceDetails.map((r) => ({
|
||||
id: r.resourceId,
|
||||
name: r.name
|
||||
}))
|
||||
];
|
||||
}
|
||||
|
||||
if (siteResourceIds.length > 0) {
|
||||
const siteResourceDetails = await primaryDb
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
name: siteResources.name
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||
|
||||
resourcesWithNames = [
|
||||
...resourcesWithNames,
|
||||
...siteResourceDetails.map((r) => ({
|
||||
id: r.siteResourceId,
|
||||
name: r.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 | 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()
|
||||
};
|
||||
}
|
||||
|
||||
export async function queryAiSessionLogs(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = queryAiSessionLogsQuery.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = queryAiSessionLogsParams.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||
|
||||
const baseQuery = queryAiSession(data);
|
||||
|
||||
const logsRaw = await baseQuery.limit(data.limit).offset(data.offset);
|
||||
|
||||
const log = await enrichWithDetails(logsRaw);
|
||||
|
||||
const totalCountResult = await countAiSessionQuery(data);
|
||||
const totalCount = totalCountResult[0].count;
|
||||
|
||||
const filterAttributes = await queryUniqueFilterAttributes(
|
||||
data.timeStart,
|
||||
data.timeEnd,
|
||||
data.orgId
|
||||
);
|
||||
|
||||
return response<QueryAiSessionLogResponse>(res, {
|
||||
data: {
|
||||
log,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit: data.limit,
|
||||
offset: data.offset
|
||||
},
|
||||
filterAttributes
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI session logs retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
db,
|
||||
aiUsageRecords,
|
||||
aiProviders,
|
||||
resources,
|
||||
siteResources,
|
||||
users,
|
||||
roles,
|
||||
userOrgRoles,
|
||||
virtualApiKeys
|
||||
} 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<typeof queryAiUsageFilterOptionsCombined>;
|
||||
|
||||
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
|
||||
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,
|
||||
uniqueVirtualApiKeys
|
||||
] = 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),
|
||||
db
|
||||
.selectDistinct({ id: aiUsageRecords.virtualApiKeyId })
|
||||
.from(aiUsageRecords)
|
||||
.where(
|
||||
and(baseConditions, not(isNull(aiUsageRecords.virtualApiKeyId)))
|
||||
)
|
||||
.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<number, string | null>();
|
||||
for (const r of roleRows) {
|
||||
roleMap.set(r.roleId, r.name);
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
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<
|
||||
ReturnType<typeof query>
|
||||
>;
|
||||
|
||||
export async function queryAiUsageFilterOptions(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
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<QueryAiUsageFilterOptionsResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<number>`COALESCE(SUM(${aiUsageRecords.promptTokens}), 0)`,
|
||||
cacheReadTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.cacheReadTokens}), 0)`,
|
||||
cacheWriteTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.cacheWriteTokens}), 0)`,
|
||||
completionTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.completionTokens}), 0)`,
|
||||
reasoningTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.reasoningTokens}), 0)`,
|
||||
totalTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
|
||||
costUsd: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
|
||||
estimatedRequests: sql<number>`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<number>`COALESCE(SUM(${aiUsageRecords.promptTokens}), 0)`,
|
||||
cacheReadTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.cacheReadTokens}), 0)`,
|
||||
cacheWriteTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.cacheWriteTokens}), 0)`,
|
||||
completionTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.completionTokens}), 0)`,
|
||||
reasoningTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.reasoningTokens}), 0)`
|
||||
})
|
||||
.from(aiUsageRecords)
|
||||
.where(baseConditions)
|
||||
.groupBy(dayExpr)
|
||||
.orderBy(dayExpr);
|
||||
|
||||
const costPerDay = await db
|
||||
.select({
|
||||
day: dayExpr.as("day"),
|
||||
cost: sql<number>`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<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
|
||||
tokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`
|
||||
})
|
||||
.from(aiUsageRecords)
|
||||
.where(baseConditions)
|
||||
.groupBy(dayExpr, aiUsageRecords.requestedModel)
|
||||
.orderBy(dayExpr);
|
||||
|
||||
const modelCostTotals = new Map<string, number>();
|
||||
const modelTokenTotals = new Map<string, number>();
|
||||
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<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
|
||||
costUsd: sql<number>`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<ReturnType<typeof query>>;
|
||||
|
||||
export async function queryAiUsageOverview(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
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<QueryAiUsageOverviewResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
|
||||
tokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`
|
||||
})
|
||||
.from(aiUsageRecords)
|
||||
.where(baseConditions)
|
||||
.groupBy(dayExpr, aiUsageRecords.providerId)
|
||||
.orderBy(dayExpr);
|
||||
|
||||
const costTotals = new Map<string, number>();
|
||||
const tokenTotals = new Map<string, number>();
|
||||
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<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
|
||||
costUsd: sql<number>`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<number, string | null>();
|
||||
if (providerIds.length > 0) {
|
||||
const providerDetails = await db
|
||||
.select({
|
||||
providerId: aiProviders.providerId,
|
||||
name: aiProviders.name
|
||||
})
|
||||
.from(aiProviders)
|
||||
.where(
|
||||
inArray(
|
||||
aiProviders.providerId,
|
||||
providerIds.filter((id): id is number => id !== null)
|
||||
)
|
||||
);
|
||||
for (const p of providerDetails) {
|
||||
nameMap.set(p.providerId, p.name);
|
||||
}
|
||||
}
|
||||
|
||||
const topProviders = topProvidersRaw.map((r) => ({
|
||||
providerId: r.providerId,
|
||||
name: r.providerId ? (nameMap.get(r.providerId) ?? null) : 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<ReturnType<typeof query>>;
|
||||
|
||||
export async function queryAiUsageProviders(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
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<QueryAiUsageProvidersResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
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. 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}`;
|
||||
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<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
|
||||
tokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`
|
||||
})
|
||||
.from(aiUsageRecords)
|
||||
.where(baseConditions)
|
||||
.groupBy(dayExpr, aiUsageRecords.resourceId, aiUsageRecords.siteResourceId)
|
||||
.orderBy(dayExpr);
|
||||
|
||||
const costTotals = new Map<string, number>();
|
||||
const tokenTotals = new Map<string, number>();
|
||||
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<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
|
||||
costUsd: sql<number>`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<string, string | null>();
|
||||
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<ReturnType<typeof query>>;
|
||||
|
||||
export async function queryAiUsageResources(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
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<QueryAiUsageResourcesResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
|
||||
tokens: sql<number>`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<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
|
||||
costUsd: sql<number>`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<string, string | null>();
|
||||
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<string, number>();
|
||||
const userTokenTotals = new Map<string, number>();
|
||||
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<string, { roleId: number; name: string | null }[]>();
|
||||
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<string, number>();
|
||||
const roleTokenTotals = new Map<string, number>();
|
||||
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<ReturnType<typeof query>>;
|
||||
|
||||
export async function queryAiUsageUsersRoles(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
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<QueryAiUsageUsersRolesResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
|
||||
tokens: sql<number>`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<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
|
||||
costUsd: sql<number>`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<string, number>();
|
||||
const virtualApiKeyTokenTotals = new Map<string, number>();
|
||||
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<typeof query>
|
||||
>;
|
||||
|
||||
export async function queryAiUsageVirtualApiKeys(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
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<QueryAiUsageVirtualApiKeysResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export function queryRequest(data: Q) {
|
||||
})
|
||||
.from(requestAuditLog)
|
||||
.where(getWhere(data))
|
||||
.orderBy(desc(requestAuditLog.timestamp));
|
||||
.orderBy(desc(requestAuditLog.timestamp), desc(requestAuditLog.id));
|
||||
}
|
||||
|
||||
async function enrichWithResourceDetails(
|
||||
|
||||
@@ -94,6 +94,72 @@ export type QueryAccessAuditLogResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type QueryAiSessionLogResponse = {
|
||||
log: {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
orgId: string | null;
|
||||
providerId: number | null;
|
||||
providerName: string | null;
|
||||
providerType: string | null;
|
||||
capability: string;
|
||||
resourceId: number | null;
|
||||
siteResourceId: number | null;
|
||||
resourceName: string | null;
|
||||
resourceNiceId: string | null;
|
||||
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;
|
||||
responseBody: string | null;
|
||||
normalizedRequest: string | null;
|
||||
normalizedResponse: string | null;
|
||||
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;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
filterAttributes: {
|
||||
providers: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
}[];
|
||||
resources: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
}[];
|
||||
users: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
}[];
|
||||
virtualApiKeys: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
lastChars: string | null;
|
||||
}[];
|
||||
models: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type QueryConnectionAuditLogResponse = {
|
||||
log: {
|
||||
sessionId: string;
|
||||
|
||||
@@ -164,6 +164,14 @@ export async function exchangeSession(
|
||||
)
|
||||
.limit(1);
|
||||
if (res) {
|
||||
if (res.resourceId !== resource.resourceId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"Invalid request token"
|
||||
)
|
||||
);
|
||||
}
|
||||
await createResourceSession({
|
||||
token,
|
||||
resourceId: resource.resourceId,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,13 +5,18 @@ 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,
|
||||
getRoleResourceAccess,
|
||||
getUserResourceAccess,
|
||||
getOrgLoginPage,
|
||||
getUserSessionWithUser
|
||||
getUserSessionWithUser,
|
||||
getWhitelistEmail
|
||||
} from "@server/db/queries/verifySessionQueries";
|
||||
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
|
||||
import {
|
||||
@@ -44,6 +49,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,
|
||||
@@ -85,6 +95,15 @@ type BasicUserData = {
|
||||
role: string | null;
|
||||
};
|
||||
|
||||
// Some auth methods (e.g. email whitelist) only know the remote email and
|
||||
// have no associated user record to attach userId/username/name/role to.
|
||||
type EmailOnlyUserData = {
|
||||
dontStripSession?: boolean;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type { ClientErrorResponse };
|
||||
|
||||
export type VerifyUserResponse = {
|
||||
valid: boolean;
|
||||
headerAuthChallenged?: boolean;
|
||||
@@ -92,8 +111,31 @@ export type VerifyUserResponse = {
|
||||
userData?: BasicUserData;
|
||||
pangolinVersion?: string;
|
||||
dontStripSession?: boolean;
|
||||
clientError?: ClientErrorResponse;
|
||||
// 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;
|
||||
};
|
||||
|
||||
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<VerifyUserResponse>(res, data);
|
||||
}
|
||||
|
||||
export async function verifyResourceSession(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -127,7 +169,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
|
||||
@@ -222,7 +265,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);
|
||||
@@ -300,20 +345,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
|
||||
@@ -325,6 +373,96 @@ 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,
|
||||
key.virtualApiKeyId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: false,
|
||||
reason: 299, // no more auth methods / VAK required
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
// 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
|
||||
if (
|
||||
headers &&
|
||||
@@ -652,6 +790,18 @@ export async function verifyResourceSession(
|
||||
"Resource allowed because whitelist session is valid"
|
||||
);
|
||||
|
||||
const whitelistCacheKey = `whitelistEmail:${resourceSession.whitelistId}:${resourceSession.policyWhitelistId}`;
|
||||
let whitelistEmail: string | null | undefined =
|
||||
localCache.get(whitelistCacheKey);
|
||||
|
||||
if (whitelistEmail === undefined) {
|
||||
whitelistEmail = await getWhitelistEmail(
|
||||
resourceSession.whitelistId,
|
||||
resourceSession.policyWhitelistId
|
||||
);
|
||||
localCache.set(whitelistCacheKey, whitelistEmail, 12);
|
||||
}
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
@@ -663,14 +813,14 @@ export async function verifyResourceSession(
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res, undefined, dontStripSession);
|
||||
return allowed(
|
||||
res,
|
||||
whitelistEmail ? { email: whitelistEmail } : undefined,
|
||||
dontStripSession
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceSession.accessTokenId) {
|
||||
logger.debug(
|
||||
"Resource allowed because access token session is valid"
|
||||
);
|
||||
|
||||
const [tokenItem] = await db
|
||||
.select()
|
||||
.from(resourceAccessToken)
|
||||
@@ -682,26 +832,37 @@ export async function verifyResourceSession(
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const userData = tokenItem
|
||||
? await getAccessTokenUserData(
|
||||
tokenItem,
|
||||
resource.orgId
|
||||
)
|
||||
: undefined;
|
||||
if (
|
||||
tokenItem &&
|
||||
tokenItem.resourceId === resource.resourceId
|
||||
) {
|
||||
logger.debug(
|
||||
"Resource allowed because access token session is valid"
|
||||
);
|
||||
|
||||
logAccessTokenRequestAudit(
|
||||
{
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC,
|
||||
accessTokenId: resourceSession.accessTokenId,
|
||||
tokenTitle: tokenItem?.title ?? null,
|
||||
userData
|
||||
},
|
||||
parsedBody.data
|
||||
const userData = await getAccessTokenUserData(
|
||||
tokenItem,
|
||||
resource.orgId
|
||||
);
|
||||
|
||||
logAccessTokenRequestAudit(
|
||||
{
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC,
|
||||
accessTokenId: resourceSession.accessTokenId,
|
||||
tokenTitle: tokenItem.title ?? null,
|
||||
userData
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res, userData, dontStripSession);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
"Access token session does not belong to this resource"
|
||||
);
|
||||
|
||||
return allowed(res, userData, dontStripSession);
|
||||
}
|
||||
|
||||
if (resourceSession.userSessionId && sso) {
|
||||
@@ -892,17 +1053,21 @@ async function notAllowed(
|
||||
|
||||
function allowed(
|
||||
res: Response,
|
||||
userData?: BasicUserData,
|
||||
dontStripSession?: boolean
|
||||
userData?: BasicUserData | EmailOnlyUserData,
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resources, targets } 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 { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
|
||||
|
||||
const getBrowserTargetSchema = z
|
||||
.object({
|
||||
fullDomain: z.string().min(1, "fullDomain is required")
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function getBrowserTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsed = getBrowserTargetSchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsed.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { fullDomain } = parsed.data;
|
||||
|
||||
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
ip: targets.ip,
|
||||
port: targets.port,
|
||||
authToken: targets.authToken,
|
||||
resourceId: resources.resourceId,
|
||||
niceId: resources.niceId,
|
||||
name: resources.name,
|
||||
orgId: resources.orgId,
|
||||
pamMode: resources.pamMode,
|
||||
authDaemonMode: resources.authDaemonMode
|
||||
})
|
||||
.from(targets)
|
||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.where(
|
||||
and(
|
||||
eq(resources.fullDomain, fullDomain),
|
||||
eq(targets.enabled, true),
|
||||
inArray(targets.mode, ["ssh", "rdp", "vnc"])
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"No resource found for this domain"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const decryptedAuthToken = row.authToken
|
||||
? decrypt(row.authToken, config.getRawConfig().server.secret!)
|
||||
: "";
|
||||
|
||||
return response<GetBrowserTargetResponse>(res, {
|
||||
data: {
|
||||
ip: row.ip,
|
||||
port: row.port,
|
||||
authToken: decryptedAuthToken,
|
||||
pamMode: row.pamMode,
|
||||
authDaemonMode: row.authDaemonMode,
|
||||
orgId: row.orgId,
|
||||
resourceId: row.resourceId,
|
||||
niceId: row.niceId,
|
||||
name: row.name ?? ""
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser target retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred while retrieving the browser target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./types";
|
||||
export * from "./getBrowserTarget";
|
||||
|
||||
@@ -1,9 +1,102 @@
|
||||
import { db, Transaction } from "@server/db";
|
||||
import { Certificate, certificates, db, domains } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { Transaction } from "@server/db";
|
||||
import { eq, or, and, like } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Checks if a certificate exists for the given domain.
|
||||
* If not, creates a new certificate in 'pending' state.
|
||||
* Wildcard certs cover subdomains.
|
||||
*/
|
||||
export async function createCertificate(
|
||||
domainId: string,
|
||||
domain: string,
|
||||
trx: Transaction | typeof db
|
||||
) {
|
||||
return;
|
||||
const [domainRecord] = await trx
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (!domainRecord) {
|
||||
throw new Error(`Domain with ID ${domainId} not found`);
|
||||
}
|
||||
|
||||
let existing: Certificate[] = [];
|
||||
if (domainRecord.type == "ns" || domainRecord.type == "wildcard") {
|
||||
const domainLevelDown = domain.split(".").slice(1).join(".");
|
||||
const wildcardPrefixed = `*.${domainLevelDown}`;
|
||||
|
||||
existing = await trx
|
||||
.select()
|
||||
.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 trx
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.domainId, domainId),
|
||||
eq(certificates.domain, domain) // exact match for non-NS domains
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
logger.info(`Certificate already exists for domain ${domain}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let domainToWrite = domain;
|
||||
if (
|
||||
domainRecord.type == "wildcard" && // this is to fix the wildcard certs for traefik in self hosted NOT ON THE CLOUD
|
||||
domainRecord.preferWildcardCert &&
|
||||
!domain.startsWith("*.")
|
||||
) {
|
||||
// in this case traefik is going to generate a domain one level down so we need to store it that way
|
||||
const parts = domain.split(".");
|
||||
if (parts.length > 2) {
|
||||
domainToWrite = parts.slice(1).join(".");
|
||||
domainToWrite = `*.${domainToWrite}`;
|
||||
}
|
||||
} else if (domainRecord.type == "ns") {
|
||||
if (domain == domainRecord.baseDomain) {
|
||||
domainToWrite = domainRecord.baseDomain;
|
||||
} else {
|
||||
const parts = domain.split(".");
|
||||
if (parts.length > 2) {
|
||||
domainToWrite = parts.slice(1).join(".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No cert found, create a new one in pending state
|
||||
await trx.insert(certificates).values({
|
||||
domain: domainToWrite,
|
||||
domainId,
|
||||
wildcard:
|
||||
domainRecord.type == "ns" ||
|
||||
(domainRecord.type == "wildcard" &&
|
||||
domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains
|
||||
status: "pending",
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
createdAt: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
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, isNotNull, 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";
|
||||
|
||||
const getCertificateParamSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const getCertificateQuerySchema = z.object({
|
||||
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))
|
||||
)
|
||||
});
|
||||
|
||||
async function query(orgId: string, domainList: string[]) {
|
||||
// Try to get CNAME certificates first
|
||||
const 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,
|
||||
domainId: domains.domainId,
|
||||
domainType: domains.type
|
||||
})
|
||||
.from(certificates)
|
||||
.innerJoin(domains, eq(certificates.domainId, domains.domainId))
|
||||
.leftJoin(
|
||||
orgDomains,
|
||||
and(
|
||||
eq(domains.domainId, orgDomains.domainId),
|
||||
eq(orgDomains.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.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
|
||||
const foundDomains = new Set(
|
||||
existingCertificates.map((cert) => cert.domain)
|
||||
);
|
||||
const domainsWithMissingCertificates = domainList.filter(
|
||||
(domain) => !foundDomains.has(domain)
|
||||
);
|
||||
|
||||
if (domainsWithMissingCertificates.length > 0) {
|
||||
const domainLevelDownSet = new Set<string>();
|
||||
const wildcardDomainSet = new Set<string>();
|
||||
|
||||
for (const domain of domainsWithMissingCertificates) {
|
||||
const domainLevelDown = domain.split(".").slice(1).join(".");
|
||||
const wildcardPrefixed = `*.${domainLevelDown}`;
|
||||
domainLevelDownSet.add(domainLevelDown);
|
||||
wildcardDomainSet.add(wildcardPrefixed);
|
||||
}
|
||||
|
||||
// Need to map the certificates to each domain
|
||||
const wildcardCertificates = 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,
|
||||
domainId: domains.domainId,
|
||||
domainType: domains.type
|
||||
})
|
||||
.from(certificates)
|
||||
.innerJoin(domains, eq(certificates.domainId, domains.domainId))
|
||||
.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)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
existingCertificates.push(...wildcardCertificates);
|
||||
}
|
||||
|
||||
const certificateMap: Record<string, any> = {};
|
||||
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 getBatchedCertificates(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = getCertificateParamSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const parsedQuery = getCertificateQuerySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { domains } = parsedQuery.data;
|
||||
|
||||
const cert = await query(orgId, domains);
|
||||
|
||||
return response<GetBatchedCertificateResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { certificates, db, domains } from "@server/db";
|
||||
import { eq, and, or, like } 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({
|
||||
domainId: z.string(),
|
||||
domain: 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;
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/certificate/{domainId}/{domain}",
|
||||
description: "Get a certificate by domain.",
|
||||
tags: ["Certificate"],
|
||||
request: {
|
||||
params: z.object({
|
||||
domainId: z.string(),
|
||||
domain: z.string().min(1).max(255),
|
||||
orgId: z.string()
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function getCertificate(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = getCertificateSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { domainId, domain } = parsedParams.data;
|
||||
|
||||
const cert = await query(domainId, domain);
|
||||
|
||||
if (!cert) {
|
||||
logger.warn(`Certificate not found for domain: ${domainId}`);
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Certificate not found")
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetCertificateResponse>(res, {
|
||||
data: cert,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Certificate retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./getCertificate";
|
||||
export * from "./restartCertificate";
|
||||
export * from "./syncCertToNewts";
|
||||
export * from "./getBatchedCertificates";
|
||||
export * from "./createCertificate";
|
||||
@@ -0,0 +1,111 @@
|
||||
import { certificates, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
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";
|
||||
|
||||
const restartCertificateParamsSchema = z.strictObject({
|
||||
certId: z.coerce.number().int().positive(),
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/certificate/{certId}",
|
||||
description: "Restart a certificate by ID.",
|
||||
tags: ["Certificate"],
|
||||
request: {
|
||||
params: z.object({
|
||||
certId: z.coerce.number().int().positive(),
|
||||
orgId: z.string()
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function restartCertificate(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = restartCertificateParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { certId } = parsedParams.data;
|
||||
|
||||
// get the certificate by ID
|
||||
const [cert] = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.certId, certId))
|
||||
.limit(1);
|
||||
|
||||
if (!cert) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Certificate not found")
|
||||
);
|
||||
}
|
||||
|
||||
if (cert.status != "failed" && cert.status != "expired") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Certificate is already valid, no need to restart"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// update the certificate status to 'pending'
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
status: "pending",
|
||||
errorMessage: null,
|
||||
lastRenewalAttempt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.where(eq(certificates.certId, certId));
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Certificate restarted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { pushCertUpdateToAffectedNewts } from "@server/lib/acmeCertSync";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const bodySchema = z.object({
|
||||
domain: z.string().min(1),
|
||||
domainId: z.string().nullable().optional().default(null)
|
||||
});
|
||||
|
||||
export async function syncCertToNewts(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
const parsed = bodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsed.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { domain, domainId } = parsed.data;
|
||||
|
||||
logger.debug(
|
||||
`syncCertToNewts: received request to push cert update for domain "${domain}" (domainId: ${domainId ?? "none"})`
|
||||
);
|
||||
|
||||
try {
|
||||
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||
|
||||
res.status(HttpCode.OK).json({
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: `Certificate update pushed to affected newts for domain "${domain}"`
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`syncCertToNewts: error pushing cert update for domain "${domain}": ${err}`
|
||||
);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to push certificate update to affected newts"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+506
-2
@@ -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 {
|
||||
@@ -45,16 +46,25 @@ import {
|
||||
verifySiteResourceAccess,
|
||||
verifyOlmAccess,
|
||||
verifyLimits,
|
||||
verifyResourcePolicyAccess
|
||||
verifyResourcePolicyAccess,
|
||||
verifyAiProviderAccess,
|
||||
verifyAiModelAccess,
|
||||
verifyAiBudgetAccess,
|
||||
verifyVirtualApiKeyAccess,
|
||||
logActionAudit,
|
||||
verifyCertificateAccess
|
||||
} from "@server/middlewares";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
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 { 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 * as certificates from "@server/routers/certificates";
|
||||
|
||||
// Root routes
|
||||
export const unauthenticated = Router();
|
||||
@@ -404,6 +414,20 @@ authenticated.get(
|
||||
siteResource.listSiteResourceClients
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/site-resource/:siteResourceId/ai-models",
|
||||
verifySiteResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.listResourceAiModels),
|
||||
siteResource.listSiteResourceAiModels
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/site-resource/:siteResourceId/ai-providers",
|
||||
verifySiteResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.listResourceAiModels),
|
||||
siteResource.listSiteResourceAiProviders
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/site-resource/:siteResourceId/roles",
|
||||
verifySiteResourceAccess,
|
||||
@@ -414,6 +438,54 @@ 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/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,
|
||||
@@ -521,6 +593,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,
|
||||
@@ -648,6 +734,20 @@ authenticated.get(
|
||||
resource.listResourceUsers
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource/:resourceId/ai-models",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.listResourceAiModels),
|
||||
resource.listResourceAiModels
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource/:resourceId/ai-providers",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.listResourceAiModels),
|
||||
resource.listResourceAiProviders
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource/:resourceId",
|
||||
verifyResourceAccess,
|
||||
@@ -851,6 +951,54 @@ authenticated.post(
|
||||
resource.setResourceUsers
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/resource/:resourceId/ai-models",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.setResourceAiModels),
|
||||
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||
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,
|
||||
@@ -1342,6 +1490,63 @@ authenticated.get(
|
||||
logs.exportRequestAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.viewLogs),
|
||||
logs.queryAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/export",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
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/logs/ai/usage/virtual-api-keys",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.viewLogs),
|
||||
logs.queryAiUsageVirtualApiKeys
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/blueprints",
|
||||
verifyOrgAccess,
|
||||
@@ -1366,6 +1571,259 @@ 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(
|
||||
"/ai-provider/:providerId",
|
||||
verifyAiProviderAccess,
|
||||
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",
|
||||
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(
|
||||
"/ai-provider/:providerId",
|
||||
verifyAiProviderAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateAiProvider),
|
||||
logActionAudit(ActionsEnum.updateAiProvider),
|
||||
aiProvider.updateAiProvider
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/ai-provider/:providerId",
|
||||
verifyAiProviderAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteAiProvider),
|
||||
logActionAudit(ActionsEnum.deleteAiProvider),
|
||||
aiProvider.deleteAiProvider
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/ai-provider/:providerId/model",
|
||||
verifyAiProviderAccess,
|
||||
verifyUserHasAction(ActionsEnum.createAiModel),
|
||||
logActionAudit(ActionsEnum.createAiModel),
|
||||
aiProvider.createAiModel
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-provider/:providerId/models",
|
||||
verifyAiProviderAccess,
|
||||
verifyUserHasAction(ActionsEnum.listAiModels),
|
||||
aiProvider.listAiModels
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-provider/:providerId/catalog-models",
|
||||
verifyAiProviderAccess,
|
||||
verifyUserHasAction(ActionsEnum.listAiModels),
|
||||
aiProvider.listCatalogModels
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/ai-catalog-models",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listAiModels),
|
||||
aiProvider.listCatalogModelsByType
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-model/:modelId",
|
||||
verifyAiModelAccess,
|
||||
verifyUserHasAction(ActionsEnum.getAiModel),
|
||||
aiProvider.getAiModel
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/ai-model/:modelId",
|
||||
verifyAiModelAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateAiModel),
|
||||
logActionAudit(ActionsEnum.updateAiModel),
|
||||
aiProvider.updateAiModel
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/ai-model/:modelId",
|
||||
verifyAiModelAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteAiModel),
|
||||
logActionAudit(ActionsEnum.deleteAiModel),
|
||||
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.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.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,
|
||||
virtualApiKey.listMyVirtualApiKeys
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/my-virtual-api-keys/:virtualApiKeyId",
|
||||
verifyOrgAccess,
|
||||
virtualApiKey.getMyVirtualApiKey
|
||||
);
|
||||
|
||||
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,
|
||||
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(
|
||||
"/virtual-api-key/:virtualApiKeyId/ai-budgets",
|
||||
verifyVirtualApiKeyAccess,
|
||||
verifyUserHasAction(ActionsEnum.listAiBudgets),
|
||||
aiBudget.listAiBudgetsForVirtualApiKey
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/labels",
|
||||
verifyOrgAccess,
|
||||
@@ -1408,6 +1866,52 @@ authenticated.put(
|
||||
labels.detachLabelFromItem
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/ssh/sign-key",
|
||||
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
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/certificate/:domainId/:domain",
|
||||
verifyOrgAccess,
|
||||
verifyCertificateAccess,
|
||||
verifyUserHasAction(ActionsEnum.getCertificate),
|
||||
certificates.getCertificate
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/batched-certificates",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.getCertificate),
|
||||
certificates.getBatchedCertificates
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/certificate/:certId/restart",
|
||||
verifyOrgAccess,
|
||||
verifyCertificateAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.restartCertificate),
|
||||
logActionAudit(ActionsEnum.restartCertificate),
|
||||
certificates.restartCertificate
|
||||
);
|
||||
|
||||
// Auth routes
|
||||
export const authRouter = Router();
|
||||
unauthenticated.use("/auth", authRouter);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -89,11 +89,27 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
|
||||
and(
|
||||
eq(sites.exitNodeId, exitNode.exitNodeId),
|
||||
isNotNull(sites.pubKey),
|
||||
isNotNull(sites.subnet)
|
||||
isNotNull(sites.exitNodeSubnet)
|
||||
)
|
||||
);
|
||||
|
||||
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 {
|
||||
@@ -103,7 +119,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
|
||||
} else if (site.type === "newt") {
|
||||
return {
|
||||
publicKey: site.pubKey,
|
||||
allowedIps: [site.subnet!]
|
||||
allowedIps: [site.exitNodeSubnet!]
|
||||
};
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,9 @@ 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 * as aiBudget from "./aiBudget";
|
||||
import * as virtualApiKey from "./virtualApiKey";
|
||||
import {
|
||||
verifyApiKey,
|
||||
verifyApiKeyOrgAccess,
|
||||
@@ -31,6 +34,10 @@ import {
|
||||
verifyLimits,
|
||||
verifyApiKeyDomainAccess,
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyAiModelAccess,
|
||||
verifyApiKeyAiBudgetAccess,
|
||||
verifyApiKeyVirtualApiKeyAccess,
|
||||
verifyUserHasAction
|
||||
} from "@server/middlewares";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -243,6 +250,26 @@ authenticated.get(
|
||||
siteResource.listSiteResourceClients
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
[
|
||||
"/site-resource/:siteResourceId/ai-models",
|
||||
"/private-resource/:siteResourceId/ai-models"
|
||||
],
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listResourceAiModels),
|
||||
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",
|
||||
@@ -295,6 +322,72 @@ 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/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",
|
||||
@@ -507,6 +600,26 @@ 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/ai-providers",
|
||||
"/public-resource/:resourceId/ai-providers"
|
||||
],
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listResourceAiModels),
|
||||
resource.listResourceAiProviders
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
["/resource/:resourceId", "/public-resource/:resourceId"],
|
||||
verifyApiKeyResourceAccess,
|
||||
@@ -708,6 +821,28 @@ 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/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,
|
||||
@@ -900,6 +1035,50 @@ 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/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",
|
||||
@@ -1353,6 +1532,63 @@ authenticated.get(
|
||||
logs.exportRequestAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.viewLogs),
|
||||
logs.queryAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/export",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
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/ai/usage/virtual-api-keys",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.viewLogs),
|
||||
logs.queryAiUsageVirtualApiKeys
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/analytics",
|
||||
verifyApiKeyOrgAccess,
|
||||
@@ -1366,3 +1602,249 @@ 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(
|
||||
"/ai-provider/:providerId",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
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",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createTarget),
|
||||
logActionAudit(ActionsEnum.createTarget),
|
||||
target.createTarget
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-provider/:providerId/targets",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listTargets),
|
||||
target.listTargets
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/ai-provider/:providerId",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateAiProvider),
|
||||
logActionAudit(ActionsEnum.updateAiProvider),
|
||||
aiProvider.updateAiProvider
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/ai-provider/:providerId",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteAiProvider),
|
||||
logActionAudit(ActionsEnum.deleteAiProvider),
|
||||
aiProvider.deleteAiProvider
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/ai-provider/:providerId/model",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createAiModel),
|
||||
logActionAudit(ActionsEnum.createAiModel),
|
||||
aiProvider.createAiModel
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-provider/:providerId/models",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listAiModels),
|
||||
aiProvider.listAiModels
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-provider/:providerId/catalog-models",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listAiModels),
|
||||
aiProvider.listCatalogModels
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/ai-catalog-models",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listAiModels),
|
||||
aiProvider.listCatalogModelsByType
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-model/:modelId",
|
||||
verifyApiKeyAiModelAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getAiModel),
|
||||
aiProvider.getAiModel
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/ai-model/:modelId",
|
||||
verifyApiKeyAiModelAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateAiModel),
|
||||
logActionAudit(ActionsEnum.updateAiModel),
|
||||
aiProvider.updateAiModel
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/ai-model/:modelId",
|
||||
verifyApiKeyAiModelAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteAiModel),
|
||||
logActionAudit(ActionsEnum.deleteAiModel),
|
||||
aiProvider.deleteAiModel
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/ai-budget",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createAiBudget),
|
||||
logActionAudit(ActionsEnum.createAiBudget),
|
||||
aiBudget.createAiBudget
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/ai-budgets",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listAiBudgets),
|
||||
aiBudget.listAiBudgets
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-budget/:budgetId",
|
||||
verifyApiKeyAiBudgetAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getAiBudget),
|
||||
aiBudget.getAiBudget
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/ai-budget/:budgetId",
|
||||
verifyApiKeyAiBudgetAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateAiBudget),
|
||||
logActionAudit(ActionsEnum.updateAiBudget),
|
||||
aiBudget.updateAiBudget
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/ai-budget/:budgetId",
|
||||
verifyApiKeyAiBudgetAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteAiBudget),
|
||||
logActionAudit(ActionsEnum.deleteAiBudget),
|
||||
aiBudget.deleteAiBudget
|
||||
);
|
||||
|
||||
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.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,
|
||||
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
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-provider/:providerId/ai-budgets",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listAiBudgets),
|
||||
aiBudget.listAiBudgetsForProvider
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-model/:modelId/ai-budgets",
|
||||
verifyApiKeyAiModelAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listAiBudgets),
|
||||
aiBudget.listAiBudgetsForModel
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
[
|
||||
"/resource/:resourceId/ai-budgets",
|
||||
"/public-resource/:resourceId/ai-budgets"
|
||||
],
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listAiBudgets),
|
||||
aiBudget.listAiBudgetsForResource
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
[
|
||||
"/site-resource/:siteResourceId/ai-budgets",
|
||||
"/private-resource/:siteResourceId/ai-budgets"
|
||||
],
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listAiBudgets),
|
||||
aiBudget.listAiBudgetsForSiteResource
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/role/:roleId/ai-budgets",
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listAiBudgets),
|
||||
aiBudget.listAiBudgetsForRole
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/virtual-api-key/:virtualApiKeyId/ai-budgets",
|
||||
verifyApiKeyVirtualApiKeyAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listAiBudgets),
|
||||
aiBudget.listAiBudgetsForVirtualApiKey
|
||||
);
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
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";
|
||||
import * as ws from "@server/routers/ws";
|
||||
import * as browserTarget from "@server/routers/browserGatewayTarget";
|
||||
|
||||
// Root routes
|
||||
export const internalRouter = Router();
|
||||
@@ -42,6 +46,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,3 +73,11 @@ internalRouter.use("/badger", badgerRouter);
|
||||
badgerRouter.post("/verify-session", badger.verifyResourceSession);
|
||||
|
||||
badgerRouter.post("/exchange-session", badger.exchangeSession);
|
||||
|
||||
internalRouter.get("/resource/browser-target", browserTarget.getBrowserTarget);
|
||||
|
||||
internalRouter.get(
|
||||
"/ws/round-trip-message/:messageId",
|
||||
verifyUserFromResourceSessionMiddleware,
|
||||
ws.checkRoundTripMessage
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -97,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,
|
||||
@@ -124,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,
|
||||
@@ -141,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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
@@ -314,6 +316,7 @@ function buildSearchConditionForPublic(query: string) {
|
||||
const pattern = searchPattern(query.toLowerCase());
|
||||
const queryList = [
|
||||
like(sql`LOWER(${resources.name})`, pattern),
|
||||
like(sql`LOWER(${resources.niceId})`, pattern),
|
||||
like(sql`LOWER(${resources.fullDomain})`, pattern),
|
||||
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern),
|
||||
inArray(
|
||||
@@ -346,6 +349,7 @@ function buildSearchConditionForSiteResource(query: string) {
|
||||
const pattern = searchPattern(query.toLowerCase());
|
||||
const queryList = [
|
||||
like(sql`LOWER(${siteResources.name})`, pattern),
|
||||
like(sql`LOWER(${siteResources.niceId})`, pattern),
|
||||
like(sql`LOWER(${siteResources.destination})`, pattern),
|
||||
like(
|
||||
sql`LOWER(cast(${siteResources.destinationPort} as text))`,
|
||||
@@ -652,6 +656,7 @@ async function listSiteGroups(
|
||||
}
|
||||
}
|
||||
|
||||
let aiGatewayCount = 0;
|
||||
let noSiteCount = 0;
|
||||
|
||||
if (accessible.resourceIds.length > 0 && siteFilterIds.length === 0) {
|
||||
@@ -665,27 +670,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 +727,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 +788,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 +815,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 +1248,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 +1389,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)
|
||||
|
||||
@@ -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<ReturnType<typeof listEffectiveAllowModels>>;
|
||||
};
|
||||
|
||||
export async function listLauncherPublicAiModels(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
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<ListLauncherAiModelsResponse>(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<any> {
|
||||
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<ListLauncherAiModelsResponse>(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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string>`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))
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
+12
-28
@@ -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 {
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
@@ -94,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
|
||||
|
||||
@@ -115,7 +108,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSubnet = await getUniqueSubnetForSite(exitNode);
|
||||
const newSubnet = await getUniqueSubnetForExitNode(exitNode);
|
||||
|
||||
if (!newSubnet) {
|
||||
logger.error(
|
||||
@@ -131,7 +124,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
.set({
|
||||
pubKey: publicKey,
|
||||
exitNodeId: exitNodeId,
|
||||
subnet: newSubnet
|
||||
exitNodeSubnet: newSubnet
|
||||
})
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.returning();
|
||||
@@ -250,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<string | null> {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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()
|
||||
@@ -48,10 +51,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 +67,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(
|
||||
@@ -81,7 +80,15 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
);
|
||||
|
||||
const siteResourcesBySiteId = new Map<number, SiteResource[]>();
|
||||
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;
|
||||
}
|
||||
const arr = siteResourcesBySiteId.get(row.siteId);
|
||||
if (arr) {
|
||||
arr.push(row.siteResource);
|
||||
@@ -90,6 +97,17 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
}
|
||||
}
|
||||
|
||||
exitNodeAliases = siteResourcesForExitNode
|
||||
.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) {
|
||||
return {
|
||||
siteConfigurations,
|
||||
exitNodeAliases
|
||||
};
|
||||
}
|
||||
|
||||
// Batch-fetch exit nodes for all sites in one query (only needed in relay mode).
|
||||
const exitNodesById = new Map<number, typeof exitNodes.$inferSelect>();
|
||||
if (!jitMode && relay) {
|
||||
@@ -167,7 +185,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;
|
||||
}
|
||||
@@ -226,5 +244,8 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
});
|
||||
}
|
||||
|
||||
return siteConfigurations;
|
||||
return {
|
||||
siteConfigurations,
|
||||
exitNodeAliases
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db, orgs, primaryDb } from "@server/db";
|
||||
import { db, ExitNode, exitNodes, orgs, primaryDb } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import {
|
||||
clients,
|
||||
@@ -22,6 +22,13 @@ 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,
|
||||
verifyExitNodeOrgAccess
|
||||
} from "#dynamic/lib/exitNodes";
|
||||
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
|
||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||
|
||||
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
|
||||
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;
|
||||
@@ -49,11 +56,20 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
olmAgent,
|
||||
orgId,
|
||||
userToken,
|
||||
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);
|
||||
@@ -284,7 +300,64 @@ 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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.exitNodeSubnet !== clientSubnet
|
||||
) {
|
||||
logger.info(
|
||||
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
|
||||
{ orgId: client.orgId, clientId: client.clientId }
|
||||
@@ -294,7 +367,9 @@ 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
|
||||
exitNodeSubnet: clientSubnet
|
||||
})
|
||||
.where(eq(clients.clientId, client.clientId));
|
||||
|
||||
@@ -319,6 +394,24 @@ 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
|
||||
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) !== "";
|
||||
@@ -376,15 +469,29 @@ 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);
|
||||
|
||||
const siteConfigurations = await buildSiteConfigurationForOlmClient(
|
||||
client,
|
||||
publicKey,
|
||||
relay,
|
||||
jitMode
|
||||
);
|
||||
const { siteConfigurations, exitNodeAliases } =
|
||||
await buildSiteConfigurationForOlmClient(
|
||||
client,
|
||||
publicKey,
|
||||
relay,
|
||||
jitMode
|
||||
);
|
||||
|
||||
// logger.info(
|
||||
// `ExitNode Aliases: ${exitNodeAliases}`
|
||||
// );
|
||||
|
||||
// Return connect message with all site configurations
|
||||
return {
|
||||
@@ -394,6 +501,17 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
sites: siteConfigurations,
|
||||
tunnelIP: client.subnet,
|
||||
utilitySubnet: org.utilitySubnet,
|
||||
exitNode:
|
||||
exitNode && clientSubnet
|
||||
? {
|
||||
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}`,
|
||||
publicKey: exitNode.publicKey,
|
||||
serverIP: exitNode.address.split("/")[0],
|
||||
tunnelIP: `${clientSubnet.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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,3 +15,4 @@ export * from "./handleOlmServerInitAddPeerHandshake";
|
||||
export * from "./offlineChecker";
|
||||
export * from "./handleOlmUnLocalMessage";
|
||||
export * from "./handleOlmLocalMessage";
|
||||
export * from "./handleOlmExitNodesRequestMessage";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -33,7 +33,6 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor
|
||||
import { doCidrsOverlap } from "@server/lib/ip";
|
||||
import { generateCA } from "@server/lib/sshCA";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
|
||||
const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/;
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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";
|
||||
import {
|
||||
assertPublicModelListApiEligible,
|
||||
assertPublicResourceModelEntriesValid,
|
||||
modelListTypeSchema
|
||||
} from "@server/lib/aiInferenceResource";
|
||||
|
||||
const addAiModelToResourceBodySchema = z.strictObject({
|
||||
modelId: z.number().int().positive(),
|
||||
listType: modelListTypeSchema.optional().default("allow")
|
||||
});
|
||||
|
||||
const addAiModelToResourceParamsSchema = z.strictObject({
|
||||
resourceId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/resource/{resourceId}/ai-models/add",
|
||||
description:
|
||||
"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,
|
||||
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<any> {
|
||||
try {
|
||||
const parsedBody = addAiModelToResourceBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { modelId, listType } = 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")
|
||||
);
|
||||
}
|
||||
|
||||
const eligibleError = await assertPublicModelListApiEligible(resource);
|
||||
if (eligibleError) {
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
||||
}
|
||||
|
||||
const modelError = await assertPublicResourceModelEntriesValid({
|
||||
orgId: resource.orgId,
|
||||
resourceId,
|
||||
models: [{ modelId, listType }]
|
||||
});
|
||||
if (modelError) {
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
|
||||
}
|
||||
|
||||
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, listType });
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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 addAiProviderToResourceBodySchema = z.strictObject({
|
||||
providerId: z.number().int().positive()
|
||||
});
|
||||
|
||||
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. The provider is attached in inherit mode, using its own allow/block lists.",
|
||||
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<any> {
|
||||
try {
|
||||
const parsedBody = addAiProviderToResourceBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { providerId } = 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,
|
||||
accessMode: a.accessMode,
|
||||
enabled: a.enabled
|
||||
})),
|
||||
{
|
||||
providerId,
|
||||
accessMode: "inherit" as const,
|
||||
enabled: true as const
|
||||
}
|
||||
];
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import { db } from "@server/db";
|
||||
import { Resource, resources, users } from "@server/db";
|
||||
import { db, users } from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -65,82 +64,35 @@ export async function authWithAccessToken(
|
||||
const { accessToken, accessTokenId } = parsedBody.data;
|
||||
|
||||
try {
|
||||
let valid;
|
||||
let tokenItem;
|
||||
let error;
|
||||
let resource: Resource | undefined;
|
||||
|
||||
if (accessTokenId) {
|
||||
if (!resourceId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Resource ID is required"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [foundResource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!foundResource) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||
);
|
||||
}
|
||||
|
||||
const res = await verifyResourceAccessToken({
|
||||
const { valid, tokenItem, error, resource } =
|
||||
await verifyResourceAccessToken({
|
||||
accessToken,
|
||||
accessTokenId,
|
||||
accessToken
|
||||
resourceId
|
||||
});
|
||||
|
||||
valid = res.valid;
|
||||
tokenItem = res.tokenItem;
|
||||
error = res.error;
|
||||
resource = foundResource;
|
||||
} else {
|
||||
const res = await verifyResourceAccessToken({
|
||||
accessToken
|
||||
});
|
||||
if (!valid || !tokenItem || !resource) {
|
||||
if (resource) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
|
||||
valid = res.valid;
|
||||
tokenItem = res.tokenItem;
|
||||
error = res.error;
|
||||
resource = res.resource;
|
||||
}
|
||||
|
||||
if (!tokenItem || !resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"Access token does not exist for resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
|
||||
);
|
||||
logAccessAudit({
|
||||
orgId: resource.orgId,
|
||||
resourceId: resource.resourceId,
|
||||
action: false,
|
||||
type: "accessToken",
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
}
|
||||
|
||||
logAccessAudit({
|
||||
orgId: resource.orgId,
|
||||
resourceId: resource.resourceId,
|
||||
action: false,
|
||||
type: "accessToken",
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
error || "Invalid access token"
|
||||
error || "Access token does not exist for resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,37 +18,44 @@ 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";
|
||||
import config from "@server/lib/config";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { createCertificate } from "@server/routers/certificates";
|
||||
import {
|
||||
validateAndConstructDomain,
|
||||
checkWildcardDomainConflict
|
||||
} 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
|
||||
} 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()
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -90,11 +97,20 @@ 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
|
||||
aiProviders: z
|
||||
.array(resourceAiProviderAttachmentSchema)
|
||||
.optional()
|
||||
.describe(
|
||||
"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(
|
||||
(data) => {
|
||||
@@ -365,11 +381,50 @@ async function createHttpResource(
|
||||
mode,
|
||||
authDaemonPort,
|
||||
authDaemonMode,
|
||||
pamMode
|
||||
pamMode,
|
||||
aiProviders: aiProviderInputs
|
||||
} = parsedBody.data;
|
||||
const subdomain = parsedBody.data.subdomain;
|
||||
const stickySession = parsedBody.data.stickySession;
|
||||
|
||||
const effectiveMode = mode ?? "http";
|
||||
|
||||
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 ?? []).map((p) => ({
|
||||
providerId: p.providerId,
|
||||
accessMode: "inherit" as const,
|
||||
enabled: true as const
|
||||
})),
|
||||
requireAtLeastOne: false
|
||||
});
|
||||
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 not allowed for inference-mode resources
|
||||
if (effectiveMode === "inference" && subdomain && subdomain.includes("*")) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Wildcard subdomains are not supported for inference-mode resources."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Wildcard subdomains are a paid feature
|
||||
if (subdomain && subdomain.includes("*")) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
@@ -409,21 +464,6 @@ async function createHttpResource(
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
["ssh", "rdp", "vnc"].includes(mode!) &&
|
||||
!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,
|
||||
@@ -439,11 +479,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"
|
||||
? ne(resources.mode, "inference")
|
||||
: eq(resources.mode, "inference")
|
||||
)
|
||||
);
|
||||
|
||||
if (existingResource.length > 0) {
|
||||
return next(
|
||||
@@ -543,7 +594,7 @@ async function createHttpResource(
|
||||
orgId,
|
||||
name,
|
||||
subdomain: finalSubdomain,
|
||||
mode: mode,
|
||||
mode: effectiveMode,
|
||||
pamMode: pamMode,
|
||||
authDaemonMode: authDaemonMode,
|
||||
authDaemonPort: authDaemonPort,
|
||||
@@ -556,6 +607,14 @@ async function createHttpResource(
|
||||
})
|
||||
.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
|
||||
@@ -583,9 +642,7 @@ async function createHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
if (build !== "oss") {
|
||||
await createCertificate(domainId, fullDomain, db);
|
||||
}
|
||||
await createCertificate(domainId, fullDomain, db);
|
||||
|
||||
return response<CreateResourceResponse>(res, {
|
||||
data: resource,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,3 +35,11 @@ export * from "./removeEmailFromResourceWhitelist";
|
||||
export * from "./getStatusHistory";
|
||||
export * from "./getBatchedStatusHistory";
|
||||
export * from "./getResourcePolicies";
|
||||
export * from "./listResourceAiModels";
|
||||
export * from "./setResourceAiModels";
|
||||
export * from "./addAiModelToResource";
|
||||
export * from "./removeAiModelFromResource";
|
||||
export * from "./listResourceAiProviders";
|
||||
export * from "./setResourceAiProviders";
|
||||
export * from "./addAiProviderToResource";
|
||||
export * from "./removeAiProviderFromResource";
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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,
|
||||
providerId: aiModels.providerId,
|
||||
enabled: aiModels.enabled,
|
||||
listType: resourceAiModels.listType
|
||||
})
|
||||
.from(resourceAiModels)
|
||||
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
|
||||
.where(eq(resourceAiModels.resourceId, resourceId));
|
||||
}
|
||||
|
||||
export type ListResourceAiModelsResponse = {
|
||||
models: NonNullable<Awaited<ReturnType<typeof query>>>;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/resource/{resourceId}/ai-models",
|
||||
description:
|
||||
"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
|
||||
},
|
||||
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<any> {
|
||||
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<ListResourceAiModelsResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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<ReturnType<typeof listPublicResourceAiProviders>>;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/resource/{resourceId}/ai-providers",
|
||||
description:
|
||||
"List AI providers attached to an inference resource, including each attachment's accessMode.",
|
||||
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<any> {
|
||||
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<ListResourceAiProvidersResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}),
|
||||
@@ -637,11 +646,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;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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";
|
||||
import { assertPublicModelListApiEligible } from "@server/lib/aiInferenceResource";
|
||||
|
||||
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 model from an inference resource allow/block list. Requires at least one attached AI provider.",
|
||||
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<any> {
|
||||
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 eligibleError = await assertPublicModelListApiEligible(resource);
|
||||
if (eligibleError) {
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
||||
}
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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<any> {
|
||||
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,
|
||||
accessMode: a.accessMode,
|
||||
enabled: a.enabled
|
||||
}));
|
||||
|
||||
const attachments = await resolveProviderAttachments({
|
||||
orgId: resource.orgId,
|
||||
attachments: remaining,
|
||||
requireAtLeastOne: false
|
||||
});
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
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 {
|
||||
assertPublicModelListApiEligible,
|
||||
assertPublicResourceModelEntriesValid,
|
||||
resourceAiModelEntrySchema
|
||||
} from "@server/lib/aiInferenceResource";
|
||||
|
||||
const setResourceAiModelsBodySchema = z.strictObject({
|
||||
models: z.array(resourceAiModelEntrySchema)
|
||||
});
|
||||
|
||||
const setResourceAiModelsParamsSchema = z.strictObject({
|
||||
resourceId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/resource/{resourceId}/ai-models",
|
||||
description:
|
||||
"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,
|
||||
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<any> {
|
||||
try {
|
||||
const parsedBody = setResourceAiModelsBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { models } = 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")
|
||||
);
|
||||
}
|
||||
|
||||
const eligibleError = await assertPublicModelListApiEligible(resource);
|
||||
if (eligibleError) {
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
||||
}
|
||||
|
||||
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,
|
||||
models: uniqueModels
|
||||
});
|
||||
if (modelError) {
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(resourceAiModels)
|
||||
.where(eq(resourceAiModels.resourceId, resourceId));
|
||||
|
||||
if (uniqueModels.length > 0) {
|
||||
await trx.insert(resourceAiModels).values(
|
||||
uniqueModels.map((m) => ({
|
||||
resourceId,
|
||||
modelId: m.modelId,
|
||||
listType: m.listType
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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. 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,
|
||||
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<any> {
|
||||
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: false
|
||||
});
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
} from "@server/lib/schemas";
|
||||
import { registry } from "@server/openApi";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { createCertificate } from "@server/routers/certificates/createCertificate";
|
||||
import {
|
||||
validateAndConstructDomain,
|
||||
checkWildcardDomainConflict
|
||||
@@ -345,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,
|
||||
@@ -529,6 +531,20 @@ async function updateHttpResource(
|
||||
}
|
||||
}
|
||||
|
||||
// Wildcard subdomains are not allowed for inference-mode resources
|
||||
if (
|
||||
resource.mode === "inference" &&
|
||||
updateData.subdomain &&
|
||||
updateData.subdomain.includes("*")
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Wildcard subdomains are not supported for inference-mode resources."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Wildcard subdomains are a paid feature
|
||||
if (updateData.subdomain && updateData.subdomain.includes("*")) {
|
||||
if (!isLicensed) {
|
||||
@@ -594,10 +610,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"
|
||||
? ne(resources.mode, "inference")
|
||||
: eq(resources.mode, "inference")
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
existingDomain &&
|
||||
@@ -663,9 +692,7 @@ async function updateHttpResource(
|
||||
// Update the subdomain in the update data
|
||||
updateData.subdomain = finalSubdomain;
|
||||
|
||||
if (build != "oss") {
|
||||
await createCertificate(domainId, fullDomain, db);
|
||||
}
|
||||
await createCertificate(domainId, fullDomain, db);
|
||||
}
|
||||
|
||||
let headers = undefined;
|
||||
|
||||
@@ -135,7 +135,7 @@ export async function createRole(
|
||||
|
||||
const isLicensedSshPam = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.advancedPrivateResources
|
||||
tierMatrix.roleBasedSSHControls
|
||||
);
|
||||
const roleInsertValues: Record<string, unknown> = {
|
||||
name: roleData.name,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user