Compare commits

..

169 Commits

Author SHA1 Message Date
Owen c35ac493ed Rebalance the data in the expand column 2026-08-13 09:38:42 -04:00
Owen e100645a00 Use the right subnet 2026-08-13 09:38:42 -04:00
miloschwartz 96bfc66a94 set default provider name 2026-08-13 09:37:53 -04:00
miloschwartz e21c9eec31 Merge branch 'aig' of https://github.com/fosrl/pangolin into aig 2026-08-13 09:29:02 -04:00
miloschwartz a9f32102b5 format custom error codes according to capability 2026-08-13 09:26:58 -04:00
Owen 7291628f86 optionally stamp ip header for downstream use 2026-08-13 09:14:37 -04:00
Owen 5fbb205044 search the right resource 2026-08-12 17:52:39 -04:00
Owen 02ab24d01e Fix clients not connecting on first connect 2026-08-12 17:16:59 -04:00
Owen 3cfbd66a80 Merge branch 'dev' into aig 2026-08-12 16:34:52 -04:00
Owen 860fa47b7c Split out the token header into a common middleware for efficiency 2026-08-12 16:30:42 -04:00
miloschwartz 71d9d8f010 fix accept invite as idp user 2026-08-12 16:25:20 -04:00
Owen cc58e28e54 Allow merging a catalog file with the api response 2026-08-12 16:06:12 -04:00
Owen 653dd920ad Allow remote header overrides and fix display issues with log 2026-08-12 15:54:22 -04:00
miloschwartz dd78c2cc08 dont show servers idps in create user when idp mode is org on enterprise 2026-08-12 15:20:41 -04:00
Owen 47ae017f94 add virtual api keys to budgets 2026-08-12 13:21:56 -04:00
miloschwartz c33fa8782b Merge branch 'aig' of https://github.com/fosrl/pangolin into aig 2026-08-12 12:35:30 -04:00
miloschwartz 93cba1d098 support sending capability specific error codes 2026-08-12 12:34:25 -04:00
Owen d3a1f9798d Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-08-12 11:45:28 -04:00
miloschwartz 115c3cbf07 show sso page in browser for gateway resource 2026-08-12 11:32:22 -04:00
Owen bed5817da3 send, process, store, display virtual api key ai information in usage and sessions 2026-08-12 11:20:25 -04:00
miloschwartz 83035753af support bypass auth rules on gateway 2026-08-12 11:18:16 -04:00
miloschwartz 379b53bcca redirect to api key page when access gateway in browser 2026-08-12 11:15:50 -04:00
Owen 355294a2d4 fix logo warning in logs 2026-08-12 10:48:06 -04:00
Owen 833b27ab4a separate the users and roles tabs 2026-08-12 10:40:38 -04:00
Owen 8a04f13dd4 translate keys and restructure roles and users 2026-08-12 10:40:38 -04:00
miloschwartz 49020fa6ea add virtual api key validation in verifySession 2026-08-12 10:38:26 -04:00
Owen ac3402a8b3 Adjust structure delete models 2026-08-12 09:41:42 -04:00
Owen 9369e60695 Merge branch 'dev' into aig 2026-08-12 09:19:12 -04:00
Owen 295e38d2af Remove arbitrary max 50 limit from email whitelist
Fix #3568
2026-08-12 09:11:10 -04:00
Owen 11595f5f96 use title case and fix colors 2026-08-11 18:02:17 -04:00
Owen b08e875b37 basic ai analytics created 2026-08-11 17:52:27 -04:00
Owen 0016b8fce7 show other tokens on the expanded row 2026-08-11 16:40:24 -04:00
Owen f5b10df7cf fix non-https crypto issue 2026-08-11 16:40:24 -04:00
miloschwartz 1782f31075 show link to provider settings 2026-08-11 16:38:22 -04:00
Owen f7aca85417 link the usage with the session to display together 2026-08-11 16:15:01 -04:00
miloschwartz c42df737b0 improve model provider selection algorithm 2026-08-11 16:10:46 -04:00
Owen 75c6af3b11 Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-08-11 15:58:12 -04:00
Owen e734cc93a1 small visual adjustments and chat button 2026-08-11 15:57:57 -04:00
Owen e3ccc4f8d4 show the sessions in the ui 2026-08-11 15:50:51 -04:00
miloschwartz 896c362dce only support sso on inference resource auth policy 2026-08-11 15:35:28 -04:00
miloschwartz 864ab97293 hide expire column 2026-08-11 14:59:03 -04:00
Owen bc2f291352 normalize the requests to also store in the log for viewing later 2026-08-11 14:45:25 -04:00
miloschwartz 98f5e39a7f show ai gateway resource details in launcher 2026-08-11 14:42:07 -04:00
Owen e0a66e79bb Continue to quiet log messages 2026-08-11 14:07:33 -04:00
Owen c781be4e70 quiet up some more logs 2026-08-11 14:05:58 -04:00
Owen 48c4b44f72 log chat sessions to database 2026-08-11 12:19:28 -04:00
Owen 68a84181d9 remove excess logging 2026-08-11 12:19:27 -04:00
Owen b60390d805 batch up usage records for insert 2026-08-11 12:19:27 -04:00
miloschwartz b33e776072 add virtual keys link in header on launcher 2026-08-11 12:09:11 -04:00
miloschwartz 2187f23588 add page to retrieve user virtual api keys 2026-08-11 11:53:39 -04:00
miloschwartz 732fd4eba1 allow retrieving api key secret 2026-08-11 10:47:04 -04:00
Owen 495c772d6b Merge branch 'dev' into aig 2026-08-11 09:42:10 -04:00
Owen 02e4fe8b48 Show the provided domains when changing properly 2026-08-11 09:40:55 -04:00
miloschwartz 6d45486bb5 add manual virtual api key create ui 2026-08-10 21:44:38 -04:00
Owen 4c1f7f6243 add caching to budget check 2026-08-10 17:39:00 -04:00
miloschwartz 1e3a9fb921 add virtual api key schema and crud endpoints 2026-08-10 17:20:06 -04:00
Owen 8b8e7913dc budget enforcement logic 2026-08-10 17:17:24 -04:00
Owen 88515a7dad remove duplicate save buttons 2026-08-10 15:31:45 -04:00
Owen e012063f5b add the budget to the models on the provider 2026-08-10 15:09:39 -04:00
Owen e76377d3c7 Match the pg query logging 2026-08-10 14:55:44 -04:00
Owen a357f42c48 allow creating role with budgets 2026-08-10 14:55:44 -04:00
Owen 2becb15916 put the budget on the role form 2026-08-10 14:55:44 -04:00
miloschwartz 186eeed784 add richer model editor on provider 2026-08-10 14:54:58 -04:00
Owen 216c932cb9 order by budget id 2026-08-10 14:12:34 -04:00
Owen 52de5eb539 add the budget config to the resources 2026-08-10 14:08:17 -04:00
Owen eec3119297 Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-08-10 13:55:10 -04:00
Owen f2e7f83b3d match the budget picker to the rules picker 2026-08-10 13:55:05 -04:00
miloschwartz 93dea0525e basic provider model selector 2026-08-10 12:32:17 -04:00
Owen 7e6e0a8a9c make the budget match the rules 2026-08-10 12:20:47 -04:00
Owen 211d3a53f5 initial budget ui on the provider 2026-08-10 12:03:51 -04:00
miloschwartz 187936e5dd use class with helper methods for model catelog 2026-08-10 11:54:57 -04:00
Owen b180d064d1 adjust conflict to be on unit<>period and add list endpoints for providers 2026-08-10 11:39:34 -04:00
Owen ed46afd81a add subscription override column 2026-08-10 11:15:48 -04:00
Owen 0050fad70d add basic crud for ai budgets 2026-08-10 11:05:10 -04:00
miloschwartz 3dc9c100e9 better form feedback when saving roles 2026-08-10 11:02:44 -04:00
Owen 969e7c9296 add crud endpoint skill 2026-08-10 10:58:11 -04:00
Owen 591caab45a Add documentation for creating new crud endpoints 2026-08-10 10:47:18 -04:00
miloschwartz 02e97d6ae4 add copy button to org id 2026-08-10 10:44:25 -04:00
miloschwartz 996160fadc add missing resource policy actions to api key closes #3542 2026-08-10 10:38:40 -04:00
Owen dc1923ab1f Remove budget periods 2026-08-10 09:57:01 -04:00
Owen fe5831eb48 pull the models from the upstream server 2026-08-10 09:57:00 -04:00
Owen ca79abc9d4 Update structure 2026-08-10 09:57:00 -04:00
miloschwartz 9eafa067b9 improved provider picker ui on resource 2026-08-07 16:13:51 -04:00
miloschwartz 5e5e172d39 add streaming function to capability 2026-08-07 14:36:15 -04:00
miloschwartz bc7a883f6c add some parallelization to ai gateway pipeline 2026-08-07 14:36:11 -04:00
Owen 12056aebc6 Add basic cost calculations for testing 2026-08-07 13:54:32 -04:00
miloschwartz 297cb9c8f2 set provider default capabilities 2026-08-07 12:25:29 -04:00
miloschwartz d699455b38 support changing capabilities on provider integrations 2026-08-07 11:23:07 -04:00
miloschwartz 9e7b4afaec support allow list and blocklist 2026-08-07 10:59:50 -04:00
Owen e91c344e64 Update link to be correct 2026-08-07 10:21:15 -04:00
Owen 07f628b928 add the pangolin header information 2026-08-06 17:53:51 -04:00
Owen 184e1425a4 Private connection working with traefik 2026-08-06 17:22:01 -04:00
Owen 22f2990f56 support streaming and closes properly on site targets 2026-08-06 17:15:11 -04:00
Owen 6c28c5f383 Basic target routing 2026-08-06 15:48:13 -04:00
Owen e9f9cf54f4 Include the sheme in the routing header 2026-08-06 15:18:11 -04:00
Owen 751d1b083d Add back port restrictions 2026-08-06 14:55:07 -04:00
Owen a4d77a4fd3 Fix info box ui 2026-08-06 14:45:25 -04:00
Owen f72252552f Fix saving 2026-08-06 14:28:14 -04:00
miloschwartz c5d68675c9 support patterns in model key 2026-08-06 14:27:08 -04:00
miloschwartz 36b8ef5fba Merge branch 'dev' into aig 2026-08-06 13:59:07 -04:00
Owen 6cca5e0472 Use fulldoman instead of the alias for the site resources 2026-08-06 13:45:51 -04:00
Owen b4d2974e19 Properly configure ssl and domain when creating and editing 2026-08-06 12:20:56 -04:00
Owen fb896d6c0f Add domain to the resource selection 2026-08-06 12:03:40 -04:00
Owen 6564bfe8ae disabled advanced mode on the providers 2026-08-06 11:00:34 -04:00
Owen 75ce7e91d7 Restrict the ai site resource router to exit node subnet 2026-08-06 11:00:34 -04:00
Owen 656eea5bb1 add a target routing provider for custom target resources 2026-08-06 11:00:34 -04:00
miloschwartz aba27a7bbf support skip tls per request 2026-08-05 17:53:28 -04:00
miloschwartz 3d7e322bf9 add headers to provider 2026-08-05 17:42:58 -04:00
miloschwartz 1f3fff4a9d Merge branch 'aig' of https://github.com/fosrl/pangolin into aig 2026-08-05 16:55:58 -04:00
miloschwartz 39e06f2b6d add api capabilities 2026-08-05 16:55:48 -04:00
Owen b775c5b674 include clients in the peer config 2026-08-05 16:24:09 -04:00
Owen 796bf37141 ai gateway override only required sometimes 2026-08-05 16:24:01 -04:00
Owen c5b0e1e876 add QUERY_LOGGING env to sqlite driver for verbose logging 2026-08-05 16:17:28 -04:00
miloschwartz 2e8bd7a8c7 improve form error codes 2026-08-05 15:56:12 -04:00
miloschwartz 790daba796 allow no provider on a resource 2026-08-05 15:43:42 -04:00
miloschwartz bcf6b86b84 add no auth and passthrough auth 2026-08-05 15:39:08 -04:00
miloschwartz 2e9bd50172 add provider specific auth modes 2026-08-05 15:17:50 -04:00
miloschwartz c673dce484 pin docker base image 2026-08-05 13:34:45 -04:00
Owen e1dd8965dc Remove extra checks 2026-08-05 11:31:12 -04:00
Owen 346a0bdc98 Resolve syntax issue 2026-08-05 10:31:14 -04:00
Owen 8c1169738e Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-08-05 10:24:42 -04:00
miloschwartz bc80f91a45 gateway endpoint mvp 2026-08-05 10:24:37 -04:00
Owen 3d062389e9 update oss with gateway traefik routers 2026-08-05 10:14:40 -04:00
Owen 7afddb5eb5 Clean logging 2026-08-05 10:00:36 -04:00
Owen 425a99e5ee Show the auth settings for testing 2026-08-05 09:59:41 -04:00
Owen a5e9339af9 Merge branch 'dev' into aig 2026-08-05 09:32:03 -04:00
miloschwartz 83e20c2dfa adjust placeholder ui for rules and targets 2026-08-04 17:36:00 -04:00
miloschwartz f85d41945a Merge branch 'aig' of https://github.com/fosrl/pangolin into aig 2026-08-04 17:27:10 -04:00
miloschwartz 149eb17b27 create basic public inference resource 2026-08-04 17:26:57 -04:00
Owen 72d469b19c Revert changes to traefik config to pull site resources 2026-08-04 17:17:51 -04:00
Owen 6526d7f225 Remove completions endpoints 2026-08-04 17:09:44 -04:00
Owen 36c0edc62e Use p host to pass the host header 2026-08-04 17:08:59 -04:00
Owen aad26b9ae4 move the ai gateway to its own server 2026-08-04 17:08:59 -04:00
Owen 80dcdfe251 change override to work for bother badger and ai gateway 2026-08-04 17:08:59 -04:00
miloschwartz 1696fc37a8 dont set port restrictions for inference 2026-08-04 17:04:29 -04:00
miloschwartz 7759d87835 add basic ui for private inference resource 2026-08-04 16:54:23 -04:00
miloschwartz c085de1e9e Merge branch 'aig' of https://github.com/fosrl/pangolin into aig
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 15:56:27 -04:00
miloschwartz e38359c74f add crud for adding providers and models to resources 2026-08-04 15:54:24 -04:00
Owen 2cfd7e867b delete the client peers from the exit nodes properly 2026-08-04 15:46:22 -04:00
Owen ec5a2b0cbe add peers to exit node 2026-08-04 15:46:22 -04:00
Owen 2bf426bc22 dont restrict to enterprise and dont link with providers yet 2026-08-04 15:46:21 -04:00
miloschwartz ed8545f8a2 update schema to support models and provider access on inferences resources and add budgets 2026-08-04 14:50:07 -04:00
Owen 9811492a0b send the aliases when connecting for the first time 2026-08-04 14:23:25 -04:00
Owen 973925b35d allow creating basic inference resource 2026-08-04 14:22:36 -04:00
Owen 0b30cfc341 Working on inference resource management 2026-08-04 11:48:36 -04:00
Owen a7e44944fb Use ip to find user 2026-08-04 10:43:49 -04:00
Owen 9b25ebd6cd remove token fallback 2026-08-04 10:19:22 -04:00
miloschwartz 33b775e263 clean up providers ui 2026-08-04 10:15:49 -04:00
miloschwartz b0edc6302e providers table, create, and edit first pass 2026-08-04 10:15:49 -04:00
Owen 1a644b131d Pull the session cookie properly 2026-08-04 10:15:49 -04:00
Owen f7689b7a5a Endpoints to update models on the resource 2026-08-04 10:15:49 -04:00
Owen 1073011a2a first pass of traefik -> basic gateway 2026-08-04 10:15:49 -04:00
miloschwartz 6fa0009ebf add targets and refactor endpoints 2026-08-04 10:15:49 -04:00
Owen 42c0abedb7 Add basic page to configure the alias 2026-08-04 10:15:48 -04:00
Owen 56e59a93f3 Handle the alias create and update special case exit nodes 2026-08-04 10:15:48 -04:00
Owen 9d581f3897 Fix types 2026-08-04 10:15:48 -04:00
miloschwartz e5ac6ec7cd add default providers and support overrides 2026-08-04 10:15:48 -04:00
miloschwartz 694fe98131 add crud for providers and models 2026-08-04 10:15:48 -04:00
miloschwartz 730078597e add basic schema 2026-08-04 10:15:48 -04:00
Owen 32ac8db803 Handle aliases when registering 2026-08-04 10:15:48 -04:00
Owen aad2ed2719 Get exit node every time 2026-08-04 10:15:48 -04:00
Owen a790fed297 sync the exit node connection 2026-08-04 10:15:48 -04:00
Owen deb2d5ce2a Update site resources to handle new inference 2026-08-04 10:15:48 -04:00
Owen 093097c619 send if we need to connect to the gerbil or not 2026-08-04 10:15:48 -04:00
Owen 2cdeb7c104 Add inference resource type 2026-08-04 10:15:47 -04:00
Owen 4dbb04bfb8 Handle the ping exit node request backward mode for olm and install handler 2026-08-04 10:15:47 -04:00
Owen fa8b921635 Fix postgres schema exit node subnet 2026-08-04 10:15:47 -04:00
Owen 33dd10c670 Rename subnet for clarity, pick subnet on client 2026-08-04 10:15:47 -04:00
Owen ba24e1c4f5 Add exit node selection to the clients 2026-08-04 10:15:47 -04:00
305 changed files with 36365 additions and 1385 deletions
+31
View File
@@ -0,0 +1,31 @@
---
name: crud-endpoints
description: Use whenever asked to add, create, or scaffold a CRUD endpoint, router, or entity in this repo's server (create/list/get/update/delete handlers, new `server/routers/<entity>/` or `server/private/routers/<entity>/` folder). Points to the established file layout, middleware, ActionsEnum, and route-registration conventions before writing any code.
---
Before writing any router/handler/middleware code for a new entity, read
`docs/crud-endpoints.md` in full. It documents, with real examples from
`server/routers/aiProvider/` (public) and `server/private/routers/alertRule/`
(enterprise-only), how this repo structures CRUD endpoints:
- Directory/file layout per entity (`index.ts`, `types.ts`, `validation.ts`,
one file per operation).
- The standard handler anatomy (zod parsing, OpenAPI registry, response
envelope, error handling).
- Where access-control middleware (`verify<Entity>Access`) lives and when
it's needed vs. plain `verifyOrgAccess`.
- How to wire up `ActionsEnum` entries, `verifyUserHasAction`, and
`logActionAudit`.
- Which of the four router files (`server/routers/external.ts`,
`server/routers/internal.ts`, `server/private/routers/external.ts`,
`server/private/routers/internal.ts`) to register routes in, and the
middleware chain template per HTTP verb.
- The repo's non-standard verb convention: **`PUT` = create, `POST` =
update** (backwards from typical REST) — don't "fix" this to standard
REST verbs, match the existing convention.
- The `#dynamic` import alias, for the rare case of a hook needing different
implementations in OSS vs. enterprise builds.
Follow that doc's checklist (§8) step by step rather than improvising a
structure. If the doc and the actual code in `aiProvider`/`alertRule` ever
disagree, trust the code and flag the doc as stale.
+4 -4
View File
@@ -1,5 +1,5 @@
# FROM node:24-slim AS base
FROM public.ecr.aws/docker/library/node:24-slim AS base
# FROM node:24.18.1-slim AS base
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS base
WORKDIR /app
@@ -32,8 +32,8 @@ FROM base AS builder
RUN npm ci --omit=dev
# FROM node:24-slim AS runner
FROM public.ecr.aws/docker/library/node:24-slim AS runner
# FROM node:24.18.1-slim AS runner
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS runner
WORKDIR /app
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine
FROM node:24.18.1-alpine
WORKDIR /app
+101 -1
View File
@@ -1,5 +1,5 @@
import { CommandModule } from "yargs";
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db";
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions, aiProviders, virtualApiKeys } from "@server/db";
import { encrypt, decrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq } from "drizzle-orm";
@@ -132,12 +132,16 @@ export const rotateServerSecret: CommandModule<
const certs = await db.select().from(certificates);
const streamingDestinations = await db.select().from(eventStreamingDestinations);
const webhookActions = await db.select().from(alertWebhookActions);
const providers = await db.select().from(aiProviders);
const virtualKeys = await db.select().from(virtualApiKeys);
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
console.log(`Found ${licenseKeys.length} license key(s)`);
console.log(`Found ${certs.length} certificate(s)`);
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
console.log(`Found ${providers.length} AI provider(s)`);
console.log(`Found ${virtualKeys.length} virtual API key(s)`);
// Prepare all decrypted and re-encrypted values
console.log("\nDecrypting and re-encrypting values...");
@@ -171,11 +175,24 @@ export const rotateServerSecret: CommandModule<
encryptedConfig: string;
};
type AiProviderUpdate = {
providerId: number;
encryptedApiKey: string | null;
encryptedHeaders: string | null;
};
type VirtualApiKeyUpdate = {
virtualApiKeyId: string;
encryptedToken: string;
};
const idpUpdates: IdpUpdate[] = [];
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
const certUpdates: CertUpdate[] = [];
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
const webhookActionUpdates: WebhookActionUpdate[] = [];
const aiProviderUpdates: AiProviderUpdate[] = [];
const virtualApiKeyUpdates: VirtualApiKeyUpdate[] = [];
// Process idpOidcConfig entries
for (const idpConfig of idpConfigs) {
@@ -306,6 +323,60 @@ export const rotateServerSecret: CommandModule<
}
}
// Process aiProviders entries (apiKey + headers)
for (const provider of providers) {
try {
if (!provider.apiKey && !provider.headers) {
continue;
}
const encryptedApiKey = provider.apiKey
? encrypt(decrypt(provider.apiKey, oldSecret), newSecret)
: null;
const encryptedHeaders = provider.headers
? encrypt(
decrypt(provider.headers, oldSecret),
newSecret
)
: null;
aiProviderUpdates.push({
providerId: provider.providerId,
encryptedApiKey,
encryptedHeaders
});
} catch (error) {
console.error(
`Error processing AI provider ${provider.providerId}:`,
error
);
throw error;
}
}
// Process virtualApiKeys entries (token)
for (const key of virtualKeys) {
try {
if (!key.token) {
continue;
}
virtualApiKeyUpdates.push({
virtualApiKeyId: key.virtualApiKeyId,
encryptedToken: encrypt(
decrypt(key.token, oldSecret),
newSecret
)
});
} catch (error) {
console.error(
`Error processing virtual API key ${key.virtualApiKeyId}:`,
error
);
throw error;
}
}
// Perform all database updates in a single transaction
console.log("\nUpdating database in transaction...");
await db.transaction(async (trx) => {
@@ -376,6 +447,32 @@ export const rotateServerSecret: CommandModule<
)
);
}
// Update AI provider entries
for (const update of aiProviderUpdates) {
await trx
.update(aiProviders)
.set({
apiKey: update.encryptedApiKey,
headers: update.encryptedHeaders
})
.where(eq(aiProviders.providerId, update.providerId));
}
// Update virtual API key entries
for (const update of virtualApiKeyUpdates) {
await trx
.update(virtualApiKeys)
.set({
token: update.encryptedToken
})
.where(
eq(
virtualApiKeys.virtualApiKeyId,
update.virtualApiKeyId
)
);
}
});
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
@@ -383,6 +480,8 @@ export const rotateServerSecret: CommandModule<
console.log(`Rotated ${certUpdates.length} certificate(s)`);
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
console.log(`Rotated ${aiProviderUpdates.length} AI provider(s)`);
console.log(`Rotated ${virtualApiKeyUpdates.length} virtual API key(s)`);
// Update config file with new secret
console.log("\nUpdating config file...");
@@ -402,6 +501,7 @@ export const rotateServerSecret: CommandModule<
console.log(` - Certificates: ${certUpdates.length}`);
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
console.log(` - AI providers: ${aiProviderUpdates.length}`);
console.log(
`\n IMPORTANT: Restart the server for the new secret to take effect.`
);
+285
View File
@@ -0,0 +1,285 @@
# AI Gateway Provider Selection
How the AI gateway picks which attached provider handles a request when an
inference resource has more than one AI provider.
**Code:**
- Route → capability binding: `server/routers/aiGateway/createAiGatewayRouter.ts`
- Request pipeline: `server/routers/aiGateway/pipeline.ts` (`selectProvider`)
- Tie-break scoring: `server/lib/aiProviderSelection.ts`
- Allow/block matching: `server/lib/aiModelKeyMatch.ts`
- Model catalog: `server/lib/aiModelCatalog.ts`
- Default capabilities per provider type: `server/lib/aiProviderDefaults.ts`
Overlapping model allows are permitted at save time. Selection happens at
request time. If the algorithm cannot confidently pick one provider, the
gateway returns `403` with an ambiguous-provider error.
## Selection Pipeline
Every gateway request runs through these steps in order. Each step narrows
the candidate set. Later steps only run when more than one provider remains.
```
1. Capability filter
2. Allow / block lists
3. Most specific allow pattern
4. Catalog ownership
5. Provider class preference
6. Ambiguous → error
```
### 1. Capability Filter
The incoming path selects a capability before any provider logic runs.
| Path | Capability |
|------|------------|
| `POST /v1/chat/completions` | `openai_chat` |
| `POST /v1/responses` | `openai_responses` |
| `POST /v1/messages` | `anthropic_messages` |
| Gemini / Vertex / Bedrock routes | their respective capability ids |
Only attached providers that advertise that capability stay in the candidate
set. Default capabilities do not overlap for native OpenAI vs Anthropic:
| Provider type | Default capabilities |
|---------------|----------------------|
| `openai` | `openai_chat`, `openai_responses` |
| `anthropic` | `anthropic_messages` |
| `openRouter` | `openai_chat` |
| `vercelAiGateway` | `openai_chat`, `openai_responses` |
| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages` |
| `custom` | whatever was configured |
### 2. Allow / Block Lists
For each remaining provider, the gateway resolves the effective allow and
block patterns:
- **`inherit`**: use the provider's own model lists
- **`select`**: use the resource-selected subset of those lists
A candidate is kept only if `isAllowedByLists(requestedModel, allows, blocks)`
passes:
1. At least one allow pattern must match
2. No block pattern may match
Patterns support `*` and `?` globs (`gpt-*`, `claude-3-5-sonnet-?`).
### 3. Most Specific Allow Pattern
Among providers that allow the model, keep those whose matching allow
pattern is most specific:
1. Exact keys beat patterns
2. Fewer wildcard characters win
3. Longer literal length wins
Example: `gpt-4o` beats `gpt-*` beats `*`.
### 4. Catalog Ownership
When specificity is tied (common with multiple `*` allows), score each
provider against the known model catalog:
| Score | Meaning |
|------:|---------|
| 2 | Typed provider whose catalog contains the model (`openai` → openai catalog, `anthropic` → anthropic, etc.) |
| 1 | Aggregator or custom (`openRouter`, `vercelAiGateway`, `custom`) and the model exists somewhere in the catalog |
| 0 | No ownership signal (typed catalog miss, or unknown model on aggregator/custom) |
Model id lookup tries the raw id, then a stripped `vendor/model` form
(e.g. `openai/gpt-4o` → also try `gpt-4o`).
Typed providers map to catalog providers as:
| Provider type | Catalog |
|---------------|---------|
| `openai` | `openai` |
| `anthropic` | `anthropic` |
| `googleGemini` | `gemini` |
| `vertexAi` | `vertex` |
| `bedrock` | `bedrock` |
| `microsoftFoundry` | `azure` |
| `openRouter` / `vercelAiGateway` / `custom` | none (aggregator/custom path) |
### 5. Provider Class Preference
If catalog ownership is still tied, prefer:
| Rank | Class |
|-----:|-------|
| 2 | Native typed provider (`openai`, `anthropic`, `googleGemini`, …) |
| 1 | Aggregator (`openRouter`, `vercelAiGateway`) |
| 0 | `custom` |
### 6. Ambiguous Error
If more than one distinct provider remains after all steps, the gateway
rejects the request:
```
Model "<id>" is ambiguous across multiple AI providers on this resource
```
Typical remaining ties: two OpenAI-type providers both with `*`, or two
customs advertising the same capability for an unknown model.
## Examples
Assume each provider below is attached and enabled on the same inference
resource.
### Example A: OpenAI + Anthropic, Both `*`
| Provider | Allow | Capabilities |
|----------|-------|--------------|
| OpenAI | `*` | `openai_chat`, `openai_responses` |
| Anthropic | `*` | `anthropic_messages` |
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
1. Capability → only OpenAI remains
2. Allow → OpenAI matches `*`
3. Result → **OpenAI**
Anthropic never reaches pattern or catalog scoring. Capability alone decides.
**Request:** `POST /v1/messages` with `model: "claude-3-5-sonnet-latest"`
1. Capability → only Anthropic remains
2. Result → **Anthropic**
### Example B: OpenAI + OpenRouter, Both `*`
| Provider | Allow | Capabilities |
|----------|-------|--------------|
| OpenAI | `*` | `openai_chat`, … |
| OpenRouter | `*` | `openai_chat` |
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
1. Capability → both remain (`openai_chat`)
2. Allow → both match `*`
3. Specificity → tie (`*` vs `*`)
4. Catalog → OpenAI scores `2` (owns `gpt-4o`); OpenRouter scores `1`
5. Result → **OpenAI**
### Example C: OpenRouter Only Serving a Claude Model Over OpenAI Chat
| Provider | Allow | Capabilities |
|----------|-------|--------------|
| OpenRouter | `*` | `openai_chat` |
**Request:** `POST /v1/chat/completions` with `model: "anthropic/claude-3.5-sonnet"`
1. Capability → OpenRouter remains
2. Only one candidate → **OpenRouter**
No tie-breaking needed.
### Example D: OpenAI (`gpt-*`) + OpenRouter (`*`)
| Provider | Allow |
|----------|-------|
| OpenAI | `gpt-*` |
| OpenRouter | `*` |
**Request:** `model: "gpt-4o"` on `openai_chat`
1. Capability → both
2. Allow → both match
3. Specificity → OpenAI's `gpt-*` beats OpenRouter's `*`
4. Result → **OpenAI**
Catalog scoring is not needed because specificity already unique'd the set.
### Example E: OpenAI + Anthropic With Overlapping Custom Capabilities
Someone grants Anthropic `openai_chat` as well (non-default).
| Provider | Allow | Capabilities |
|----------|-------|--------------|
| OpenAI | `*` | `openai_chat`, … |
| Anthropic | `*` | `anthropic_messages`, `openai_chat` |
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
1. Capability → both remain
2. Allow → both match `*`
3. Specificity → tie
4. Catalog → OpenAI `2`, Anthropic `0` (`gpt-4o` is not in the anthropic catalog)
5. Result → **OpenAI**
### Example F: Two Aggregators, Known Model
| Provider | Allow |
|----------|-------|
| OpenRouter | `*` |
| Vercel AI Gateway | `*` |
**Request:** `model: "gpt-4o"` on `openai_chat`
1. Capability → both
2. Allow / specificity → tie
3. Catalog → both score `1` (known model, no typed owner in the set)
4. Class → both aggregators (rank `1`) → still tied
5. Result → **ambiguous error**
Attach a native OpenAI provider (or narrow one aggregator's allow list) to
make this determinable.
### Example G: Two OpenAI Providers, Both `*`
| Provider | Type | Allow |
|----------|------|-------|
| OpenAI Prod | `openai` | `*` |
| OpenAI Staging | `openai` | `*` |
**Request:** `model: "gpt-4o"`
15 all leave both candidates (same capability, same specificity, same
catalog ownership, same class).
Result → **ambiguous error**
Disambiguate with different allow patterns, disable one attachment, or
split across resources.
### Example H: Unknown Model Across Native + Aggregator
| Provider | Allow |
|----------|-------|
| OpenAI | `*` |
| OpenRouter | `*` |
**Request:** `model: "my-fine-tune-v3"` (not in catalog)
1. Capability → both
2. Allow / specificity → tie
3. Catalog → both score `0` (typed miss + unknown aggregator model)
4. Class → OpenAI (`2`) beats OpenRouter (`1`)
5. Result → **OpenAI**
## Practical Guidance
- Native OpenAI + Anthropic with `*` is safe. Different default APIs never
collide.
- OpenAI + OpenRouter with `*` is usually fine for catalog-known OpenAI
models. Native wins.
- Prefer specific allow patterns (`gpt-4o`, `gpt-*`) when two providers share
a capability.
- Two providers of the same type both using `*` will stay ambiguous. Narrow
at least one allow list.
- Custom providers only win ties when no stronger native/aggregator signal
remains.
## Related Behavior
- **Saving providers on a resource does not reject overlapping allows.**
Collisions are resolved (or rejected) per request.
- Budgets, auth, and upstream URL / target routing run after a single
provider has been selected.
+347
View File
@@ -0,0 +1,347 @@
# How to build a CRUD endpoint in this repo
Reference for adding a new CRUD entity to the server. Based on two real
examples already in the codebase — read them side by side with this doc:
- **Public / open-source (Community Edition) pattern**: `server/routers/aiProvider/`
- **Enterprise-only pattern**: `server/private/routers/alertRule/`
The two are structurally identical. The only difference is *where the files
live* and *which router they get wired into*.
## 1. Decide: public or private?
- `server/routers/<entity>/` — ships in the open-source Community Edition.
Anyone running Pangolin gets this.
- `server/private/routers/<entity>/` — Enterprise/SaaS only. Gated behind
`verifyValidLicense` (and often `verifyValidSubscription(tierMatrix.x)`).
Every file here starts with the Fossorial Commercial License header block
(copy it verbatim from an existing private file).
Everything below applies to both — swap `@server/...` for `#private/...`
import paths and add license headers when building the private version.
## 2. Directory layout
One folder per entity, one file per operation, a barrel `index.ts`:
```
server/routers/<entity>/
index.ts # export * from each operation file + ./types
types.ts # response payload types + row->public mapper
validation.ts # zod schemas/refinements shared by create + update (optional)
create<Entity>.ts
list<Entities>.ts
get<Entity>.ts
update<Entity>.ts
delete<Entity>.ts
```
`index.ts` is a flat barrel:
```ts
export * from "./createAiProvider";
export * from "./listAiProviders";
export * from "./getAiProvider";
export * from "./updateAiProvider";
export * from "./deleteAiProvider";
export * from "./types";
```
## 3. Anatomy of a single handler
Every handler file (`create<Entity>.ts`, etc.) follows the same shape:
```ts
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { <table>, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
import type { GetXResponse } from "@server/routers/<entity>/types";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty() // or entityId: z.coerce.number().int().positive()
});
const bodySchema = z.strictObject({ /* ... */ }); // create/update only
registry.registerPath({
method: "get", // put | post | delete
path: "/org/{orgId}/x",
description: "...",
tags: [OpenAPITags.<Entity>],
request: { params: paramsSchema, /* body: {...} for write ops, query: for list */ },
responses: { 200: { description: "Successful response" } }
});
export async function getX(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString()));
}
// parse body too, if present, same pattern
// ...business logic against db...
if (!row) {
return next(createHttpError(HttpCode.NOT_FOUND, `X with ID ${id} not found`));
}
return response<GetXResponse>(res, {
data: { /* ... */ },
success: true,
error: false,
message: "X retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred"));
}
}
```
Rules to keep consistent with the rest of the codebase:
- `z.strictObject` for params/body — rejects unknown keys.
- Params parsed first, then body; each on its own `safeParse` + early
`next(createHttpError(...))` — never throw raw errors.
- Every handler registers itself with the OpenAPI `registry` even if nobody
reads the spec directly — it's how `/api/v1/docs` stays accurate.
- Catch-all `try/catch` at the bottom: `logger.error(error)` +
generic `500` message. Never leak internal error details to the client.
- Use `response<T>(res, { data, success, error, message, status })` from
`@server/lib/response` for every response, success or otherwise (errors go
through `next(createHttpError(...))` instead, not through `response`).
- If the route already ran an access-control middleware that fetched the row
(see §5), reuse it instead of re-querying:
`req.aiProvider && req.aiProvider.providerId === providerId ? [req.aiProvider] : await db.select()...`
### List handler specifics
Pagination is a fixed shape (`page`, `pageSize`, optional `query` for
search). See `listAiProviders.ts`:
```ts
const listSchema = z.object({
pageSize: z.coerce.number<string>().int().positive().optional().catch(20).default(20),
page: z.coerce.number<string>().int().min(0).optional().catch(1).default(1),
query: z.string().optional()
});
```
Run the count query and the page query in `Promise.all`, and return
`PaginatedResponse<{ items: T[] }>` (`@server/types/Pagination`) with
`{ total, pageSize, page }`.
### types.ts specifics
- Define one response type per operation: `List<Entities>Response`,
`Get<Entity>Response`, `CreateOrEdit<Entity>Response` (create and update
commonly share a response shape).
- If the raw DB row needs to be shaped for clients (decrypting secrets,
parsing a serialized column, hiding a column), put a `toPublic<Entity>()`
mapper here — see `toPublicAiProvider` for the pattern of stripping
`apiKey`/serialized columns and re-adding decrypted/parsed versions.
### validation.ts specifics
Only needed when create and update share non-trivial zod pieces (enums,
`superRefine` cross-field rules). Export the raw schemas (`z.enum([...])`)
and refinement functions, and import them into both `createX.ts` and
`updateX.ts` — see `aiProvider/validation.ts`'s
`refineProviderUpstreamFields`.
## 4. Wire up an access-control middleware (for id-scoped routes)
For routes scoped to a single row (`/x/:xId`, as opposed to
`/org/:orgId/x` create/list), add a `verify<Entity>Access` middleware in
`server/middlewares/` (or `server/private/middlewares/` for enterprise-only
entities) and export it from that directory's `index.ts`.
Pattern (`verifyAiProviderAccess.ts`):
1. Read the id param, `Number.parseInt`/validate it.
2. Load the row by id.
3. `404` if it doesn't exist.
4. Resolve the row's `orgId`, then check/attach `req.userOrg` (query
`userOrgs` if not already on the request), `403` if the user isn't in
that org.
5. Run `checkOrgAccessPolicy` if `req.orgPolicyAllowed` hasn't been resolved
yet.
6. Set `req.userOrgId`, `req.userOrgRoleIds`, and stash the row on the
request (e.g. `req.aiProvider = provider`) so downstream handlers and
`verifyUserHasAction` don't have to refetch it.
Org-scoped create/list routes (`/org/:orgId/x`) don't need a bespoke
middleware — they use the existing generic `verifyOrgAccess` from
`@server/middlewares`.
## 5. Register an action + permission check
Add one `ActionsEnum` entry per operation in `server/auth/actions.ts`,
grouped near the entity's other actions, named `create<Entity>`,
`get<Entity>`, `update<Entity>`, `delete<Entity>`, `list<Entities>`:
```ts
createAiProvider = "createAiProvider",
deleteAiProvider = "deleteAiProvider",
getAiProvider = "getAiProvider",
listAiProviders = "listAiProviders",
updateAiProvider = "updateAiProvider",
```
Every route uses `verifyUserHasAction(ActionsEnum.x)` to check the caller's
role/permissions for that action, and mutating routes (create/update/delete)
follow it with `logActionAudit(ActionsEnum.x)` to record the action in the
audit log.
## 6. Register the routes
There are four router files; which one(s) you touch depends on public vs.
private and user-facing vs. service-to-service:
| File | Purpose |
|---|---|
| `server/routers/external.ts` | Public, user-facing API. Exports `authenticated`, `unauthenticated`, `authRouter` Express routers. |
| `server/routers/internal.ts` | Public, internal service-to-service API (gerbil, badger, traefik-config) — no user auth, exports `internalRouter`. |
| `server/private/routers/external.ts` | Enterprise-only, user-facing. Imports `authenticated`/`unauthenticated`/`authRouter` **from the public `external.ts`** and re-exports them, then adds more routes on top. |
| `server/private/routers/internal.ts` | Enterprise-only, service-to-service. Same re-export trick with `internalRouter`. |
Private router files always start:
```ts
import {
unauthenticated as ua,
authenticated as a,
authRouter as aa
} from "@server/routers/external";
export const authenticated = a;
export const unauthenticated = ua;
export const authRouter = aa;
```
...and then call `authenticated.get/put/post/delete(...)` to bolt on
additional, enterprise-only routes on the *same* router instances the public
build uses. This is why the private build has strictly more routes than the
public build, not a divergent copy.
### Route registration order (mutating vs read)
Standard middleware chain per verb, using `alertRule`'s registrations as the
template:
```ts
// Create — org-scoped, no row exists yet
authenticated.put(
"/org/:orgId/x",
verifyValidLicense, // private/enterprise routes only
verifyOrgAccess,
verifyLimits, // if the entity counts against a plan limit
verifyUserHasAction(ActionsEnum.createX),
logActionAudit(ActionsEnum.createX),
x.createX
);
// Update — row-scoped
authenticated.post(
"/org/:orgId/x/:xId", // or "/x/:xId" if id is globally unique
verifyValidLicense,
verifyOrgAccess, // or verifyXAccess if globally-keyed
verifyUserHasAction(ActionsEnum.updateX),
logActionAudit(ActionsEnum.updateX),
x.updateX
);
// Delete — row-scoped
authenticated.delete(
"/org/:orgId/x/:xId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteX),
logActionAudit(ActionsEnum.deleteX),
x.deleteX
);
// List — org-scoped, read-only, no audit log
authenticated.get(
"/org/:orgId/xs",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listXs),
x.listXs
);
// Get one — row-scoped, read-only, no audit log
authenticated.get(
"/org/:orgId/x/:xId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getX),
x.getX
);
```
Notes:
- HTTP verbs: `PUT` = create, `POST` = update, `GET` = read, `DELETE` =
delete. This repo does not use `PATCH` for entity updates (site
provisioning keys are the one exception, using `PATCH`).
- `verifyValidLicense` is only needed on private/enterprise routes; public
OSS routes skip it.
- Use `verifyValidSubscription(tierMatrix.someFeature)` right after
`verifyValidLicense` when a feature is gated to specific SaaS tiers (see
`tierMatrix` usages in `server/private/routers/external.ts`).
- `verifyLimits` goes on create routes for entities that count against a
plan/seat limit.
- For entities keyed by a globally-unique id (not nested under `/org/:orgId`),
use the dedicated `verify<Entity>Access` middleware from §4 instead of
`verifyOrgAccess` on the row-scoped routes (see how `/ai-provider/:providerId`
uses `verifyAiProviderAccess`, while `/org/:orgId/ai-provider` create/list
use plain `verifyOrgAccess`).
- Read-only routes (`get`, `list`) skip `logActionAudit` — only mutations are
audited.
- `internal*.ts` routes are for trusted internal callers (gerbil/badger
sidecars) and generally skip user-facing auth entirely, using
`verifySessionUserMiddleware` / `verifyUserFromResourceSessionMiddleware`
instead of `verifyOrgAccess`/`verifyUserHasAction`. CRUD entities almost
never need internal router entries — only add one if a sidecar process
needs direct access to the resource.
## 7. The `#dynamic` alias (advanced — most CRUD work can ignore this)
Some middleware (e.g. `logActionAudit`) needs a real implementation in the
enterprise/SaaS build but a no-op stub in the open-source build, while
being imported by identical code in `server/routers/external.ts` in both
builds. That's done via the `#dynamic/*` import alias, which
`tsconfig.oss.json` points at `./server/*` and `tsconfig.enterprise.json` /
`tsconfig.saas.json` point at `./server/private/*`. You only need this
pattern if you're adding a genuinely dual-implementation hook; a normal
private-only CRUD entity (like `alertRule`) never touches `#dynamic` — it
just lives entirely under `server/private/` and is imported with `#private/*`
directly from `server/private/routers/external.ts`.
## 8. Checklist for a new entity
1. Add the DB table to `server/db/pg/schema/schema.ts` (and sqlite schema if
applicable).
2. Add `ActionsEnum` entries in `server/auth/actions.ts`.
3. Create `server/routers/<entity>/` (or `server/private/routers/<entity>/`):
`types.ts`, optional `validation.ts`, one file per operation, `index.ts`
barrel.
4. If routes are row-scoped by a global id, add
`verify<Entity>Access.ts` to `server/middlewares/` or
`server/private/middlewares/`, and export it from that directory's
`index.ts`.
5. Wire routes into `external.ts` (public or private) following the verb/
middleware table in §6. Add to `internal.ts` only if a sidecar needs
direct access.
6. Add license header block to every new file if it's under `server/private/`.
+435 -1
View File
@@ -43,6 +43,8 @@
"inviteLoginUser": "Please make sure you're logged in as the correct user.",
"inviteErrorNoUser": "We're sorry, but it looks like the invite you're trying to access is not for a user that exists.",
"inviteCreateUser": "Please create an account first.",
"inviteErrorOidcNotAllowed": "Invites can only be accepted by internal accounts. Sign out and log in with your password for this email.",
"inviteLoginInternalOnly": "Invites require an internal account with a password. Create an account or sign in with your password.",
"goHome": "Go Home",
"inviteLogInOtherUser": "Log In as a Different User",
"createAnAccount": "Create an Account",
@@ -855,12 +857,16 @@
"authMethodsSave": "Save Settings",
"policyAuthStackTitle": "Authentication",
"policyAuthStackDescription": "Control which authentication methods are required to access this resource",
"policyAuthInferenceStackDescription": "Choose which users and roles can authenticate to this AI gateway",
"policyAuthOrLogicTitle": "Multiple authentication methods active",
"policyAuthOrLogicBanner": "Visitors may authenticate using any one of the active methods below. They do not need to complete all of them.",
"policyAuthMethodActive": "Active",
"policyAuthMethodOff": "Off",
"policyAuthSsoTitle": "Platform SSO",
"policyAuthSsoDescription": "Require sign-in through your organization's identity provider",
"policyAuthInferenceSsoDescription": "Selected users and roles can authenticate to the gateway using their identity API key",
"policyAuthInferenceIdentityKeyHelp": "Every user already has an identity API key, so you only need to create virtual API keys for non-user clients or shared access. Users can retrieve their key by signing in with their identity provider at <resourceLink></resourceLink>, where it will be shown after login.",
"policyAuthInferenceIdentityKeyHelpNoUrl": "Every user already has an identity API key, so you only need to create virtual API keys for non-user clients or shared access. Users can retrieve their key by signing in with their identity provider at this resource's URL, where it will be shown after login.",
"policyAuthSsoSummary": "{idp} · {users} users, {roles} roles",
"policyAuthSsoDefaultIdp": "Default provider",
"policyAuthAddDefaultIdentityProvider": "Add Default Identity Provider",
@@ -1079,6 +1085,8 @@
"accessRoleErrorNewRequired": "New role is required",
"accessRoleErrorRemove": "Failed to remove role",
"accessRoleErrorRemoveDescription": "An error occurred while removing the role.",
"accessRoleInferenceBudget": "Inference Budget",
"accessRoleInferenceBudgetDescription": "Configure how members of this role restrict AI usage based on spending or token limits",
"accessRoleName": "Role Name",
"accessRoleQuestionRemove": "You're about to delete the `{name}` role. You cannot undo this action.",
"accessRoleRemove": "Remove Role",
@@ -1424,6 +1432,21 @@
"actionDeleteSite": "Delete Site",
"actionGetSite": "Get Site",
"actionListSites": "List Sites",
"actionCreateAiProvider": "Create AI Provider",
"actionDeleteAiProvider": "Delete AI Provider",
"actionGetAiProvider": "Get AI Provider",
"actionListAiProviders": "List AI Providers",
"actionUpdateAiProvider": "Update AI Provider",
"actionCreateAiModel": "Create AI Model",
"actionDeleteAiModel": "Delete AI Model",
"actionGetAiModel": "Get AI Model",
"actionListAiModels": "List AI Models",
"actionUpdateAiModel": "Update AI Model",
"actionCreateVirtualApiKey": "Create Virtual API Key",
"actionDeleteVirtualApiKey": "Delete Virtual API Key",
"actionGetVirtualApiKey": "Get Virtual API Key",
"actionListVirtualApiKeys": "List Virtual API Keys",
"actionUpdateVirtualApiKey": "Update Virtual API Key",
"actionApplyBlueprint": "Apply Blueprint",
"actionListBlueprints": "List Blueprints",
"actionGetBlueprint": "Get Blueprint",
@@ -1449,8 +1472,11 @@
"actionSetResourcePincode": "Set Resource Pincode",
"actionSetResourceEmailWhitelist": "Set Resource Email Whitelist",
"actionGetResourceEmailWhitelist": "Get Resource Email Whitelist",
"actionListResourcePolicies": "List Resource Policies",
"actionCreateResourcePolicy": "Create Resource Policy",
"actionGetResourcePolicy": "Get Resource Policy",
"actionUpdateResourcePolicy": "Update Resource Policy",
"actionDeleteResourcePolicy": "Delete Resource Policy",
"actionSetResourcePolicyUsers": "Set Resource Policy Users",
"actionSetResourcePolicyRoles": "Set Resource Policy Roles",
"actionSetResourcePolicyPassword": "Set Resource Policy Password",
@@ -1622,6 +1648,363 @@
"sidebarInvitations": "Invitations",
"sidebarRoles": "Roles",
"sidebarShareableLinks": "Shareable Links",
"sidebarAiGateway": "AI Gateway",
"sidebarAiProviders": "Providers",
"commandAiProviders": "AI Providers",
"sidebarVirtualApiKeys": "Virtual API Keys",
"sidebarMyApiKeys": "Your API Keys",
"sidebarAccount": "Launcher",
"commandVirtualApiKeys": "Virtual API Keys",
"virtualApiKeysTitle": "Manage Virtual API Keys",
"virtualApiKeysDescription": "Create and manage manual API keys for AI Gateway access to public inference resources",
"virtualApiKeys": "Virtual API Keys",
"virtualApiKeysSearch": "Search keys...",
"virtualApiKeysCreate": "Create Virtual API Key",
"virtualApiKeysCreateDescription": "Mint a manual key that can call public inference resources in this organization",
"virtualApiKeysCreateButton": "Create Key",
"virtualApiKeysEmpty": "No virtual API keys yet",
"virtualApiKeysName": "Name",
"virtualApiKeysDescriptionOptional": "Description (optional)",
"virtualApiKeysAssociateUserOptional": "Associate User (optional)",
"virtualApiKeysAssociateUserDescription": "Associate this key with a user to track usage.",
"virtualApiKeysAllResources": "All public inference resources",
"virtualApiKeysAllResourcesDescription": "Allow this key to access every public inference resource in the organization",
"virtualApiKeysSelectResources": "Public Inference Resources",
"virtualApiKeysSelectResourcesPlaceholder": "Select resources",
"virtualApiKeysSelectResourcesDescription": "Choose which public inference resources this key can access",
"virtualApiKeysNoResources": "No resources",
"virtualApiKeysSecret": "Key",
"virtualApiKeysCopyKey": "Copy this key. You can view it again later from the table or when editing.",
"virtualApiKeysViewSecret": "View Secret",
"virtualApiKeysViewSecretTitle": "Virtual API Key Secret",
"virtualApiKeysViewSecretDescription": "This secret grants access to the public inference resources assigned to this key",
"virtualApiKeysEdit": "Edit Virtual API Key",
"virtualApiKeysEditDescription": "Update the associated user and public inference resource access for this key",
"virtualApiKeysSaveButton": "Save Changes",
"virtualApiKeysSelectResourcesRequired": "Select at least one public inference resource, or enable all public inference resources",
"virtualApiKeysUpdated": "Virtual API key updated",
"virtualApiKeysUpdatedDescription": "The virtual API key has been updated",
"virtualApiKeysErrorUpdate": "Error updating virtual API key",
"virtualApiKeysErrorUpdateDescription": "Failed to update virtual API key",
"virtualApiKeysErrorCreate": "Error creating virtual API key",
"virtualApiKeysErrorCreateDescription": "Failed to create virtual API key",
"virtualApiKeysErrorDelete": "Error deleting virtual API key",
"virtualApiKeysErrorDeleteMessage": "Failed to delete virtual API key",
"virtualApiKeysDeleted": "Virtual API key deleted",
"virtualApiKeysDeletedDescription": "The virtual API key has been deleted",
"virtualApiKeysDelete": "Delete Virtual API Key",
"virtualApiKeysDeleteConfirm": "Delete Key",
"virtualApiKeysQuestionRemove": "Are you sure you want to delete this virtual API key?",
"virtualApiKeysMessageRemove": "Clients using this key will lose access immediately.",
"virtualApiKeysErrorFetchSecret": "Error loading secret",
"virtualApiKeysErrorFetchSecretDescription": "Failed to load the virtual API key secret",
"virtualApiKeysFilterUnassigned": "Unassigned",
"virtualApiKeysInferenceBudget": "Inference Budget",
"virtualApiKeysInferenceBudgetDescription": "Configure how this key restricts AI usage based on spending or token limits",
"myVirtualApiKeysTitle": "Your API Keys",
"myVirtualApiKeysDescription": "View your identity key and any virtual API keys attributed to you in this organization",
"myVirtualApiKeysResourceTitle": "Your API Keys for {resourceName}",
"myVirtualApiKeysResourceDescription": "View your identity key and virtual API keys attributed to you that can access {resourceName}",
"myVirtualApiKeysIdentityTitle": "Identity Key",
"myVirtualApiKeysIdentityHeadline": "Your Personal API Key",
"myVirtualApiKeysIdentityDescription": "Your personal key for this organization. It is unique to your account and used to identify you when calling AI Gateway resources.",
"myVirtualApiKeysIdentityResourceHeadline": "Your Personal API Key for {resourceName}",
"myVirtualApiKeysIdentityResourceDescription": "Your personal key for this organization. Use it to call {resourceName}.",
"myVirtualApiKeysManualTitle": "Attributed Keys",
"myVirtualApiKeysManualDescription": "Manual virtual API keys an admin associated with your account",
"myVirtualApiKeysManualResourceDescription": "Manual virtual API keys associated with your account that can access {resourceName}",
"myVirtualApiKeysManualEmpty": "No attributed keys yet",
"myVirtualApiKeysKindUser": "Identity",
"myVirtualApiKeysKindManual": "Manual",
"myVirtualApiKeysUnnamed": "Unnamed key",
"myVirtualApiKeysRevealSecret": "Reveal Secret",
"myVirtualApiKeysViewSecretDescription": "This secret authenticates you to AI Gateway resources",
"aiProvidersTitle": "AI Providers",
"aiProvidersDescription": "Connect model providers for AI workloads in this organization",
"aiProvidersAdd": "Add Provider",
"aiProvidersSearch": "Search providers...",
"aiProvidersEmpty": "No AI providers yet",
"aiProviderCreate": "Create AI Provider",
"aiProviderCreateDescription": "Add a model provider for this organization",
"aiProviderSeeAll": "See All Providers",
"aiProviderSetting": "Provider Settings for {providerName}",
"aiProviderSettingDescription": "Configure this AI provider",
"aiProviderGeneral": "General",
"aiProviderGeneralDescription": "Basic settings for this provider",
"aiProviderConfiguration": "Configuration",
"aiProviderConfigurationDescription": "Network routing and authentication for this provider",
"aiProviderNetworkSettings": "Network Settings",
"aiProviderNetworkSettingsDescription": "Choose how traffic reaches this provider",
"aiProviderAuthSettings": "Authentication",
"aiProviderAuthSettingsDescription": "Configure how this provider authenticates requests to its upstream URL",
"aiProviderBudgetSettings": "Budget",
"aiProviderBudgetSettingsDescription": "Configure how this provider restricts usage based on spending or token limits",
"aiBudgetAdd": "Add Budget",
"aiBudgetEmpty": "No budgets configured yet. Click Add Budget to set a spending or token limit.",
"aiBudgetUnit": "Spend Type",
"aiBudgetPeriod": "Reset Period",
"aiBudgetAmount": "Maximum Spend",
"aiBudgetAmountPlaceholder": "Maximum spend",
"aiBudgetPeriodHourly": "Hourly",
"aiBudgetPeriodDaily": "Daily",
"aiBudgetPeriodWeekly": "Weekly",
"aiBudgetPeriodMonthly": "Monthly",
"aiBudgetPeriodYearly": "Yearly",
"aiBudgetPeriodLifetime": "Lifetime",
"aiBudgetUnitUsd": "USD",
"aiBudgetUnitTokens": "Tokens",
"aiBudgetConflictError": "A budget for this reset period and spend type already exists",
"aiBudgetInvalidAmountError": "Enter a maximum spend greater than 0",
"aiBudgetUpdated": "Budgets updated",
"aiBudgetErrorSave": "Failed to update budgets",
"aiProviderType": "Provider Type",
"aiProviderTypeSearch": "Search providers...",
"aiProviderTypeNotFound": "No provider type found",
"aiProviderTypeOpenai": "OpenAI",
"aiProviderTypeAnthropic": "Anthropic",
"aiProviderTypeGoogleGemini": "Google Gemini",
"aiProviderTypeVertexAi": "Vertex AI",
"aiProviderTypeBedrock": "Amazon Bedrock",
"aiProviderTypeMicrosoftFoundry": "Microsoft Foundry",
"aiProviderTypeOpenRouter": "OpenRouter",
"aiProviderTypeVercelAiGateway": "Vercel AI Gateway",
"aiProviderTypeCustom": "Custom",
"aiProviderTypeOpenaiDescription": "OpenAI API with default upstream URL",
"aiProviderTypeAnthropicDescription": "Anthropic API with default upstream URL",
"aiProviderTypeGoogleGeminiDescription": "Google Gemini generateContent API",
"aiProviderTypeVertexAiDescription": "Google Vertex AI; upstream URL required",
"aiProviderTypeBedrockDescription": "Amazon Bedrock Runtime",
"aiProviderTypeMicrosoftFoundryDescription": "Microsoft Foundry; upstream URL required",
"aiProviderTypeOpenRouterDescription": "OpenRouter API",
"aiProviderTypeVercelAiGatewayDescription": "Vercel AI Gateway",
"aiProviderTypeCustomDescription": "Bring your own OpenAI-compatible endpoint or route via Pangolin targets",
"aiProviderUpstreamUrl": "Upstream URL",
"aiProviderUpstreamUrlDescription": "Base URL for the provider API",
"aiProviderUpstreamUrlOptionalDescription": "Leave blank to use the default upstream URL for this provider",
"aiProviderEffectiveUpstreamUrl": "Effective Upstream URL",
"aiProviderApiKey": "API Key",
"aiProviderApiKeyDescription": "API key used to authenticate requests to this provider",
"aiProviderCustomHeadersDescription": "Headers sent on every request to this provider. Newline separated: Header-Name: value",
"aiProviderApiKeyLastChars": "API Key",
"aiProviderAuthType": "Auth Type",
"aiProviderAuthTypeSearch": "Search auth types...",
"aiProviderAuthTypeNotFound": "No auth type found",
"aiProviderAuthTypeBearer": "Bearer",
"aiProviderAuthTypeBearerDescription": "Authorization: Bearer key. Used by OpenAI and most providers",
"aiProviderAuthTypeXApiKey": "x-api-key",
"aiProviderAuthTypeXApiKeyDescription": "x-api-key header. Used by Anthropic",
"aiProviderAuthTypeXGoogApiKey": "x-goog-api-key",
"aiProviderAuthTypeXGoogApiKeyDescription": "x-goog-api-key header. Used by Google Gemini",
"aiProviderAuthTypeHec": "Splunk HEC",
"aiProviderAuthTypeHecDescription": "Authorization: Splunk key. Used by Splunk HTTP Event Collector",
"aiProviderAuthTypeCfAigAuthorization": "Cloudflare AI Gateway",
"aiProviderAuthTypeCfAigAuthorizationDescription": "cf-aig-authorization: Bearer key. Used by Cloudflare AI Gateway",
"aiProviderAuthTypeNone": "No Auth",
"aiProviderAuthTypePassthrough": "Passthrough",
"aiProviderAuthTypeDescription": "How the upstream API authenticates requests",
"aiProviderAuthTypePassthroughDescription": "Forward the caller's API key headers to the upstream",
"aiProviderAuthTypeNoneDescription": "Do not send authentication headers to the upstream",
"aiProviderRoutingMode": "Routing Mode",
"aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to HTTP targets on your sites",
"aiProviderRoutingModeUrl": "Upstream URL",
"aiProviderRoutingModeUrlDescription": "Call a public or private API base URL",
"aiProviderRoutingModeTarget": "Site Targets",
"aiProviderRoutingModeTargetDescription": "Route through targets on your sites",
"aiProviderRoutingModeTargetNote": "After creating this provider, configure site targets on the Network Settings tab.",
"aiProviderTargetNoOne": "This provider doesn't have any targets. Add a target to route requests through your sites.",
"aiProviderSkipTlsVerification": "Skip TLS Verification",
"aiProviderSkipTlsVerificationDescription": "Disable TLS certificate verification for the upstream connection",
"aiProviderBudget": "Budget",
"aiProviderBudgetDescription": "Optional spending or token budget for this provider",
"aiProviderBudgetAmount": "Budget Amount",
"aiProviderBudgetUnit": "Budget Unit",
"aiProviderBudgetUnitUsd": "USD",
"aiProviderBudgetUnitTokens": "Tokens",
"aiProviderEnabled": "Enabled",
"aiProviderEnabledDescription": "Disable to stop using this provider without deleting it",
"aiProviderErrorCreate": "Failed to create AI provider",
"aiProviderErrorUpdate": "Failed to update AI provider",
"aiProviderErrorDelete": "Failed to delete AI provider",
"aiProviderErrorLoad": "Failed to load AI provider",
"aiProviderErrorUpstreamUrlInvalid": "Enter a valid upstream URL",
"aiProviderErrorUpstreamUrlRequired": "Upstream URL is required for this provider",
"aiProviderErrorAuthTypeRequired": "Auth type is required",
"aiProviderErrorApiKeyRequired": "API key is required",
"aiProviderErrorRoutingModeTarget": "Site targets routing is only available for custom providers",
"aiProviderErrorCapabilitiesRequired": "Select at least one API capability",
"aiProviderCapabilities": "API Capabilities",
"aiProviderCapabilitiesDescription": "Select which API formats this provider can handle. Known providers start with recommended defaults.",
"aiProviderCapabilitiesCustomDescription": "Select which API formats this custom provider can handle",
"aiProviderCapabilitiesSelect": "Select capabilities",
"aiProviderCapabilitiesEmpty": "No capabilities found",
"aiProviderCapabilitiesSearch": "Search capabilities...",
"aiCapabilityOpenaiChat": "OpenAI Chat Completions",
"aiCapabilityOpenaiChatDescription": "Supports /v1/chat/completions",
"aiCapabilityOpenaiResponses": "OpenAI Responses",
"aiCapabilityOpenaiResponsesDescription": "Supports /v1/responses",
"aiCapabilityAnthropicMessages": "Anthropic Messages",
"aiCapabilityAnthropicMessagesDescription": "Supports /v1/messages",
"aiCapabilityGeminiGenerateContent": "Gemini Generate Content",
"aiCapabilityGeminiGenerateContentDescription": "Supports the direct Gemini API",
"aiCapabilityBedrockModelInvoke": "Bedrock Model Invoke",
"aiCapabilityBedrockModelInvokeDescription": "Supports Amazon Bedrock InvokeModel",
"aiCapabilityGoogleGenerateContent": "Vertex Generate Content",
"aiCapabilityGoogleGenerateContentDescription": "Supports Vertex AI Gemini format",
"aiCapabilityGoogleRawPredict": "Vertex Raw Predict",
"aiCapabilityGoogleRawPredictDescription": "Supports Vertex AI rawPredict for Anthropic models",
"aiCapabilityBedrockConverse": "Bedrock Converse",
"aiCapabilityBedrockConverseDescription": "Supports Amazon Bedrock Converse API",
"aiProviderCreated": "AI provider created",
"aiProviderUpdated": "AI provider updated",
"aiProviderDeleted": "AI provider deleted",
"aiProviderDelete": "Delete Provider",
"aiProviderDeleteConfirm": "Delete Provider",
"aiProviderQuestionRemove": "Are you sure you want to delete this AI provider?",
"aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.",
"aiProviderErrorNoUpdate": "AI provider is not available to update",
"aiProviderModels": "Models",
"aiProviderModelsDescription": "Define allow and block lists for this provider. Requests must match an allow entry and must not match a block entry.",
"aiProviderModelsPlaceholder": "Search models or type a custom key",
"aiProviderModelsAllow": "Allow List",
"aiProviderModelsAllowDescription": "Models that may be used through this provider. Empty means deny all.",
"aiProviderModelsAllowPlaceholder": "Enter model key",
"aiProviderModelsAllowEmpty": "No models allowed. All requests will be denied.",
"aiProviderModelsBlock": "Block List",
"aiProviderModelsBlockDescription": "Models to deny even if they match an allow entry.",
"aiProviderModelsBlockPlaceholder": "Enter model key",
"aiProviderModelsBlockEmpty": "No blocked models.",
"aiProviderModelsAdd": "Add Models",
"aiProviderModelsClearAll": "Clear All",
"aiProviderModelsAddCustom": "Add \"{key}\"",
"aiProviderModelsAddCustomHint": "Press Enter to add this custom model key.",
"aiProviderModelsAddBulk": "Add {count} custom keys",
"aiProviderModelsAddBulkHint": "Press Enter to add {count} custom keys.",
"aiProviderModelsAddOne": "Add {key} now",
"aiProviderModelsAddSelected": "Add Selected",
"aiProviderModelsSelectedCount": "{count} selected",
"aiProviderModelsSelectAll": "Select all",
"aiProviderModelsClearSelected": "Clear",
"aiProviderModelsBulkHint": "Select known models or type a custom key.",
"aiProviderModelsCatalogEmpty": "No matching catalog models.",
"aiProviderModelsCatalogHeading": "Known Models",
"aiProviderModelsAllLabel": "All models",
"aiProviderModelsAllPatternHint": "Wildcard: *",
"aiProviderModelsAddAllAllow": "Allow all models",
"aiProviderModelsAddAllBlock": "Block all models",
"aiProviderModelsAddAllDescription": "Uses the * wildcard so every model key matches.",
"aiProviderModelsViewMore": "View more ({count})",
"aiProviderModelsViewLess": "View less",
"aiProviderModelsRemove": "Remove model",
"aiProviderModelsEditHint": "Click to edit model settings",
"aiProviderModelsSourceCatalog": "Known catalog model",
"aiProviderModelsSourceCustom": "Custom model key",
"aiProviderModelsSourcePattern": "Wildcard pattern",
"aiProviderModelsSourceAll": "Matches every model key",
"aiProviderModelsBudgetConfigured": "Budget configured",
"aiProviderModelsEditTitle": "Edit Model",
"aiProviderModelsEditDescription": "Update the model key or configure its inference budget.",
"aiProviderModelsBudgetTab": "Inference Budget",
"aiProviderModelsBudgetDescription": "Configure how this model restricts usage based on spending or token limits",
"aiProviderModelsBudgetUnsaved": "Save this model first to configure its inference budget.",
"aiProviderModelsKeyLabel": "Model Key",
"aiProviderModelsKeyRequired": "Enter a model key",
"aiProviderModelsKeyDuplicate": "This model key is already on a list",
"aiProviderModelsOverlapError": "These patterns cannot be on both lists: {keys}",
"aiProviderModelsUpdated": "Models updated",
"aiProviderModelsErrorUpdate": "Failed to update models",
"aiResourceProviders": "Providers",
"aiResourceProvidersDescription": "Choose which AI providers this inference resource can use",
"aiResourceProvidersHelp": "Attach providers and choose inherit (use each provider's lists) or select (pick an allow list for this resource). Allow patterns that conflict across attached providers are not allowed.",
"aiResourceProvidersSelect": "Select providers",
"aiResourceProvidersEmpty": "No AI providers found",
"aiResourceProvidersNoneAttached": "No providers attached yet.",
"aiResourceProvidersAdd": "Add provider",
"aiResourceProvidersRemove": "Remove provider",
"aiResourceProviderToggleEnabled": "Enable or disable this provider on the resource",
"aiResourceProviderDisabled": "Disabled",
"aiResourceProvidersUpdated": "Providers updated",
"aiResourceProvidersErrorUpdate": "Failed to update providers",
"aiResourceProviderEditDescription": "Choose how this provider's models are exposed on this resource.",
"viewProviderSettings": "View Provider Settings",
"aiResourceProviderMode": "Access mode",
"aiResourceProviderModeInherit": "Inherit",
"aiResourceProviderModeSelect": "Select",
"aiResourceProviderModeSelectSummary": "Select · {count} models",
"aiResourceProviderModeInheritHelp": "Use this provider's allow and block lists as configured on the provider.",
"aiResourceProviderModeSelectHelp": "Choose a subset of this provider's allow-list models for this resource.",
"aiResourceProviderAllowModels": "Allow list",
"aiResourceProviderAllowModelsSelect": "Select models",
"aiResourceProviderAllowModelsSearch": "Search models...",
"aiResourceProviderAllowModelsEmpty": "No models found",
"aiResourceProviderAllowModelsHelp": "Only models from this provider's allow list can be selected.",
"aiResourceAliasRequired": "Alias is required for inference resources",
"aiResourceDomainConfiguration": "Domain configuration",
"aiResourceDomainConfigurationDescription": "Choose the domain clients will use to reach this inference resource.",
"aiUsageAnalyticsTitle": "AI Usage Analytics",
"aiUsageAnalyticsDescription": "Analyze AI gateway cost, token usage, and activity across providers, resources, roles, and users",
"aiUsageTabOverview": "Overview",
"aiUsageTabProviders": "Providers",
"aiUsageTabResources": "Resources",
"aiUsageFilterProvider": "Provider",
"aiUsageFilterModel": "Model",
"aiUsageFilterResource": "Resource",
"aiUsageFilterRole": "Role",
"aiUsageFilterUser": "User",
"aiUsageFilterAllProviders": "All Providers",
"aiUsageFilterAllModels": "All Models",
"aiUsageFilterAllResources": "All Resources",
"aiUsageFilterAllRoles": "All Roles",
"aiUsageFilterAllUsers": "All Users",
"aiUsageResetFilters": "Reset Filters",
"aiUsageRefresh": "Refresh",
"aiUsageTokenTypePrompt": "Prompt",
"aiUsageTokenTypeCacheRead": "Cache read",
"aiUsageTokenTypeCacheWrite": "Cache write",
"aiUsageTokenTypeCompletion": "Completion",
"aiUsageTokenTypeReasoning": "Reasoning",
"aiUsageRequests": "Requests",
"aiUsageCost": "Cost",
"aiUsageTokens": "Tokens",
"aiUsageOther": "Other",
"aiUsageTotalRequests": "Total Requests",
"aiUsageTotalTokens": "Total Tokens",
"aiUsageTotalCost": "Total Cost",
"aiUsageEstimated": "Estimated",
"aiUsageRequestVolume": "Request Volume",
"aiUsageTokenUsage": "Token Usage",
"aiUsageModelCost": "Model Cost",
"aiUsageModelTokens": "Model Tokens",
"aiUsageTopModels": "Top Models",
"aiUsageTopProviders": "Top Providers",
"aiUsageProviderCost": "Provider Cost",
"aiUsageProviderTokenUsage": "Provider Token Usage",
"aiUsageTopResources": "Top Resources",
"aiUsageResourceCost": "Resource Cost",
"aiUsageResourceTokenUsage": "Resource Token Usage",
"aiUsageResourceTypePublic": "Resource",
"aiUsageResourceTypeSite": "Site resource",
"aiUsageNoResource": "No resource",
"aiUsageRolesTab": "Roles",
"aiUsageUsersTab": "Users",
"aiUsageTopRoles": "Top Roles",
"aiUsageRoleCost": "Role Cost",
"aiUsageRoleTokenUsage": "Role Token Usage",
"aiUsageTopUsers": "Top Users",
"aiUsageUserCost": "User Cost",
"aiUsageUserTokenUsage": "User Token Usage",
"aiUsageUnknownUser": "Unknown user",
"aiUsageVirtualApiKeysTab": "Virtual API Keys",
"aiUsageFilterVirtualApiKey": "Virtual API Key",
"aiUsageFilterAllVirtualApiKeys": "All Virtual API Keys",
"aiUsageTopVirtualApiKeys": "Top Virtual API Keys",
"aiUsageVirtualApiKeyCost": "Virtual API Key Cost",
"aiUsageVirtualApiKeyTokenUsage": "Virtual API Key Token Usage",
"aiUsageUnknownVirtualApiKey": "No virtual API key",
"aiUsageUnnamedVirtualApiKey": "Unnamed key",
"aiUsageLoading": "Loading...",
"aiUsageNoData": "No data",
"resourceBudgetSettings": "Budget",
"resourceBudgetSettingsDescription": "Configure how this inference resource restricts usage based on spending or token limits",
"sidebarApiKeys": "API Keys",
"sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings",
@@ -2193,6 +2576,8 @@
"subnetPlaceholder": "Subnet",
"addressDescription": "The internal address of the client. Must fall within the organization's subnet.",
"selectSites": "Select sites",
"selectResources": "Select resources",
"multiResourcesSelectorResourcesCount": "{count, plural, one {# resource} other {# resources}}",
"selectLabels": "Select labels",
"sitesDescription": "The client will have connectivity to the selected sites",
"clientInstallOlm": "Install Machine Client",
@@ -2246,6 +2631,7 @@
"requireDeviceApproval": "Require Device Approvals",
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
"sshSettings": "SSH Settings",
"inferenceSettings": "Inference Settings",
"sshAccess": "SSH Access",
"rdpSettings": "RDP Settings",
"vncSettings": "VNC Settings",
@@ -2401,6 +2787,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeInference": "Inference",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Scheme",
"editInternalResourceDialogEnableSsl": "Enable TLS",
@@ -2460,6 +2847,7 @@
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"createInternalResourceDialogModeInference": "Inference",
"scheme": "Scheme",
"createInternalResourceDialogScheme": "Scheme",
"createInternalResourceDialogEnableSsl": "Enable TLS",
@@ -3051,6 +3439,7 @@
"validPassword": "Valid Password",
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"validVirtualAPIKey": "Valid Virtual API Key",
"view": "View",
"configManaged": "Config Managed",
"connectedClient": "Connected Client",
@@ -3072,6 +3461,38 @@
"logRetention": "Log Retention",
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them",
"requestLogsDescription": "View detailed request logs for HTTPS resources in this organization",
"aiSessionLogs": "AI Gateway Session Logs",
"aiSessionLogsDescription": "View prompt and response transcripts for AI gateway requests in this organization",
"sidebarLogsAi": "AI Session Logs",
"commandLogsAi": "AI Session Logs",
"sidebarLogsAiUsage": "AI Usage Analytics",
"commandLogsAiUsage": "AI Usage Analytics",
"provider": "Provider",
"capability": "Capability",
"model": "Model",
"virtualApiKey": "Virtual API Key",
"noVirtualApiKey": "No virtual API key",
"stream": "Stream",
"streaming": "Streaming",
"nonStreaming": "Non-streaming",
"statusCode": "Status Code",
"aiSessionId": "Session ID",
"aiSessionRequest": "Request",
"aiSessionResponse": "Response",
"aiSessionNoData": "No data captured",
"aiSessionCouldNotParse": "(raw, could not parse transcript)",
"aiSessionLogTruncated": "This session was truncated before storage and may be incomplete.",
"aiSessionViewRaw": "View Raw JSON",
"aiSessionViewChat": "View Chat",
"cost": "Cost",
"estimated": "Estimated",
"tokenUsage": "Token Usage",
"promptTokens": "Prompt Tokens",
"cacheReadTokens": "Cache Read Tokens",
"cacheWriteTokens": "Cache Write Tokens",
"completionTokens": "Completion Tokens",
"reasoningTokens": "Reasoning Tokens",
"totalTokens": "Total Tokens",
"requestAnalyticsDescription": "View detailed request analytics for resources in this organization",
"logRetentionRequestLabel": "HTTP Request Log Retention",
"logRetentionRequestDescription": "How long to retain request logs",
@@ -3081,6 +3502,8 @@
"logRetentionActionDescription": "How long to retain action logs",
"logRetentionConnectionLabel": "Network Log Retention",
"logRetentionConnectionDescription": "How long to retain connection logs",
"logRetentionAISessionsLabel": "AI Gateway Session Log Retention",
"logRetentionAISessionsDescription": "How long to retain AI gateway prompt/response session logs",
"logRetentionDisabled": "Disabled",
"logRetention3Days": "3 days",
"logRetention7Days": "7 days",
@@ -3735,7 +4158,18 @@
"resourceLauncherTcp": "TCP",
"resourceLauncherUdp": "UDP",
"resourceLauncherUnlabeled": "Unlabeled",
"resourceLauncherAiGateway": "AI Gateway",
"resourceLauncherNoSite": "No Site",
"resourceLauncherAvailableModels": "Available Models",
"resourceLauncherAvailableModelsDescription": "Models you can use with this inference resource.",
"resourceLauncherAvailableModelsEmpty": "No models are available for this resource.",
"resourceLauncherAvailableModelsError": "Could not load available models.",
"resourceLauncherApiKeys": "API Keys",
"resourceLauncherApiKeysDescription": "Use your identity key or an attributed key to authenticate with this resource.",
"resourceLauncherApiKeysIdentity": "Identity Key",
"resourceLauncherApiKeysManual": "Attributed Keys",
"resourceLauncherApiKeysEmpty": "No API keys are available for this resource.",
"resourceLauncherApiKeysError": "Could not load API keys.",
"resourceLauncherNoResourcesInGroup": "No resources in this group",
"resourceLauncherEmptyStateTitle": "No Resources Available",
"resourceLauncherEmptyStateDescription": "You don't have access to any resources yet. Contact your administrator to request access.",
@@ -3826,4 +4260,4 @@
"sessionToolbarShow": "Show toolbar",
"sessionToolbarHide": "Hide toolbar",
"actionUpdateSiteApprovals": "Update Site Approvals"
}
}
+11 -4
View File
@@ -65,12 +65,13 @@
"express": "5.2.1",
"express-rate-limit": "8.5.2",
"glob": "13.0.6",
"gpt-tokenizer": "^3.4.0",
"helmet": "8.2.0",
"http-errors": "2.0.1",
"input-otp": "1.4.2",
"ioredis": "5.11.0",
"jmespath": "0.16.0",
"js-yaml": "4.3.1",
"js-yaml": "4.3.0",
"jsonwebtoken": "9.0.3",
"lucide-react": "1.17.0",
"maxmind": "5.0.6",
@@ -12760,6 +12761,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gpt-tokenizer": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-3.4.0.tgz",
"integrity": "sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==",
"license": "MIT"
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -13633,9 +13640,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"funding": [
{
"type": "github",
+3 -2
View File
@@ -33,9 +33,9 @@
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.5.0",
"@aws-sdk/client-s3": "3.1056.0",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
"@aws-sdk/client-s3": "3.1056.0",
"@headlessui/react": "2.2.10",
"@hookform/resolvers": "5.4.0",
"@monaco-editor/react": "4.7.0",
@@ -88,12 +88,13 @@
"express": "5.2.1",
"express-rate-limit": "8.5.2",
"glob": "13.0.6",
"gpt-tokenizer": "^3.4.0",
"helmet": "8.2.0",
"http-errors": "2.0.1",
"input-otp": "1.4.2",
"ioredis": "5.11.0",
"jmespath": "0.16.0",
"js-yaml": "4.3.1",
"js-yaml": "4.3.0",
"jsonwebtoken": "9.0.3",
"lucide-react": "1.17.0",
"maxmind": "5.0.6",
+39
View File
@@ -0,0 +1,39 @@
import express from "express";
import helmet from "helmet";
import cors from "cors";
import config from "@server/lib/config";
import logger from "@server/logger";
import {
errorHandlerMiddleware,
notFoundMiddleware
} from "@server/middlewares";
import { createAiGatewayRouter } from "@server/routers/aiGateway";
const aiGatewayPort = config.getRawConfig().server.ai_gateway_port;
export function createAiGatewayServer() {
const aiGatewayServer = express();
const trustProxy = config.getRawConfig().server.trust_proxy;
if (trustProxy) {
aiGatewayServer.set("trust proxy", trustProxy);
}
aiGatewayServer.use(helmet());
aiGatewayServer.use(cors());
aiGatewayServer.use(express.json());
aiGatewayServer.use(createAiGatewayRouter());
aiGatewayServer.use(notFoundMiddleware);
aiGatewayServer.use(errorHandlerMiddleware);
aiGatewayServer.listen(aiGatewayPort, (err?: any) => {
if (err) throw err;
logger.info(
`AI gateway server is running on http://localhost:${aiGatewayPort}`
);
});
return aiGatewayServer;
}
+23 -1
View File
@@ -50,6 +50,8 @@ export enum ActionsEnum {
setResourceUsers = "setResourceUsers",
setResourceRoles = "setResourceRoles",
listResourceUsers = "listResourceUsers",
listResourceAiModels = "listResourceAiModels",
setResourceAiModels = "setResourceAiModels",
// removeRoleSite = "removeRoleSite",
// addRoleAction = "addRoleAction",
// removeRoleAction = "removeRoleAction",
@@ -182,7 +184,27 @@ export enum ActionsEnum {
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
setResourcePolicyRules = "setResourcePolicyRules",
createOrgWideLauncherView = "createOrgWideLauncherView"
createOrgWideLauncherView = "createOrgWideLauncherView",
createAiProvider = "createAiProvider",
deleteAiProvider = "deleteAiProvider",
getAiProvider = "getAiProvider",
listAiProviders = "listAiProviders",
updateAiProvider = "updateAiProvider",
createAiModel = "createAiModel",
deleteAiModel = "deleteAiModel",
getAiModel = "getAiModel",
listAiModels = "listAiModels",
updateAiModel = "updateAiModel",
createAiBudget = "createAiBudget",
deleteAiBudget = "deleteAiBudget",
getAiBudget = "getAiBudget",
listAiBudgets = "listAiBudgets",
updateAiBudget = "updateAiBudget",
createVirtualApiKey = "createVirtualApiKey",
deleteVirtualApiKey = "deleteVirtualApiKey",
getVirtualApiKey = "getVirtualApiKey",
listVirtualApiKeys = "listVirtualApiKeys",
updateVirtualApiKey = "updateVirtualApiKey"
}
export async function checkUserActionPermission(
+316
View File
@@ -0,0 +1,316 @@
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
import {
db,
users,
virtualApiKeyResources,
virtualApiKeys,
type VirtualApiKey
} from "@server/db";
import config from "@server/lib/config";
import {
decryptVirtualApiKeyToken,
VIRTUAL_API_KEY_PREFIX,
looksLikeVirtualApiKeyCredential
} from "@server/lib/virtualApiKey";
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
import { and, eq } from "drizzle-orm";
import { isWithinExpirationDate } from "oslo";
export type VirtualApiKeyCredential = {
virtualApiKeyId: string;
secret: string;
};
export type VirtualApiKeyUserData = {
userId: string;
username: string;
email: string | null;
name: string | null;
role: string | null;
};
function getHeader(
headers: Record<string, string> | undefined,
name: string
): string | undefined {
if (!headers) {
return undefined;
}
if (headers[name] !== undefined) {
return headers[name];
}
const lower = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === lower) {
return value;
}
}
return undefined;
}
function parseVkCredential(
raw: string | undefined
): VirtualApiKeyCredential | null {
if (!raw || !looksLikeVirtualApiKeyCredential(raw)) {
return null;
}
const withoutPrefix = raw.trim().slice(VIRTUAL_API_KEY_PREFIX.length);
const dot = withoutPrefix.indexOf(".");
return {
virtualApiKeyId: withoutPrefix.slice(0, dot),
secret: withoutPrefix.slice(dot + 1)
};
}
/**
* Extract a virtual API key credential from provider-style auth headers.
* Checks Authorization Bearer / Splunk, x-api-key, x-goog-api-key, and
* cf-aig-authorization. First matching vk-{id}.{secret} wins.
*/
export function extractVirtualApiKeyCredential(
headers: Record<string, string> | undefined
): VirtualApiKeyCredential | null {
if (!headers) {
return null;
}
const authorization = getHeader(headers, "authorization");
if (authorization) {
const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i);
if (bearerMatch) {
const credential = parseVkCredential(bearerMatch[1]);
if (credential) {
return credential;
}
}
const splunkMatch = authorization.match(/^Splunk\s+(.+)$/i);
if (splunkMatch) {
const credential = parseVkCredential(splunkMatch[1]);
if (credential) {
return credential;
}
}
}
const cfAig = getHeader(headers, "cf-aig-authorization");
if (cfAig) {
const bearerMatch = cfAig.match(/^Bearer\s+(.+)$/i);
const credential = parseVkCredential(
bearerMatch ? bearerMatch[1] : cfAig
);
if (credential) {
return credential;
}
}
for (const name of ["x-api-key", "x-goog-api-key"] as const) {
const credential = parseVkCredential(getHeader(headers, name));
if (credential) {
return credential;
}
}
return null;
}
async function buildUserData(
userId: string,
orgId: string
): Promise<VirtualApiKeyUserData | undefined> {
const [user] = await db
.select()
.from(users)
.where(eq(users.userId, userId))
.limit(1);
if (!user) {
return undefined;
}
if (
config.getRawConfig().flags?.require_email_verification &&
!user.emailVerified
) {
return undefined;
}
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
if (userOrgRoles.length === 0) {
return undefined;
}
return {
userId: user.userId,
username: user.username,
email: user.email,
name: user.name,
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
};
}
async function userHasResourceAccess(
userId: string,
resourceId: number,
orgId: string
): Promise<{ allowed: boolean; userData?: VirtualApiKeyUserData }> {
const [user] = await db
.select()
.from(users)
.where(eq(users.userId, userId))
.limit(1);
if (!user) {
return { allowed: false };
}
if (
config.getRawConfig().flags?.require_email_verification &&
!user.emailVerified
) {
return { allowed: false };
}
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
if (userOrgRoles.length === 0) {
return { allowed: false };
}
const allowed = await canUserAccessResource({
userId,
resourceId,
roleIds: userOrgRoles.map((r) => r.roleId)
});
if (!allowed) {
return { allowed: false };
}
return {
allowed: true,
userData: {
userId: user.userId,
username: user.username,
email: user.email,
name: user.name,
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
}
};
}
async function manualKeyHasResourceAccess(
key: VirtualApiKey,
resourceId: number
): Promise<boolean> {
if (key.allResources) {
return true;
}
const [row] = await db
.select({ resourceId: virtualApiKeyResources.resourceId })
.from(virtualApiKeyResources)
.where(
and(
eq(virtualApiKeyResources.virtualApiKeyId, key.virtualApiKeyId),
eq(virtualApiKeyResources.resourceId, resourceId)
)
)
.limit(1);
return Boolean(row);
}
async function touchLastUsedAt(virtualApiKeyId: string): Promise<void> {
try {
await db
.update(virtualApiKeys)
.set({ lastUsedAt: Date.now() })
.where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId));
} catch {
// Best-effort; do not fail auth on audit timestamp updates.
}
}
export async function verifyVirtualApiKey({
credential,
resourceId,
orgId
}: {
credential: VirtualApiKeyCredential;
resourceId: number;
orgId: string;
}): Promise<{
valid: boolean;
error?: string;
key?: VirtualApiKey;
userData?: VirtualApiKeyUserData;
}> {
const [key] = await db
.select()
.from(virtualApiKeys)
.where(eq(virtualApiKeys.virtualApiKeyId, credential.virtualApiKeyId))
.limit(1);
if (!key) {
return { valid: false, error: "Virtual API key not found" };
}
if (key.orgId !== orgId) {
return { valid: false, error: "Virtual API key org mismatch" };
}
let plaintext: string;
try {
plaintext = decryptVirtualApiKeyToken(key.token);
} catch {
return { valid: false, error: "Virtual API key secret is invalid" };
}
if (plaintext !== credential.secret) {
return { valid: false, error: "Invalid virtual API key secret" };
}
if (key.expiresAt && !isWithinExpirationDate(new Date(key.expiresAt))) {
return { valid: false, error: "Virtual API key has expired" };
}
if (key.kind === "manual") {
const scoped = await manualKeyHasResourceAccess(key, resourceId);
if (!scoped) {
return {
valid: false,
error: "Virtual API key is not scoped to this resource"
};
}
let userData: VirtualApiKeyUserData | undefined;
if (key.userId) {
userData = await buildUserData(key.userId, orgId);
}
await touchLastUsedAt(key.virtualApiKeyId);
return { valid: true, key, userData };
}
if (key.kind === "user") {
if (!key.userId) {
return { valid: false, error: "User virtual API key has no user" };
}
const access = await userHasResourceAccess(
key.userId,
resourceId,
orgId
);
if (!access.allowed || !access.userData) {
return {
valid: false,
error: "User is not allowed to access this resource"
};
}
await touchLastUsedAt(key.virtualApiKeyId);
return { valid: true, key, userData: access.userData };
}
return { valid: false, error: "Unknown virtual API key kind" };
}
+4
View File
@@ -3,12 +3,16 @@ import { flushConnectionLogToDb } from "#dynamic/routers/newt";
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
import { cleanup as wsCleanup } from "#dynamic/routers/ws";
import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement";
import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession";
async function cleanup() {
await stopPingAccumulator();
await flushBandwidthToDb();
await flushConnectionLogToDb();
await flushSiteBandwidthToDb();
await shutdownUsageRecorder();
await shutdownAiSessionLogger();
await wsCleanup();
process.exit(0);
+2 -1
View File
@@ -95,7 +95,8 @@ export const subscriptions = pgTable("subscriptions", {
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
expiresAt: bigint("expiresAt", { mode: "number" }),
trial: boolean("trial").default(false),
type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license
type: varchar("type", { length: 50 }), // tier1, tier2, tier3, or license
override: boolean("override").default(false)
});
export const subscriptionItems = pgTable("subscriptionItems", {
+493 -7
View File
@@ -3,6 +3,7 @@ import { InferSelectModel, sql } from "drizzle-orm";
import {
bigint,
boolean,
check,
index,
integer,
pgTable,
@@ -11,6 +12,7 @@ import {
serial,
text,
unique,
uniqueIndex,
varchar
} from "drizzle-orm/pg-core";
@@ -63,6 +65,11 @@ export const orgs = pgTable("orgs", {
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(0),
settingsLogRetentionDaysAISessions: integer(
"settingsLogRetentionDaysAISessions"
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(7),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: boolean("isBillingOrg"),
@@ -99,6 +106,7 @@ export const sites = pgTable(
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet"),
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node and INCLUDES THE CIDR
megabytesIn: real("bytesIn").default(0),
megabytesOut: real("bytesOut").default(0),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
@@ -194,7 +202,12 @@ export const resources = pgTable(
postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
mode: text("mode")
.default("http")
.$type<
"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"
>()
.notNull(),
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
@@ -215,6 +228,41 @@ export const resources = pgTable(
]
);
export const resourceAiProviders = pgTable(
"resourceAiProviders",
{
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
accessMode: varchar("accessMode")
.$type<"inherit" | "select">()
.notNull()
.default("inherit"),
enabled: boolean("enabled").notNull().default(true)
},
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
);
export const resourceAiModels = pgTable(
"resourceAiModels",
{
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" }),
listType: varchar("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow")
},
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
);
export const labels = pgTable("labels", {
labelId: serial("labelId").primaryKey(),
name: varchar("name").notNull(),
@@ -317,11 +365,18 @@ export const targets = pgTable(
"targets",
{
targetId: serial("targetId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
resourceId: integer("resourceId").references(
() => resources.resourceId,
{
onDelete: "cascade"
})
.notNull(),
}
),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{
onDelete: "cascade"
}
),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
@@ -345,6 +400,7 @@ export const targets = pgTable(
},
(t) => [
index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId),
index("idx_targets_providerid_siteid").on(t.providerId, t.siteId),
index("idx_targets_site_enabled_priority_target_resource")
.on(t.siteId, t.priority.desc(), t.targetId, t.resourceId)
.where(sql`${t.enabled} = true`)
@@ -424,11 +480,14 @@ export const siteResources = pgTable(
onDelete: "restrict"
}
),
requiresExitNodeConnection: boolean("requiresExitNodeConnection")
.notNull()
.default(false),
niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(),
ssl: boolean("ssl").notNull().default(false),
mode: varchar("mode")
.$type<"host" | "cidr" | "http" | "ssh">()
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
.notNull(), // "host" | "cidr" | "http"
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode
@@ -463,6 +522,45 @@ export const siteResources = pgTable(
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
);
export const siteResourceAiProviders = pgTable(
"siteResourceAiProviders",
{
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
}),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
accessMode: varchar("accessMode")
.$type<"inherit" | "select">()
.notNull()
.default("inherit"),
enabled: boolean("enabled").notNull().default(true)
},
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
);
export const siteResourceAiModels = pgTable(
"siteResourceAiModels",
{
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
}),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" }),
listType: varchar("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow")
},
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
);
export const networks = pgTable(
"networks",
{
@@ -1150,6 +1248,52 @@ export const apiKeyOrg = pgTable("apiKeyOrg", {
.notNull()
});
export const virtualApiKeys = pgTable(
"virtualApiKeys",
{
virtualApiKeyId: varchar("virtualApiKeyId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
kind: varchar("kind").$type<"user" | "manual">().notNull(),
userId: varchar("userId").references(() => users.userId, {
onDelete: "cascade"
}),
name: varchar("name"),
description: varchar("description"),
token: varchar("token").notNull(),
lastChars: varchar("lastChars").notNull(),
allResources: boolean("allResources").notNull().default(false),
expiresAt: bigint("expiresAt", { mode: "number" }),
lastUsedAt: bigint("lastUsedAt", { mode: "number" }),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
createdByUserId: varchar("createdByUserId").references(
() => users.userId,
{ onDelete: "set null" }
)
},
(t) => [
uniqueIndex("virtual_api_key_user_identity_uniq")
.on(t.orgId, t.userId)
.where(sql`${t.kind} = 'user'`)
]
);
export const virtualApiKeyResources = pgTable(
"virtualApiKeyResources",
{
virtualApiKeyId: varchar("virtualApiKeyId")
.notNull()
.references(() => virtualApiKeys.virtualApiKeyId, {
onDelete: "cascade"
}),
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" })
},
(t) => [primaryKey({ columns: [t.virtualApiKeyId, t.resourceId] })]
);
export const idpOrg = pgTable("idpOrg", {
idpId: integer("idpId")
.notNull()
@@ -1181,7 +1325,7 @@ export const clients = pgTable(
olmId: text("olmId"), // to lock it to a specific olm optionally
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet").notNull(),
exitNodeSubnet: varchar("exitNodeSubnet").notNull(), // INCLUDES THE CIDR
megabytesIn: real("bytesIn"),
megabytesOut: real("bytesOut"),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
@@ -1540,6 +1684,332 @@ export const statusHistory = pgTable(
]
);
export const aiProviders = pgTable("aiProviders", {
providerId: serial("providerId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
name: varchar("name").notNull(),
type: varchar("type")
.$type<
| "openai"
| "anthropic"
| "googleGemini"
| "vertexAi"
| "bedrock"
| "microsoftFoundry"
| "openRouter"
| "vercelAiGateway"
| "custom"
>()
.notNull(),
upstreamUrl: text("upstreamUrl"),
apiKey: text("apiKey"),
apiKeyLastChars: varchar("apiKeyLastChars"),
authType: varchar("authType")
.$type<
| "bearer"
| "x-api-key"
| "x-goog-api-key"
| "hec"
| "cf-aig-authorization"
| "none"
| "passthrough"
>()
.notNull(),
routingMode: varchar("routingMode")
.$type<"url" | "target">()
.notNull()
.default("url"),
capabilities: text("capabilities").notNull().default("[]"),
headers: text("headers"), // JSON array of { name, value }
skipTlsVerification: boolean("skipTlsVerification")
.notNull()
.default(false),
enabled: boolean("enabled").notNull().default(true),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
});
export const aiModels = pgTable(
"aiModels",
{
modelId: serial("modelId").primaryKey(),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
modelKey: varchar("modelKey").notNull(),
name: varchar("name").notNull(),
listType: varchar("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow"),
enabled: boolean("enabled").notNull().default(true),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
},
(t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)]
);
export const aiBudgets = pgTable(
"aiBudgets",
{
budgetId: serial("budgetId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "cascade" }
),
modelId: integer("modelId").references(() => aiModels.modelId, {
onDelete: "cascade"
}),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "cascade" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "cascade" }
),
roleId: integer("roleId").references(() => roles.roleId, {
onDelete: "cascade"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "cascade" }
),
amount: real("amount").notNull(),
unit: varchar("unit").$type<"usd" | "tokens">().notNull(),
period: varchar("period")
.$type<
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly"
>()
.notNull()
.default("monthly"),
enforcement: varchar("enforcement")
.$type<"hard" | "soft">()
.notNull()
.default("hard"),
enabled: boolean("enabled").notNull().default(true),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
},
(t) => [
unique("ai_budget_provider_uniq").on(t.providerId, t.unit, t.period),
unique("ai_budget_model_uniq").on(t.modelId, t.unit, t.period),
unique("ai_budget_resource_uniq").on(t.resourceId, t.unit, t.period),
unique("ai_budget_site_resource_uniq").on(
t.siteResourceId,
t.unit,
t.period
),
unique("ai_budget_role_uniq").on(t.roleId, t.unit, t.period),
unique("ai_budget_virtual_api_key_uniq").on(
t.virtualApiKeyId,
t.unit,
t.period
)
]
);
export const aiUsageRecords = pgTable(
"aiUsageRecords",
{
id: serial("id").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "set null" }),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: varchar("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
// Links this usage record back to the aiSessionLog row for the same
// request (aiSessionLog.sessionId), so token/cost usage can be shown
// alongside the session transcript. Not a DB-level FK - aiSessionLog
// lives in the separate logs database. Nullable because the session
// log may be disabled (retention set to 0) while usage tracking
// stays on.
sessionId: varchar("sessionId"),
requestedModel: varchar("requestedModel").notNull(),
promptTokens: integer("promptTokens").notNull().default(0),
cacheReadTokens: integer("cacheReadTokens").notNull().default(0),
cacheWriteTokens: integer("cacheWriteTokens").notNull().default(0),
completionTokens: integer("completionTokens").notNull().default(0),
reasoningTokens: integer("reasoningTokens").notNull().default(0),
totalTokens: integer("totalTokens").notNull().default(0),
costUsd: real("costUsd"),
estimated: boolean("estimated").notNull().default(false),
createdAt: bigint("createdAt", { mode: "number" }).notNull()
},
(t) => [
index("idx_ai_usage_records_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_usage_records_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_usage_records_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_usage_records_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_usage_records_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_usage_records_session").on(t.sessionId)
]
);
export const aiBudgetBreachEvents = pgTable(
"aiBudgetBreachEvents",
{
id: serial("id").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
budgetId: integer("budgetId")
.notNull()
.references(() => aiBudgets.budgetId, { onDelete: "cascade" }),
enforcement: varchar("enforcement").$type<"hard" | "soft">().notNull(),
unit: varchar("unit").$type<"usd" | "tokens">().notNull(),
period: varchar("period")
.$type<
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly"
>()
.notNull(),
amount: real("amount").notNull(),
usageAmount: real("usageAmount").notNull(),
blocked: boolean("blocked").notNull(),
requestUserId: varchar("requestUserId").references(() => users.userId, {
onDelete: "set null"
}),
createdAt: bigint("createdAt", { mode: "number" }).notNull()
},
(t) => [
index("idx_ai_budget_breach_events_budget_created").on(
t.budgetId,
t.createdAt
)
]
);
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = pgTable(
"aiSessionLog",
{
id: serial("id").primaryKey(),
sessionId: varchar("sessionId").notNull(),
orgId: varchar("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "set null" }),
capability: varchar("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: varchar("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: varchar("requestedModel"),
isStream: boolean("isStream").notNull().default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: boolean("truncated").notNull().default(false),
statusCode: integer("statusCode"),
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch ms
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -1595,6 +2065,10 @@ export type Idp = InferSelectModel<typeof idp>;
export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
export type VirtualApiKey = InferSelectModel<typeof virtualApiKeys>;
export type VirtualApiKeyResource = InferSelectModel<
typeof virtualApiKeyResources
>;
export type Client = InferSelectModel<typeof clients>;
export type ClientSite = InferSelectModel<typeof clientSitesAssociationsCache>;
export type Olm = InferSelectModel<typeof olms>;
@@ -1624,3 +2098,15 @@ export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
export type AiProvider = InferSelectModel<typeof aiProviders>;
export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>;
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
export type SiteResourceAiProvider = InferSelectModel<
typeof siteResourceAiProviders
>;
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
+6 -1
View File
@@ -5,6 +5,7 @@ import path from "path";
import fs from "fs";
import { APP_PATH } from "@server/lib/consts";
import { existsSync, mkdirSync } from "fs";
import logger from "@server/logger";
export const location = path.join(APP_PATH, "db", "db.sqlite");
export const exists = checkFileExists(location);
@@ -12,7 +13,11 @@ export const exists = checkFileExists(location);
bootstrapVolume();
function createDb() {
const sqlite = new Database(location);
const verbose =
process.env.QUERY_LOGGING == "true"
? (message: unknown) => logger.debug(String(message))
: undefined;
const sqlite = new Database(location, { verbose });
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
// Enable WAL mode — allows concurrent readers + single writer, preventing
+2 -1
View File
@@ -89,7 +89,8 @@ export const subscriptions = sqliteTable("subscriptions", {
expiresAt: integer("expiresAt"),
trial: integer("trial", { mode: "boolean" }).default(false),
billingCycleAnchor: integer("billingCycleAnchor"),
type: text("type") // tier1, tier2, tier3, or license
type: text("type"), // tier1, tier2, tier3, or license
override: integer("override", { mode: "boolean" }).default(false)
});
export const subscriptionItems = sqliteTable("subscriptionItems", {
+504 -10
View File
@@ -1,12 +1,15 @@
import { randomUUID } from "crypto";
import { InferSelectModel } from "drizzle-orm";
import { InferSelectModel, sql } from "drizzle-orm";
import {
check,
index,
integer,
primaryKey,
real,
sqliteTable,
text,
unique
unique,
uniqueIndex
} from "drizzle-orm/sqlite-core";
export const domains = sqliteTable("domains", {
@@ -61,6 +64,11 @@ export const orgs = sqliteTable("orgs", {
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(0),
settingsLogRetentionDaysAISessions: integer(
"settingsLogRetentionDaysAISessions"
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(7),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
@@ -107,7 +115,7 @@ export const sites = sqliteTable("sites", {
}),
name: text("name").notNull(),
pubKey: text("pubKey"),
subnet: text("subnet"),
exitNodeSubnet: text("exitNodeSubnet"),
megabytesIn: integer("bytesIn").default(0),
megabytesOut: integer("bytesOut").default(0),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
@@ -203,7 +211,10 @@ export const resources = sqliteTable("resources", {
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
mode: text("mode")
.default("http")
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
.notNull(), // rdp, ssh, http, vnc, inference
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
@@ -214,6 +225,41 @@ export const resources = sqliteTable("resources", {
status: text("status").$type<"pending" | "approved">().default("approved")
});
export const resourceAiProviders = sqliteTable(
"resourceAiProviders",
{
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
accessMode: text("accessMode")
.$type<"inherit" | "select">()
.notNull()
.default("inherit"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
},
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
);
export const resourceAiModels = sqliteTable(
"resourceAiModels",
{
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" }),
listType: text("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow")
},
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
);
export const labels = sqliteTable("labels", {
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
@@ -322,11 +368,12 @@ export const clientLabels = sqliteTable(
export const targets = sqliteTable("targets", {
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
resourceId: integer("resourceId").references(() => resources.resourceId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(() => aiProviders.providerId, {
onDelete: "cascade"
}),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
@@ -422,10 +469,17 @@ export const siteResources = sqliteTable("siteResources", {
() => networks.networkId,
{ onDelete: "restrict" }
),
requiresExitNodeConnection: integer("requiresExitNodeConnection", {
mode: "boolean"
})
.notNull()
.default(false),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
mode: text("mode")
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
.notNull(), // "host" | "cidr" | "http"
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode
@@ -453,6 +507,45 @@ export const siteResources = sqliteTable("siteResources", {
status: text("status").$type<"pending" | "approved">().default("approved")
});
export const siteResourceAiProviders = sqliteTable(
"siteResourceAiProviders",
{
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
}),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
accessMode: text("accessMode")
.$type<"inherit" | "select">()
.notNull()
.default("inherit"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
},
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
);
export const siteResourceAiModels = sqliteTable(
"siteResourceAiModels",
{
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
}),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" }),
listType: text("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow")
},
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
);
export const networks = sqliteTable("networks", {
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
niceId: text("niceId"),
@@ -599,6 +692,7 @@ export const clients = sqliteTable("clients", {
pubKey: text("pubKey"),
olmId: text("olmId"), // to lock it to a specific olm optionally
subnet: text("subnet").notNull(),
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
megabytesIn: integer("bytesIn"),
megabytesOut: integer("bytesOut"),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
@@ -1411,6 +1505,54 @@ export const apiKeyOrg = sqliteTable("apiKeyOrg", {
.notNull()
});
export const virtualApiKeys = sqliteTable(
"virtualApiKeys",
{
virtualApiKeyId: text("virtualApiKeyId").primaryKey(),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
kind: text("kind").$type<"user" | "manual">().notNull(),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
name: text("name"),
description: text("description"),
token: text("token").notNull(),
lastChars: text("lastChars").notNull(),
allResources: integer("allResources", { mode: "boolean" })
.notNull()
.default(false),
expiresAt: integer("expiresAt"),
lastUsedAt: integer("lastUsedAt"),
createdAt: integer("createdAt").notNull(),
createdByUserId: text("createdByUserId").references(
() => users.userId,
{ onDelete: "set null" }
)
},
(t) => [
uniqueIndex("virtual_api_key_user_identity_uniq")
.on(t.orgId, t.userId)
.where(sql`${t.kind} = 'user'`)
]
);
export const virtualApiKeyResources = sqliteTable(
"virtualApiKeyResources",
{
virtualApiKeyId: text("virtualApiKeyId")
.notNull()
.references(() => virtualApiKeys.virtualApiKeyId, {
onDelete: "cascade"
}),
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" })
},
(t) => [primaryKey({ columns: [t.virtualApiKeyId, t.resourceId] })]
);
export const idpOrg = sqliteTable("idpOrg", {
idpId: integer("idpId")
.notNull()
@@ -1526,6 +1668,342 @@ export const statusHistory = sqliteTable(
]
);
export const aiProviders = sqliteTable("aiProviders", {
providerId: integer("providerId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
name: text("name").notNull(),
type: text("type")
.$type<
| "openai"
| "anthropic"
| "googleGemini"
| "vertexAi"
| "bedrock"
| "microsoftFoundry"
| "openRouter"
| "vercelAiGateway"
| "custom"
>()
.notNull(),
upstreamUrl: text("upstreamUrl"),
apiKey: text("apiKey"),
apiKeyLastChars: text("apiKeyLastChars"),
authType: text("authType")
.$type<
| "bearer"
| "x-api-key"
| "x-goog-api-key"
| "hec"
| "cf-aig-authorization"
| "none"
| "passthrough"
>()
.notNull(),
routingMode: text("routingMode")
.$type<"url" | "target">()
.notNull()
.default("url"),
capabilities: text("capabilities").notNull().default("[]"),
headers: text("headers"), // JSON array of { name, value }
skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" })
.notNull()
.default(false),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
});
export const aiModels = sqliteTable(
"aiModels",
{
modelId: integer("modelId").primaryKey({ autoIncrement: true }),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
modelKey: text("modelKey").notNull(),
name: text("name").notNull(),
listType: text("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow"),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
},
(t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)]
);
export const aiBudgets = sqliteTable(
"aiBudgets",
{
budgetId: integer("budgetId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "cascade" }
),
modelId: integer("modelId").references(() => aiModels.modelId, {
onDelete: "cascade"
}),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "cascade" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "cascade" }
),
roleId: integer("roleId").references(() => roles.roleId, {
onDelete: "cascade"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "cascade" }
),
amount: real("amount").notNull(),
unit: text("unit").$type<"usd" | "tokens">().notNull(),
period: text("period")
.$type<
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly"
>()
.notNull()
.default("monthly"),
enforcement: text("enforcement")
.$type<"hard" | "soft">()
.notNull()
.default("hard"),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
},
(t) => [
unique("ai_budget_provider_uniq").on(t.providerId, t.unit, t.period),
unique("ai_budget_model_uniq").on(t.modelId, t.unit, t.period),
unique("ai_budget_resource_uniq").on(t.resourceId, t.unit, t.period),
unique("ai_budget_site_resource_uniq").on(
t.siteResourceId,
t.unit,
t.period
),
unique("ai_budget_role_uniq").on(t.roleId, t.unit, t.period),
unique("ai_budget_virtual_api_key_uniq").on(
t.virtualApiKeyId,
t.unit,
t.period
)
]
);
export const aiUsageRecords = sqliteTable(
"aiUsageRecords",
{
id: integer("id").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "set null" }),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: text("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
// Links this usage record back to the aiSessionLog row for the same
// request (aiSessionLog.sessionId), so token/cost usage can be shown
// alongside the session transcript. Not a DB-level FK - aiSessionLog
// lives in the separate logs database. Nullable because the session
// log may be disabled (retention set to 0) while usage tracking
// stays on.
sessionId: text("sessionId"),
requestedModel: text("requestedModel").notNull(),
promptTokens: integer("promptTokens").notNull().default(0),
cacheReadTokens: integer("cacheReadTokens").notNull().default(0),
cacheWriteTokens: integer("cacheWriteTokens").notNull().default(0),
completionTokens: integer("completionTokens").notNull().default(0),
reasoningTokens: integer("reasoningTokens").notNull().default(0),
totalTokens: integer("totalTokens").notNull().default(0),
costUsd: real("costUsd"),
estimated: integer("estimated", { mode: "boolean" })
.notNull()
.default(false),
createdAt: integer("createdAt").notNull()
},
(t) => [
index("idx_ai_usage_records_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_usage_records_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_usage_records_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_usage_records_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_usage_records_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_usage_records_session").on(t.sessionId)
]
);
export const aiBudgetBreachEvents = sqliteTable(
"aiBudgetBreachEvents",
{
id: integer("id").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
budgetId: integer("budgetId")
.notNull()
.references(() => aiBudgets.budgetId, { onDelete: "cascade" }),
enforcement: text("enforcement").$type<"hard" | "soft">().notNull(),
unit: text("unit").$type<"usd" | "tokens">().notNull(),
period: text("period")
.$type<
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly"
>()
.notNull(),
amount: real("amount").notNull(),
usageAmount: real("usageAmount").notNull(),
blocked: integer("blocked", { mode: "boolean" }).notNull(),
requestUserId: text("requestUserId").references(() => users.userId, {
onDelete: "set null"
}),
createdAt: integer("createdAt").notNull()
},
(t) => [
index("idx_ai_budget_breach_events_budget_created").on(
t.budgetId,
t.createdAt
)
]
);
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = sqliteTable(
"aiSessionLog",
{
id: integer("id").primaryKey({ autoIncrement: true }),
sessionId: text("sessionId").notNull(),
orgId: text("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "set null" }),
capability: text("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: text("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: text("requestedModel"),
isStream: integer("isStream", { mode: "boolean" })
.notNull()
.default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: integer("truncated", { mode: "boolean" })
.notNull()
.default(false),
statusCode: integer("statusCode"),
createdAt: integer("createdAt").notNull() // epoch ms
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -1577,6 +2055,10 @@ export type Idp = InferSelectModel<typeof idp>;
export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
export type VirtualApiKey = InferSelectModel<typeof virtualApiKeys>;
export type VirtualApiKeyResource = InferSelectModel<
typeof virtualApiKeyResources
>;
export type SiteResource = InferSelectModel<typeof siteResources>;
export type Network = InferSelectModel<typeof networks>;
export type OrgDomains = InferSelectModel<typeof orgDomains>;
@@ -1608,3 +2090,15 @@ export type ResourcePolicyHeaderAuth = InferSelectModel<
>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type AiProvider = InferSelectModel<typeof aiProviders>;
export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>;
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
export type SiteResourceAiProvider = InferSelectModel<
typeof siteResourceAiProviders
>;
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
+14 -1
View File
@@ -5,15 +5,20 @@ import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer";
import { createNextServer } from "./nextServer";
import { createInternalServer } from "./internalServer";
import { createAiGatewayServer } from "./aiGatewayServer";
import { createIntegrationApiServer } from "./integrationApiServer";
import {
ApiKey,
ApiKeyOrg,
AiBudget,
AiModel,
AiProvider,
RemoteExitNode,
Session,
SiteResource,
User,
UserOrg
UserOrg,
VirtualApiKey
} from "@server/db";
import config from "@server/lib/config";
import { setHostMeta } from "@server/lib/hostMeta";
@@ -25,6 +30,7 @@ import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
import { fetchServerIp } from "@server/lib/serverIpService";
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
async function startServers() {
await setHostMeta();
@@ -43,10 +49,12 @@ async function startServers() {
initLogCleanupInterval();
initAcmeCertSync();
startRebuildQueueProcessor();
await initAiModelCatalog();
// Start all servers
const apiServer = createApiServer();
const internalServer = createInternalServer();
const aiGatewayServer = createAiGatewayServer();
const nextServer = await createNextServer();
if (config.getRawConfig().traefik.file_mode) {
@@ -65,6 +73,7 @@ async function startServers() {
apiServer,
nextServer,
internalServer,
aiGatewayServer,
integrationServer
};
}
@@ -83,6 +92,10 @@ declare global {
userOrgIds?: string[];
remoteExitNode?: RemoteExitNode;
siteResource?: SiteResource;
aiProvider?: AiProvider;
aiModel?: AiModel;
aiBudget?: AiBudget;
virtualApiKey?: VirtualApiKey;
orgPolicyAllowed?: boolean;
}
}
+613
View File
@@ -0,0 +1,613 @@
import {
and,
eq,
gte,
inArray,
isNull,
or,
sql,
SQL,
type InferInsertModel
} from "drizzle-orm";
import {
AiBudget,
aiBudgetBreachEvents,
aiBudgets,
aiModels,
aiUsageRecords,
db,
userOrgRoles
} from "@server/db";
import { modelKeyMatches } from "@server/lib/aiModelKeyMatch";
import type { AiUsage } from "@server/lib/aiUsageExtraction";
import { regionalCache as cache } from "#dynamic/lib/cache";
import logger from "@server/logger";
type BudgetPeriod = AiBudget["period"];
const PERIOD_DURATIONS_MS: Record<Exclude<BudgetPeriod, "lifetime">, number> = {
hourly: 60 * 60 * 1000,
daily: 24 * 60 * 60 * 1000,
weekly: 7 * 24 * 60 * 60 * 1000,
monthly: 30 * 24 * 60 * 60 * 1000,
yearly: 365 * 24 * 60 * 60 * 1000
};
// Budgets are cheap to be a little stale about (enforcement is already
// check-then-act, not transactional). Re-derive each budget's usage sum
// from aiUsageRecords at most this often; in between, completed requests
// just add their own contribution onto the cached sum instead of
// re-querying/re-aggregating from scratch.
const BUDGET_CACHE_REFRESH_MS = 8_000;
// Redis-level TTL is only a safety net for eviction if a budget stops
// seeing traffic - the actual staleness check is the computedAt timestamp
// stored in the cached value, compared against BUDGET_CACHE_REFRESH_MS.
const BUDGET_CACHE_SAFETY_TTL_SEC = 60;
function applicableBudgetsCacheKey(ctx: BudgetScopeContext): string {
const roleKey = [...ctx.roleIds].sort((a, b) => a - b).join(",");
return [
"aiBudget:applicable",
ctx.orgId,
ctx.providerId,
ctx.requestedModel,
ctx.resourceId ?? "",
ctx.siteResourceId ?? "",
roleKey,
ctx.virtualApiKeyId ?? ""
].join(":");
}
function budgetUsageCacheKey(budgetId: number): string {
return `aiBudget:usage:${budgetId}`;
}
type CachedBudgetUsage = {
sum: number;
computedAt: number;
};
// Budget periods are trailing windows from "now", not calendar-aligned
// (e.g. "daily" = last 24h). "lifetime" has no lower bound.
function windowStart(period: BudgetPeriod, now: number): number {
if (period === "lifetime") {
return 0;
}
return now - PERIOD_DURATIONS_MS[period];
}
export type BudgetScopeContext = {
orgId: string;
providerId: number;
requestedModel: string;
resourceId: number | null;
siteResourceId: number | null;
roleIds: number[];
requestUserId: string | null;
virtualApiKeyId: string | null;
};
/**
* Every budget that could apply to this request: the provider itself, any
* model on that provider whose (possibly wildcarded) modelKey matches the
* requested model, the target resource/site-resource, and any role the
* requesting user holds in the org. Cached for BUDGET_CACHE_REFRESH_MS since
* budget/model config changes are rare and a request-scoped org/provider/
* model/resource/role combination repeats constantly under real traffic.
*/
export async function resolveApplicableBudgets(
ctx: BudgetScopeContext
): Promise<AiBudget[]> {
const cacheKey = applicableBudgetsCacheKey(ctx);
const cached = await cache.get<AiBudget[]>(cacheKey);
if (cached !== undefined) {
return cached;
}
const budgets = await fetchApplicableBudgets(ctx);
await cache.set(cacheKey, budgets, BUDGET_CACHE_REFRESH_MS / 1000);
return budgets;
}
async function fetchApplicableBudgets(
ctx: BudgetScopeContext
): Promise<AiBudget[]> {
const providerModels = await db
.select({ modelId: aiModels.modelId, modelKey: aiModels.modelKey })
.from(aiModels)
.where(
and(
eq(aiModels.providerId, ctx.providerId),
eq(aiModels.enabled, true)
)
);
const matchingModelIds = providerModels
.filter((m) => modelKeyMatches(m.modelKey, ctx.requestedModel))
.map((m) => m.modelId);
const scopeConditions: SQL[] = [
and(
eq(aiBudgets.providerId, ctx.providerId),
isNull(aiBudgets.modelId)
)!
];
if (matchingModelIds.length > 0) {
scopeConditions.push(inArray(aiBudgets.modelId, matchingModelIds));
}
if (ctx.resourceId != null) {
scopeConditions.push(eq(aiBudgets.resourceId, ctx.resourceId));
}
if (ctx.siteResourceId != null) {
scopeConditions.push(eq(aiBudgets.siteResourceId, ctx.siteResourceId));
}
if (ctx.roleIds.length > 0) {
scopeConditions.push(inArray(aiBudgets.roleId, ctx.roleIds));
}
if (ctx.virtualApiKeyId != null) {
scopeConditions.push(
eq(aiBudgets.virtualApiKeyId, ctx.virtualApiKeyId)
);
}
return db
.select()
.from(aiBudgets)
.where(
and(
eq(aiBudgets.orgId, ctx.orgId),
eq(aiBudgets.enabled, true),
or(...scopeConditions)
)
);
}
async function sumUsageAmount(
where: SQL,
unit: AiBudget["unit"]
): Promise<number> {
const column =
unit === "usd" ? aiUsageRecords.costUsd : aiUsageRecords.totalTokens;
const [row] = await db
.select({ total: sql<number>`coalesce(sum(${column}), 0)` })
.from(aiUsageRecords)
.where(where);
return Number(row?.total ?? 0);
}
/**
* Sums recorded usage for a single budget's scope + rolling window. Model
* budgets can't be pushed down to SQL because the model's key may itself be
* a glob, so those rows are fetched for the provider+window and matched in
* JS the same way access-control matching does.
*/
export async function sumUsageForBudget(
budget: AiBudget,
ctx: BudgetScopeContext,
now: number
): Promise<number> {
const start = windowStart(budget.period, now);
if (budget.modelId != null) {
const [model] = await db
.select({
providerId: aiModels.providerId,
modelKey: aiModels.modelKey
})
.from(aiModels)
.where(eq(aiModels.modelId, budget.modelId))
.limit(1);
if (!model) {
return 0;
}
const rows = await db
.select({
requestedModel: aiUsageRecords.requestedModel,
costUsd: aiUsageRecords.costUsd,
totalTokens: aiUsageRecords.totalTokens
})
.from(aiUsageRecords)
.where(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
eq(aiUsageRecords.providerId, model.providerId),
gte(aiUsageRecords.createdAt, start)
)
);
return rows
.filter((r) => modelKeyMatches(model.modelKey, r.requestedModel))
.reduce(
(sum, r) =>
sum +
(budget.unit === "usd" ? (r.costUsd ?? 0) : r.totalTokens),
0
);
}
if (budget.providerId != null) {
return sumUsageAmount(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
eq(aiUsageRecords.providerId, budget.providerId),
gte(aiUsageRecords.createdAt, start)
)!,
budget.unit
);
}
if (budget.resourceId != null) {
return sumUsageAmount(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
eq(aiUsageRecords.resourceId, budget.resourceId),
gte(aiUsageRecords.createdAt, start)
)!,
budget.unit
);
}
if (budget.siteResourceId != null) {
return sumUsageAmount(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
eq(aiUsageRecords.siteResourceId, budget.siteResourceId),
gte(aiUsageRecords.createdAt, start)
)!,
budget.unit
);
}
if (budget.roleId != null) {
const members = await db
.select({ userId: userOrgRoles.userId })
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.roleId, budget.roleId),
eq(userOrgRoles.orgId, ctx.orgId)
)
);
const userIds = members.map((m) => m.userId);
if (userIds.length === 0) {
return 0;
}
return sumUsageAmount(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
inArray(aiUsageRecords.userId, userIds),
gte(aiUsageRecords.createdAt, start)
)!,
budget.unit
);
}
if (budget.virtualApiKeyId != null) {
return sumUsageAmount(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
eq(aiUsageRecords.virtualApiKeyId, budget.virtualApiKeyId),
gte(aiUsageRecords.createdAt, start)
)!,
budget.unit
);
}
return 0;
}
/**
* Cached wrapper around sumUsageForBudget. Reuses a per-budget cached sum
* for up to BUDGET_CACHE_REFRESH_MS, and otherwise falls through to the DB
* aggregation and reseeds the cache. Completed requests within that window
* top the cached sum up via applyUsageToBudgetCache below rather than
* forcing a re-aggregation on every request.
*/
async function getBudgetUsage(
budget: AiBudget,
ctx: BudgetScopeContext,
now: number
): Promise<number> {
const cacheKey = budgetUsageCacheKey(budget.budgetId);
const cached = await cache.get<CachedBudgetUsage>(cacheKey);
if (cached && now - cached.computedAt < BUDGET_CACHE_REFRESH_MS) {
return cached.sum;
}
const sum = await sumUsageForBudget(budget, ctx, now);
await cache.set(
cacheKey,
{ sum, computedAt: now } satisfies CachedBudgetUsage,
BUDGET_CACHE_SAFETY_TTL_SEC
);
return sum;
}
/**
* Called once a request's actual usage is known, for every budget that was
* resolved as applicable to it (i.e. checkBudgets' returned `budgets`).
* Adds this request's contribution directly onto each budget's cached sum
* so the next request in the same refresh window doesn't need to re-query
* or re-aggregate. If there's no warm cache entry, or it's already due for
* a refresh, this is a no-op - the next reader re-derives from the DB,
* which by then already includes this request's row via recordUsage.
*/
export async function applyUsageToBudgetCache(
budgets: AiBudget[],
usage: { usd: number; tokens: number }
): Promise<void> {
await Promise.all(
budgets.map(async (budget) => {
const delta = budget.unit === "usd" ? usage.usd : usage.tokens;
if (!delta) {
return;
}
const cacheKey = budgetUsageCacheKey(budget.budgetId);
const cached = await cache.get<CachedBudgetUsage>(cacheKey);
if (
!cached ||
Date.now() - cached.computedAt >= BUDGET_CACHE_REFRESH_MS
) {
return;
}
await cache.set(
cacheKey,
{
sum: cached.sum + delta,
computedAt: cached.computedAt
} satisfies CachedBudgetUsage,
BUDGET_CACHE_SAFETY_TTL_SEC
);
})
);
}
// Throttled to one durable event per budget per breach window, so a soft
// budget being exceeded doesn't write a row on every subsequent request
// while it stays over.
async function recordBreachEventIfNew(
budget: AiBudget,
ctx: BudgetScopeContext,
usageAmount: number,
now: number
): Promise<void> {
try {
const start = windowStart(budget.period, now);
const [existing] = await db
.select({ id: aiBudgetBreachEvents.id })
.from(aiBudgetBreachEvents)
.where(
and(
eq(aiBudgetBreachEvents.budgetId, budget.budgetId),
gte(aiBudgetBreachEvents.createdAt, start)
)
)
.limit(1);
if (existing) {
return;
}
await db.insert(aiBudgetBreachEvents).values({
orgId: ctx.orgId,
budgetId: budget.budgetId,
enforcement: budget.enforcement,
unit: budget.unit,
period: budget.period,
amount: budget.amount,
usageAmount,
blocked: budget.enforcement === "hard",
requestUserId: ctx.requestUserId,
createdAt: now
});
} catch (error) {
logger.error("Failed to record AI budget breach event", {
error,
budgetId: budget.budgetId
});
}
}
export type BudgetCheckResult = {
blocked: boolean;
blockingBudget?: AiBudget;
// Every budget resolved as applicable to this request, regardless of
// whether it was breached - pass to applyUsageToBudgetCache once this
// request's actual usage is known.
budgets: AiBudget[];
};
export async function checkBudgets(
ctx: BudgetScopeContext
): Promise<BudgetCheckResult> {
const budgets = await resolveApplicableBudgets(ctx);
if (budgets.length === 0) {
return { blocked: false, budgets: [] };
}
const now = Date.now();
let blockingBudget: AiBudget | undefined;
for (const budget of budgets) {
const usage = await getBudgetUsage(budget, ctx, now);
if (usage < budget.amount) {
continue;
}
await recordBreachEventIfNew(budget, ctx, usage, now);
if (budget.enforcement === "hard" && !blockingBudget) {
blockingBudget = budget;
}
}
return blockingBudget
? { blocked: true, blockingBudget, budgets }
: { blocked: false, budgets };
}
export type UsageRecordInput = {
orgId: string;
providerId: number;
resourceId: number | null;
siteResourceId: number | null;
userId: string | null;
virtualApiKeyId: string | null;
requestedModel: string;
usage: AiUsage;
costUsd: number | null;
createdAt?: number;
// Same id as the aiSessionLog row logged for this request, so the two
// can be joined to show token/cost usage alongside the session
// transcript. Undefined when the session wasn't logged (e.g. session
// log retention disabled for the org).
sessionId?: string;
};
type AiUsageRecordInsert = InferInsertModel<typeof aiUsageRecords>;
// In-memory buffer for batching AI usage record inserts, mirroring the
// approach in server/routers/badger/logRequestAudit.ts. Usage rows are read
// back on every budget-cache miss (see getBudgetUsage above), which happens
// at least every BUDGET_CACHE_REFRESH_MS, so this buffer is flushed much
// more aggressively than the request audit log to keep the table from
// lagging behind what budget enforcement needs. Unlike the audit log, there
// is no retention/cleanup job for this table - usage history is kept
// indefinitely for billing and historical reporting.
const usageRecordBuffer: AiUsageRecordInsert[] = [];
const USAGE_BATCH_SIZE = 20; // Write to DB every 20 records
const USAGE_BATCH_INTERVAL_MS = 1000; // Or every 1 second, whichever comes first
const USAGE_MAX_BUFFER_SIZE = 5000; // Prevent unbounded memory growth
let usageFlushTimer: NodeJS.Timeout | null = null;
let isUsageFlushInProgress = false;
async function flushUsageRecords() {
if (usageRecordBuffer.length === 0 || isUsageFlushInProgress) {
return;
}
isUsageFlushInProgress = true;
const recordsToWrite = usageRecordBuffer.splice(
0,
usageRecordBuffer.length
);
try {
// Use a transaction to ensure all inserts succeed or fail together
await db.transaction(async (tx) => {
// Batch insert in groups to avoid overwhelming the database
const DB_BATCH_SIZE = 25;
for (let i = 0; i < recordsToWrite.length; i += DB_BATCH_SIZE) {
const batch = recordsToWrite.slice(i, i + DB_BATCH_SIZE);
await tx.insert(aiUsageRecords).values(batch);
}
});
logger.debug(
`Flushed ${recordsToWrite.length} AI usage records to database`
);
} catch (error) {
logger.error("Error flushing AI usage records:", error);
// On transaction error, put records back at the front of the buffer
// to retry, but only if the buffer isn't too large
if (
usageRecordBuffer.length <
USAGE_MAX_BUFFER_SIZE - recordsToWrite.length
) {
usageRecordBuffer.unshift(...recordsToWrite);
logger.info(
`Re-queued ${recordsToWrite.length} AI usage records for retry`
);
} else {
logger.error(
`Buffer full, dropped ${recordsToWrite.length} AI usage records`
);
}
} finally {
isUsageFlushInProgress = false;
// If buffer filled up while we were flushing, flush again
if (usageRecordBuffer.length >= USAGE_BATCH_SIZE) {
flushUsageRecords().catch((err) =>
logger.error("Error in follow-up AI usage flush:", err)
);
}
}
}
function scheduleUsageFlush() {
if (usageFlushTimer === null) {
usageFlushTimer = setTimeout(() => {
usageFlushTimer = null;
flushUsageRecords().catch((err) =>
logger.error("Error in scheduled AI usage flush:", err)
);
}, USAGE_BATCH_INTERVAL_MS);
}
}
/**
* Gracefully flush all pending AI usage records (call this on shutdown).
*/
export async function shutdownUsageRecorder() {
if (usageFlushTimer) {
clearTimeout(usageFlushTimer);
usageFlushTimer = null;
}
// Force flush even if one is in progress by waiting and retrying
while (isUsageFlushInProgress) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await flushUsageRecords();
}
export async function recordUsage(input: UsageRecordInput): Promise<void> {
try {
const { usage } = input;
const totalTokens =
usage.promptTokens +
usage.cacheReadTokens +
usage.cacheWriteTokens +
usage.completionTokens +
usage.reasoningTokens;
// Prevent unbounded buffer growth - drop oldest entries if buffer is too large
if (usageRecordBuffer.length >= USAGE_MAX_BUFFER_SIZE) {
const dropped = usageRecordBuffer.splice(0, USAGE_BATCH_SIZE);
logger.warn(
`AI usage record buffer exceeded max size (${USAGE_MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries`
);
}
usageRecordBuffer.push({
orgId: input.orgId,
providerId: input.providerId,
resourceId: input.resourceId,
siteResourceId: input.siteResourceId,
userId: input.userId,
virtualApiKeyId: input.virtualApiKeyId,
sessionId: input.sessionId,
requestedModel: input.requestedModel,
promptTokens: usage.promptTokens,
cacheReadTokens: usage.cacheReadTokens,
cacheWriteTokens: usage.cacheWriteTokens,
completionTokens: usage.completionTokens,
reasoningTokens: usage.reasoningTokens,
totalTokens,
costUsd: input.costUsd,
estimated: usage.estimated,
createdAt: input.createdAt ?? Date.now()
});
// Flush immediately if buffer is full, otherwise schedule a flush
if (usageRecordBuffer.length >= USAGE_BATCH_SIZE) {
flushUsageRecords().catch((err) =>
logger.error("Error flushing AI usage records:", err)
);
} else {
scheduleUsageFlush();
}
} catch (error) {
logger.error("Failed to record AI usage", { error });
}
}
+339
View File
@@ -0,0 +1,339 @@
import type { Request } from "express";
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
export { AI_CAPABILITIES, type AiCapability };
export type AiCapabilityRoute = {
method: "POST";
path: string;
};
export type AiProtocolFamily = "openai" | "anthropic" | "google" | "bedrock";
export type AiCapabilityDefinition = {
id: AiCapability;
protocolFamily: AiProtocolFamily;
routes: AiCapabilityRoute[];
extractModel: (req: Request) => string | undefined;
resolveUpstreamUrl: (
baseUrl: string,
req: Request,
model: string
) => string;
isStreaming: (req: Request, contentType: string) => boolean;
};
function bodyModel(req: Request): string | undefined {
return typeof req.body?.model === "string" ? req.body.model : undefined;
}
function paramModel(req: Request): string | undefined {
const model = req.params?.model;
return typeof model === "string" && model.length > 0 ? model : undefined;
}
export function joinUpstreamUrl(baseUrl: string, path: string): string {
const base = baseUrl.replace(/\/+$/, "");
let suffix = path.startsWith("/") ? path : `/${path}`;
let basePathname = "/";
try {
basePathname = new URL(base).pathname.replace(/\/+$/, "") || "/";
} catch {
// Fall through with "/" non-absolute bases are not expected in
// production, but keep joining usable for malformed input.
}
if (basePathname !== "/") {
const baseSegs = basePathname.split("/").filter(Boolean);
const pathSegs = suffix.split("/").filter(Boolean);
const max = Math.min(baseSegs.length, pathSegs.length);
let overlap = 0;
for (let n = max; n >= 1; n--) {
const baseSuffix = baseSegs.slice(-n);
const pathPrefix = pathSegs.slice(0, n);
if (baseSuffix.every((seg, i) => seg === pathPrefix[i])) {
overlap = n;
break;
}
}
if (overlap > 0) {
const remaining = pathSegs.slice(overlap);
suffix = remaining.length > 0 ? `/${remaining.join("/")}` : "/";
}
}
if (suffix === "/") {
return base;
}
return `${base}${suffix}`;
}
function pathFromRequest(req: Request): string {
const raw = req.originalUrl || req.url || req.path;
return raw.startsWith("/") ? raw : `/${raw}`;
}
function bodyRequestsStream(req: Request): boolean {
return req.body?.stream === true;
}
function contentTypeIsSse(contentType: string): boolean {
return contentType.includes("text/event-stream");
}
function contentTypeIsAmazonEventStream(contentType: string): boolean {
return contentType.includes("application/vnd.amazon.eventstream");
}
function pathIncludes(req: Request, fragment: string): boolean {
return pathFromRequest(req).includes(fragment);
}
function isBodyOrSseStreaming(req: Request, contentType: string): boolean {
return bodyRequestsStream(req) || contentTypeIsSse(contentType);
}
function isGeminiStyleStreaming(req: Request, contentType: string): boolean {
return (
pathIncludes(req, "streamGenerateContent") ||
pathIncludes(req, "alt=sse") ||
contentTypeIsSse(contentType)
);
}
export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
{
openai_chat: {
id: "openai_chat",
protocolFamily: "openai",
routes: [
{ method: "POST", path: "/v1/chat/completions" },
{ method: "POST", path: "/chat/completions" }
],
extractModel: bodyModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isBodyOrSseStreaming
},
openai_responses: {
id: "openai_responses",
protocolFamily: "openai",
routes: [{ method: "POST", path: "/v1/responses" }],
extractModel: bodyModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isBodyOrSseStreaming
},
anthropic_messages: {
id: "anthropic_messages",
protocolFamily: "anthropic",
routes: [{ method: "POST", path: "/v1/messages" }],
extractModel: bodyModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isBodyOrSseStreaming
},
gemini_generate_content: {
id: "gemini_generate_content",
protocolFamily: "google",
routes: [
{
method: "POST",
path: "/v1beta/models/:model\\:generateContent"
},
{
method: "POST",
path: "/v1beta/models/:model\\:streamGenerateContent"
}
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isGeminiStyleStreaming
},
google_generate_content: {
id: "google_generate_content",
protocolFamily: "google",
routes: [
{
method: "POST",
// Vertex publisher model generateContent
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:generateContent"
},
{
method: "POST",
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamGenerateContent"
}
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isGeminiStyleStreaming
},
google_raw_predict: {
id: "google_raw_predict",
protocolFamily: "google",
routes: [
{
method: "POST",
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:rawPredict"
},
{
method: "POST",
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamRawPredict"
}
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: (req, contentType) =>
pathIncludes(req, "streamRawPredict") ||
pathIncludes(req, "alt=sse") ||
contentTypeIsSse(contentType)
},
bedrock_model_invoke: {
id: "bedrock_model_invoke",
protocolFamily: "bedrock",
routes: [
{ method: "POST", path: "/model/:model/invoke" },
{
method: "POST",
path: "/model/:model/invoke-with-response-stream"
}
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: (req, contentType) =>
pathIncludes(req, "invoke-with-response-stream") ||
contentTypeIsAmazonEventStream(contentType) ||
contentTypeIsSse(contentType)
},
bedrock_converse: {
id: "bedrock_converse",
protocolFamily: "bedrock",
routes: [
{ method: "POST", path: "/model/:model/converse" },
{ method: "POST", path: "/model/:model/converse-stream" }
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: (req, contentType) =>
pathIncludes(req, "converse-stream") ||
contentTypeIsAmazonEventStream(contentType) ||
contentTypeIsSse(contentType)
}
};
export function isAiCapability(value: unknown): value is AiCapability {
return (
typeof value === "string" &&
(AI_CAPABILITIES as readonly string[]).includes(value)
);
}
/**
* Convert an Express-style route path from AI_CAPABILITY_DEFS into a RegExp.
* Handles `:param` segments and escaped literal colons (`\:`).
*/
export function routePatternToRegExp(routePath: string): RegExp {
let pattern = "";
for (let i = 0; i < routePath.length; i++) {
const ch = routePath[i];
if (
ch === "\\" &&
i + 1 < routePath.length &&
routePath[i + 1] === ":"
) {
pattern += ":";
i++;
continue;
}
if (ch === ":") {
// Named param: consume until next / or end
i++;
while (
i < routePath.length &&
routePath[i] !== "/" &&
!(routePath[i] === "\\" && routePath[i + 1] === ":")
) {
i++;
}
i--; // loop will ++
pattern += "[^/]+";
continue;
}
// Escape regex special chars
if (/[.*+?^${}()|[\]\\]/.test(ch)) {
pattern += "\\" + ch;
} else {
pattern += ch;
}
}
return new RegExp(`^${pattern}$`);
}
export function resolveAiCapabilityFromPath(path: string): AiCapability | null {
const pathname = path.split("?")[0] || "/";
const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`;
for (const def of Object.values(AI_CAPABILITY_DEFS)) {
for (const route of def.routes) {
if (routePatternToRegExp(route.path).test(normalized)) {
return def.id;
}
}
}
return null;
}
export function parseCapabilities(raw: unknown): AiCapability[] {
if (raw == null) {
return [];
}
let parsed: unknown = raw;
if (typeof raw === "string") {
const trimmed = raw.trim();
if (!trimmed) {
return [];
}
try {
parsed = JSON.parse(trimmed);
} catch {
return [];
}
}
if (!Array.isArray(parsed)) {
return [];
}
const out: AiCapability[] = [];
const seen = new Set<AiCapability>();
for (const item of parsed) {
if (isAiCapability(item) && !seen.has(item)) {
seen.add(item);
out.push(item);
}
}
return out;
}
export function serializeCapabilities(capabilities: AiCapability[]): string {
return JSON.stringify(capabilities);
}
export function providerHasCapability(
capabilities: AiCapability[] | string | null | undefined,
capability: AiCapability
): boolean {
const list =
typeof capabilities === "string" || capabilities == null
? parseCapabilities(capabilities)
: capabilities;
return list.includes(capability);
}
+139
View File
@@ -0,0 +1,139 @@
import {
AI_CAPABILITY_DEFS,
type AiCapability,
type AiProtocolFamily
} from "@server/lib/aiCapabilities";
import HttpCode from "@server/types/HttpCode";
export type ClientErrorResponse = {
statusCode?: number;
contentType?: string;
body: string;
};
export type AiCapabilityErrorKind =
| "authentication"
| "invalid_request"
| "not_found"
| "permission"
| "rate_limit"
| "internal";
const AUTH_MESSAGE = "Invalid API key provided.";
type KindFields = {
openaiType: string;
openaiCode: string | null;
anthropicType: string;
googleStatus: string;
};
const KIND_FIELDS: Record<AiCapabilityErrorKind, KindFields> = {
authentication: {
openaiType: "authentication_error",
openaiCode: "invalid_api_key",
anthropicType: "authentication_error",
googleStatus: "UNAUTHENTICATED"
},
invalid_request: {
openaiType: "invalid_request_error",
openaiCode: null,
anthropicType: "invalid_request_error",
googleStatus: "INVALID_ARGUMENT"
},
not_found: {
openaiType: "invalid_request_error",
openaiCode: null,
anthropicType: "not_found_error",
googleStatus: "NOT_FOUND"
},
permission: {
openaiType: "invalid_request_error",
openaiCode: null,
anthropicType: "permission_error",
googleStatus: "PERMISSION_DENIED"
},
rate_limit: {
openaiType: "rate_limit_error",
openaiCode: "rate_limit_exceeded",
anthropicType: "rate_limit_error",
googleStatus: "RESOURCE_EXHAUSTED"
},
internal: {
openaiType: "api_error",
openaiCode: null,
anthropicType: "api_error",
googleStatus: "INTERNAL"
}
};
function resolveProtocolFamily(
capability: AiCapability | null
): AiProtocolFamily {
if (capability == null) {
return "openai";
}
return AI_CAPABILITY_DEFS[capability].protocolFamily;
}
/**
* Build a protocol-native error body for the given capability.
* Message stays contextual; only the envelope/machine fields follow the
* capability's native API shape.
*/
export function buildAiCapabilityErrorBody(
capability: AiCapability | null,
kind: AiCapabilityErrorKind,
message: string,
httpStatus?: number
): Record<string, unknown> {
const family = resolveProtocolFamily(capability);
const fields = KIND_FIELDS[kind];
switch (family) {
case "openai":
return {
error: {
message,
type: fields.openaiType,
param: null,
code: fields.openaiCode
}
};
case "anthropic":
return {
type: "error",
error: {
type: fields.anthropicType,
message
}
};
case "google":
return {
error: {
code: httpStatus ?? HttpCode.BAD_REQUEST,
message,
status: fields.googleStatus
}
};
case "bedrock":
return { message };
}
}
export function buildInferenceAuthClientError(
capability: AiCapability | null
): ClientErrorResponse {
return {
statusCode: HttpCode.UNAUTHORIZED,
contentType: "application/json",
body: JSON.stringify(
buildAiCapabilityErrorBody(
capability,
"authentication",
AUTH_MESSAGE,
HttpCode.UNAUTHORIZED
)
)
};
}
+69
View File
@@ -0,0 +1,69 @@
import { createHash } from "crypto";
import config from "@server/lib/config";
export const AI_GATEWAY_TRUST_HEADER = "X-Pangolin-Ai-Gateway-Auth";
// Injected by the same Traefik trust middleware as AI_GATEWAY_TRUST_HEADER,
// but its value differs per router (public inference resource vs. private
// siteResource) so the gateway can tell which kind of resource a trusted
// request arrived on without re-deriving it from resourceId/siteResourceId.
export const AI_GATEWAY_RESOURCE_TYPE_HEADER =
"X-Pangolin-Ai-Gateway-Resource-Type";
export type AiGatewayResourceType = "resource" | "site-resource";
// Opt-in (server.enable_ai_gateway_client_ip_header): carries the client IP
// that Badger resolved at the Traefik hop, so it survives an intermediary
// proxy between Traefik and the AI gateway that overwrites
// X-Forwarded-For/X-Real-Ip instead of appending to them. Set by a
// disableForwardAuth Badger middleware instance (see getTraefikConfig.ts)
// on the site-resource inference router only, since that's the sole path
// that resolves request identity from the client IP.
export const AI_GATEWAY_CLIENT_IP_HEADER = "X-Pangolin-Client-Ip";
/**
* Derive a Traefik-injected trust token from the server secret.
* Traefik overwrites this header on inference routes so the AI gateway can
* trust Badger-injected Remote-* identity without re-validating credentials.
*/
export function deriveAiGatewayTrustToken(secret: string): string {
return createHash("sha256")
.update(`ai-gateway-trust:${secret}`)
.digest("hex");
}
export function getAiGatewayTrustToken(): string {
const secret = config.getRawConfig().server.secret;
if (!secret) {
throw new Error("Server secret is required for AI gateway trust token");
}
return deriveAiGatewayTrustToken(secret);
}
export function isAiGatewayTrustHeaderValid(
headers: Record<string, string | string[] | undefined> | undefined,
expectedToken?: string
): boolean {
if (!headers) {
return false;
}
const expected = expectedToken ?? getAiGatewayTrustToken();
const raw =
headers[AI_GATEWAY_TRUST_HEADER] ??
headers[AI_GATEWAY_TRUST_HEADER.toLowerCase()];
const value = Array.isArray(raw) ? raw[0] : raw;
return typeof value === "string" && value === expected;
}
export function getAiGatewayResourceType(
headers: Record<string, string | string[] | undefined> | undefined
): AiGatewayResourceType | null {
if (!headers) {
return null;
}
const raw =
headers[AI_GATEWAY_RESOURCE_TYPE_HEADER] ??
headers[AI_GATEWAY_RESOURCE_TYPE_HEADER.toLowerCase()];
const value = Array.isArray(raw) ? raw[0] : raw;
return value === "resource" || value === "site-resource" ? value : null;
}
+82
View File
@@ -0,0 +1,82 @@
import http from "node:http";
import https from "node:https";
import { Readable } from "node:stream";
type UpstreamFetchInit = {
method: string;
headers: Record<string, string>;
body?: string;
skipTlsVerification?: boolean;
signal?: AbortSignal;
};
const insecureHttpsAgent = new https.Agent({
rejectUnauthorized: false,
keepAlive: true
});
export function aiGatewayUpstreamFetch(
url: string,
init: UpstreamFetchInit
): Promise<Response> {
const parsed = new URL(url);
const isHttps = parsed.protocol === "https:";
const lib = isHttps ? https : http;
const agent =
isHttps && init.skipTlsVerification ? insecureHttpsAgent : undefined;
return new Promise((resolve, reject) => {
if (init.signal?.aborted) {
reject(init.signal.reason ?? new Error("Request aborted"));
return;
}
const req = lib.request(
url,
{
method: init.method,
headers: init.headers,
agent
},
(res) => {
const headers = new Headers();
for (const [key, value] of Object.entries(res.headers)) {
if (value === undefined) {
continue;
}
if (Array.isArray(value)) {
for (const entry of value) {
headers.append(key, entry);
}
} else {
headers.set(key, value);
}
}
const body = Readable.toWeb(res) as ReadableStream<Uint8Array>;
resolve(
new Response(body, {
status: res.statusCode ?? 502,
statusText: res.statusMessage,
headers
})
);
}
);
req.on("error", reject);
if (init.signal) {
const onAbort = () => req.destroy(init.signal!.reason);
init.signal.addEventListener("abort", onAbort, { once: true });
req.on("close", () =>
init.signal!.removeEventListener("abort", onAbort)
);
}
if (init.body !== undefined) {
req.write(init.body);
}
req.end();
});
}
+719
View File
@@ -0,0 +1,719 @@
import { and, eq, inArray } from "drizzle-orm";
import {
aiModels,
aiProviders,
db,
resourceAiModels,
resourceAiProviders,
siteResourceAiModels,
siteResourceAiProviders,
type Transaction
} from "@server/db";
import { z } from "zod";
type DbOrTrx = Transaction | typeof db;
export const modelListTypeSchema = z.enum(["allow", "block"]);
export type ModelListType = z.infer<typeof modelListTypeSchema>;
export const accessModeSchema = z.enum(["inherit", "select"]);
export type AccessMode = z.infer<typeof accessModeSchema>;
export const resourceAiProviderAttachmentSchema = z.strictObject({
providerId: z.number().int().positive(),
accessMode: accessModeSchema.optional().default("inherit"),
enabled: z.boolean().optional().default(true)
});
export type ResourceAiProviderInput = z.infer<
typeof resourceAiProviderAttachmentSchema
>;
export type ResourceAiProviderAttachment = {
providerId: number;
accessMode: AccessMode;
enabled: boolean;
};
export const resourceAiModelEntrySchema = z.strictObject({
modelId: z.number().int().positive(),
listType: modelListTypeSchema
});
export type ResourceAiModelEntry = z.infer<typeof resourceAiModelEntrySchema>;
export type InferenceFieldsError = {
error: string;
};
export function isInferenceFieldsError(
value: { error: string } | object
): value is InferenceFieldsError {
return "error" in value;
}
/**
* Resolve which allow/block patterns apply for an attachment.
* inherit → provider lists; select → resource-selected lists (replace).
*/
export function resolveEffectiveLists(input: {
accessMode: AccessMode;
providerAllows: string[];
providerBlocks: string[];
resourceAllows: string[];
resourceBlocks: string[];
}): { allows: string[]; blocks: string[] } {
if (input.accessMode === "select") {
return {
allows: input.resourceAllows,
blocks: input.resourceBlocks
};
}
return {
allows: input.providerAllows,
blocks: input.providerBlocks
};
}
function normalizeAttachments(
inputs: ResourceAiProviderInput[]
): ResourceAiProviderAttachment[] {
const byProviderId = new Map<
number,
{ accessMode: AccessMode; enabled: boolean }
>();
for (const input of inputs) {
byProviderId.set(input.providerId, {
accessMode: input.accessMode ?? "inherit",
enabled: input.enabled ?? true
});
}
return [...byProviderId.entries()].map(
([providerId, { accessMode, enabled }]) => ({
providerId,
accessMode,
enabled
})
);
}
/**
* Validate provider attachments for an org.
*/
export async function resolveProviderAttachments(input: {
orgId: string;
attachments: ResourceAiProviderInput[];
requireAtLeastOne: boolean;
}): Promise<ResourceAiProviderAttachment[] | InferenceFieldsError> {
const attachments = normalizeAttachments(input.attachments);
if (input.requireAtLeastOne && attachments.length === 0) {
return {
error: "At least one AI provider is required for inference-mode resources"
};
}
if (attachments.length === 0) {
return [];
}
const providerIds = attachments.map((a) => a.providerId);
const providers = await db
.select({
providerId: aiProviders.providerId,
orgId: aiProviders.orgId,
enabled: aiProviders.enabled
})
.from(aiProviders)
.where(
and(
inArray(aiProviders.providerId, providerIds),
eq(aiProviders.orgId, input.orgId)
)
);
if (providers.length !== providerIds.length) {
return {
error: "One or more AI providers were not found in this organization"
};
}
const disabled = providers.find((p) => !p.enabled);
if (disabled) {
return {
error: `AI provider with ID ${disabled.providerId} is disabled`
};
}
return attachments;
}
export async function assertInferenceModeAllowsProviderFields(input: {
mode: string;
hasProviderAttachments: boolean;
}): Promise<InferenceFieldsError | null> {
if (input.mode === "inference") {
return null;
}
if (input.hasProviderAttachments) {
return {
error: "AI providers can only be attached to inference-mode resources"
};
}
return null;
}
/**
* Attach providers to a resource. Inherit attachments use the provider lists
* as-is (resource model rows for those providers are pruned). Select
* attachments keep resource-selected allow/block subsets.
*/
export async function setPublicResourceAiProviders(
resourceId: number,
attachments: ResourceAiProviderAttachment[],
trx: DbOrTrx = db
): Promise<void> {
await trx
.delete(resourceAiProviders)
.where(eq(resourceAiProviders.resourceId, resourceId));
if (attachments.length > 0) {
await trx.insert(resourceAiProviders).values(
attachments.map((a) => ({
resourceId,
providerId: a.providerId,
accessMode: a.accessMode,
enabled: a.enabled
}))
);
}
await prunePublicResourceModelsToSelectProviders(
resourceId,
attachments,
trx
);
}
export async function setSiteResourceAiProviders(
siteResourceId: number,
attachments: ResourceAiProviderAttachment[],
trx: DbOrTrx = db
): Promise<void> {
await trx
.delete(siteResourceAiProviders)
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
if (attachments.length > 0) {
await trx.insert(siteResourceAiProviders).values(
attachments.map((a) => ({
siteResourceId,
providerId: a.providerId,
accessMode: a.accessMode,
enabled: a.enabled
}))
);
}
await pruneSiteResourceModelsToSelectProviders(
siteResourceId,
attachments,
trx
);
}
/**
* Keep resource model rows only for providers in select mode.
*/
async function prunePublicResourceModelsToSelectProviders(
resourceId: number,
attachments: ResourceAiProviderAttachment[],
trx: DbOrTrx
): Promise<void> {
const selectProviderIds = attachments
.filter((a) => a.accessMode === "select")
.map((a) => a.providerId);
if (selectProviderIds.length === 0) {
await trx
.delete(resourceAiModels)
.where(eq(resourceAiModels.resourceId, resourceId));
return;
}
const existing = await trx
.select({
modelId: resourceAiModels.modelId,
providerId: aiModels.providerId
})
.from(resourceAiModels)
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
.where(eq(resourceAiModels.resourceId, resourceId));
const allowed = new Set(selectProviderIds);
const toRemove = existing
.filter((row) => !allowed.has(row.providerId))
.map((row) => row.modelId);
if (toRemove.length > 0) {
await trx
.delete(resourceAiModels)
.where(
and(
eq(resourceAiModels.resourceId, resourceId),
inArray(resourceAiModels.modelId, toRemove)
)
);
}
}
async function pruneSiteResourceModelsToSelectProviders(
siteResourceId: number,
attachments: ResourceAiProviderAttachment[],
trx: DbOrTrx
): Promise<void> {
const selectProviderIds = attachments
.filter((a) => a.accessMode === "select")
.map((a) => a.providerId);
if (selectProviderIds.length === 0) {
await trx
.delete(siteResourceAiModels)
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
return;
}
const existing = await trx
.select({
modelId: siteResourceAiModels.modelId,
providerId: aiModels.providerId
})
.from(siteResourceAiModels)
.innerJoin(aiModels, eq(siteResourceAiModels.modelId, aiModels.modelId))
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
const allowed = new Set(selectProviderIds);
const toRemove = existing
.filter((row) => !allowed.has(row.providerId))
.map((row) => row.modelId);
if (toRemove.length > 0) {
await trx
.delete(siteResourceAiModels)
.where(
and(
eq(siteResourceAiModels.siteResourceId, siteResourceId),
inArray(siteResourceAiModels.modelId, toRemove)
)
);
}
}
export async function clearPublicResourceAiConfig(
resourceId: number,
trx: DbOrTrx = db
): Promise<void> {
await trx
.delete(resourceAiModels)
.where(eq(resourceAiModels.resourceId, resourceId));
await trx
.delete(resourceAiProviders)
.where(eq(resourceAiProviders.resourceId, resourceId));
}
export async function clearSiteResourceAiConfig(
siteResourceId: number,
trx: DbOrTrx = db
): Promise<void> {
await trx
.delete(siteResourceAiModels)
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
await trx
.delete(siteResourceAiProviders)
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
}
export async function listPublicResourceAiProviders(resourceId: number) {
return db
.select({
providerId: resourceAiProviders.providerId,
name: aiProviders.name,
type: aiProviders.type,
enabled: resourceAiProviders.enabled,
providerEnabled: aiProviders.enabled,
accessMode: resourceAiProviders.accessMode
})
.from(resourceAiProviders)
.innerJoin(
aiProviders,
eq(resourceAiProviders.providerId, aiProviders.providerId)
)
.where(eq(resourceAiProviders.resourceId, resourceId));
}
export async function listSiteResourceAiProviders(siteResourceId: number) {
return db
.select({
providerId: siteResourceAiProviders.providerId,
name: aiProviders.name,
type: aiProviders.type,
enabled: siteResourceAiProviders.enabled,
providerEnabled: aiProviders.enabled,
accessMode: siteResourceAiProviders.accessMode
})
.from(siteResourceAiProviders)
.innerJoin(
aiProviders,
eq(siteResourceAiProviders.providerId, aiProviders.providerId)
)
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
}
export type EffectiveAllowModel = {
modelId: number;
modelKey: string;
name: string;
providerId: number;
providerName: string;
};
export async function listEffectiveAllowModels(options: {
resourceId?: number;
siteResourceId?: number;
}): Promise<EffectiveAllowModel[]> {
if (
options.resourceId === undefined &&
options.siteResourceId === undefined
) {
return [];
}
const attachments =
options.resourceId !== undefined
? await listPublicResourceAiProviders(options.resourceId)
: await listSiteResourceAiProviders(options.siteResourceId!);
const activeAttachments = attachments.filter(
(a) => a.enabled && a.providerEnabled
);
if (activeAttachments.length === 0) {
return [];
}
const inheritProviderIds = activeAttachments
.filter((a) => a.accessMode === "inherit")
.map((a) => a.providerId);
const selectProviderIds = activeAttachments
.filter((a) => a.accessMode === "select")
.map((a) => a.providerId);
const providerNameById = new Map(
activeAttachments.map((a) => [a.providerId, a.name] as const)
);
const models: EffectiveAllowModel[] = [];
if (inheritProviderIds.length > 0) {
const rows = await db
.select({
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
name: aiModels.name,
providerId: aiModels.providerId
})
.from(aiModels)
.where(
and(
inArray(aiModels.providerId, inheritProviderIds),
eq(aiModels.enabled, true),
eq(aiModels.listType, "allow")
)
);
for (const row of rows) {
models.push({
...row,
providerName: providerNameById.get(row.providerId) ?? ""
});
}
}
if (selectProviderIds.length > 0) {
if (options.resourceId !== undefined) {
const rows = await db
.select({
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
name: aiModels.name,
providerId: aiModels.providerId
})
.from(resourceAiModels)
.innerJoin(
aiModels,
eq(resourceAiModels.modelId, aiModels.modelId)
)
.where(
and(
eq(resourceAiModels.resourceId, options.resourceId),
inArray(aiModels.providerId, selectProviderIds),
eq(resourceAiModels.listType, "allow"),
eq(aiModels.enabled, true)
)
);
for (const row of rows) {
models.push({
...row,
providerName: providerNameById.get(row.providerId) ?? ""
});
}
} else if (options.siteResourceId !== undefined) {
const rows = await db
.select({
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
name: aiModels.name,
providerId: aiModels.providerId
})
.from(siteResourceAiModels)
.innerJoin(
aiModels,
eq(siteResourceAiModels.modelId, aiModels.modelId)
)
.where(
and(
eq(
siteResourceAiModels.siteResourceId,
options.siteResourceId
),
inArray(aiModels.providerId, selectProviderIds),
eq(siteResourceAiModels.listType, "allow"),
eq(aiModels.enabled, true)
)
);
for (const row of rows) {
models.push({
...row,
providerName: providerNameById.get(row.providerId) ?? ""
});
}
}
}
models.sort((a, b) => {
const byProvider = a.providerName.localeCompare(
b.providerName,
undefined,
{
sensitivity: "base"
}
);
if (byProvider !== 0) {
return byProvider;
}
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
return models;
}
/**
* Model list APIs require an inference resource with at least one select-mode
* attached provider.
*/
export async function assertPublicModelListApiEligible(resource: {
resourceId: number;
mode: string;
}): Promise<string | null> {
if (resource.mode !== "inference") {
return "AI model lists are only supported on inference-mode resources";
}
const [row] = await db
.select({ providerId: resourceAiProviders.providerId })
.from(resourceAiProviders)
.where(
and(
eq(resourceAiProviders.resourceId, resource.resourceId),
eq(resourceAiProviders.accessMode, "select")
)
)
.limit(1);
if (!row) {
return "Set at least one attached AI provider to select mode before managing model lists";
}
return null;
}
export async function assertSiteModelListApiEligible(siteResource: {
siteResourceId: number;
mode: string;
}): Promise<string | null> {
if (siteResource.mode !== "inference") {
return "AI model lists are only supported on inference-mode resources";
}
const [row] = await db
.select({ providerId: siteResourceAiProviders.providerId })
.from(siteResourceAiProviders)
.where(
and(
eq(
siteResourceAiProviders.siteResourceId,
siteResource.siteResourceId
),
eq(siteResourceAiProviders.accessMode, "select")
)
)
.limit(1);
if (!row) {
return "Set at least one attached AI provider to select mode before managing model lists";
}
return null;
}
/**
* Resource model entries must belong to select-mode attached providers, and
* listType must match the provider catalog entry (allow→allow, block→block).
*/
export async function assertPublicResourceModelEntriesValid(input: {
orgId: string;
resourceId: number;
models: ResourceAiModelEntry[];
}): Promise<string | null> {
const uniqueModels = dedupeModelEntries(input.models);
if (uniqueModels.length === 0) {
return null;
}
const attachments = await db
.select({
providerId: resourceAiProviders.providerId,
accessMode: resourceAiProviders.accessMode,
enabled: resourceAiProviders.enabled
})
.from(resourceAiProviders)
.innerJoin(
aiProviders,
eq(resourceAiProviders.providerId, aiProviders.providerId)
)
.where(
and(
eq(resourceAiProviders.resourceId, input.resourceId),
eq(aiProviders.orgId, input.orgId)
)
);
return assertModelEntriesValid({
orgId: input.orgId,
modelEntries: uniqueModels,
attachments,
resourceLabel: "resource"
});
}
export async function assertSiteResourceModelEntriesValid(input: {
orgId: string;
siteResourceId: number;
models: ResourceAiModelEntry[];
}): Promise<string | null> {
const uniqueModels = dedupeModelEntries(input.models);
if (uniqueModels.length === 0) {
return null;
}
const attachments = await db
.select({
providerId: siteResourceAiProviders.providerId,
accessMode: siteResourceAiProviders.accessMode,
enabled: siteResourceAiProviders.enabled
})
.from(siteResourceAiProviders)
.innerJoin(
aiProviders,
eq(siteResourceAiProviders.providerId, aiProviders.providerId)
)
.where(
and(
eq(
siteResourceAiProviders.siteResourceId,
input.siteResourceId
),
eq(aiProviders.orgId, input.orgId)
)
);
return assertModelEntriesValid({
orgId: input.orgId,
modelEntries: uniqueModels,
attachments,
resourceLabel: "site resource"
});
}
function dedupeModelEntries(
models: ResourceAiModelEntry[]
): ResourceAiModelEntry[] {
const byModelId = new Map(
models.map((m) => [m.modelId, m.listType] as const)
);
return [...byModelId.entries()].map(([modelId, listType]) => ({
modelId,
listType
}));
}
async function assertModelEntriesValid(input: {
orgId: string;
modelEntries: ResourceAiModelEntry[];
attachments: ResourceAiProviderAttachment[];
resourceLabel: string;
}): Promise<string | null> {
const selectProviderIds = input.attachments
.filter((a) => a.accessMode === "select")
.map((a) => a.providerId);
if (selectProviderIds.length === 0) {
return "Set at least one attached AI provider to select mode before managing model lists";
}
const modelIds = input.modelEntries.map((m) => m.modelId);
const catalogRows = await db
.select({
modelId: aiModels.modelId,
listType: aiModels.listType,
providerId: aiModels.providerId,
enabled: aiModels.enabled
})
.from(aiModels)
.innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId))
.where(
and(
inArray(aiModels.modelId, modelIds),
inArray(aiModels.providerId, selectProviderIds),
eq(aiProviders.orgId, input.orgId)
)
);
if (catalogRows.length !== modelIds.length) {
return `One or more model IDs do not exist or do not belong to a select-mode provider on this ${input.resourceLabel}`;
}
const catalogById = new Map(catalogRows.map((row) => [row.modelId, row]));
for (const entry of input.modelEntries) {
const catalog = catalogById.get(entry.modelId);
if (!catalog) {
return `One or more model IDs do not exist or do not belong to a select-mode provider on this ${input.resourceLabel}`;
}
if (catalog.listType !== entry.listType) {
return `Model ${entry.modelId} must use listType "${catalog.listType}" to match the provider catalog entry`;
}
if (!catalog.enabled) {
return `Model ${entry.modelId} is disabled on its provider`;
}
}
return null;
}
+538
View File
@@ -0,0 +1,538 @@
import type { AiCapability } from "@server/lib/aiCapabilities";
import { sseDataFrames, tryParseJson } from "@server/lib/aiUsageExtraction";
import logger from "@server/logger";
// Uniform, capability-agnostic representation of a chat message, used so
// the AI session log can be searched/displayed the same way regardless of
// which provider/capability produced it. Content is flattened to plain text
// - non-text parts (images, tool calls/results) are rendered as readable
// placeholders rather than preserved as structured data, which is enough for
// a transcript-style replay view without a per-capability renderer.
export type NormalizedRole = "system" | "user" | "assistant" | "tool";
export type NormalizedAiMessage = {
role: NormalizedRole;
content: string;
};
function normalizeRole(role: unknown): NormalizedRole {
if (
role === "system" ||
role === "user" ||
role === "assistant" ||
role === "tool"
) {
return role;
}
if (role === "model") return "assistant"; // Gemini
if (role === "function") return "tool"; // OpenAI legacy function role
return "user";
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value ?? {});
} catch {
return "";
}
}
/**
* Flattens one message "part"/"block" (OpenAI content parts, Anthropic
* content blocks, Gemini parts, Bedrock converse content blocks - they all
* follow the same rough shape) into readable text.
*/
function flattenContentPart(part: unknown): string {
if (typeof part === "string") return part;
if (part == null || typeof part !== "object") return "";
const p = part as Record<string, unknown>;
if (typeof p.text === "string") return p.text;
if (
p.type === "image_url" ||
p.type === "image" ||
p.type === "input_image" ||
p.type === "output_image" ||
"inlineData" in p
) {
return "[image]";
}
// Anthropic-style tool_use / tool_result blocks
if (p.type === "tool_use") {
const name = typeof p.name === "string" ? p.name : "tool";
return `[tool_call: ${name}(${safeJsonStringify(p.input)})]`;
}
if (p.type === "tool_result") {
const content = p.content;
const text =
typeof content === "string"
? content
: Array.isArray(content)
? flattenContentParts(content)
: "";
return `[tool_result: ${text}]`;
}
// Gemini-style functionCall / functionResponse parts
if (p.functionCall && typeof p.functionCall === "object") {
const fc = p.functionCall as Record<string, unknown>;
return `[tool_call: ${fc.name}(${safeJsonStringify(fc.args)})]`;
}
if (p.functionResponse && typeof p.functionResponse === "object") {
const fr = p.functionResponse as Record<string, unknown>;
return `[tool_result: ${fr.name}(${safeJsonStringify(fr.response)})]`;
}
// Bedrock converse-style toolUse / toolResult content blocks
if (p.toolUse && typeof p.toolUse === "object") {
const tu = p.toolUse as Record<string, unknown>;
return `[tool_call: ${tu.name}(${safeJsonStringify(tu.input)})]`;
}
if (p.toolResult && typeof p.toolResult === "object") {
const tr = p.toolResult as Record<string, unknown>;
const content = tr.content;
const text = Array.isArray(content) ? flattenContentParts(content) : "";
return `[tool_result: ${text}]`;
}
return "";
}
function flattenContentParts(parts: unknown[]): string {
return parts.map(flattenContentPart).join("");
}
function flattenContent(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) return flattenContentParts(content);
return "";
}
/**
* Best-effort scan for every `"text":"..."` JSON string value in raw text,
* concatenated in order. Fallback for streaming formats we can't fully parse
* as JSON/SSE (Gemini's array-JSON stream, Bedrock's binary event-stream
* framing) - same spirit as aiUsageExtraction's scanNumericFields.
*/
function scanTextFragments(text: string): string {
const out: string[] = [];
const re = /"text"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
let match: RegExpExecArray | null;
while ((match = re.exec(text)) !== null) {
try {
out.push(JSON.parse(`"${match[1]}"`));
} catch {
out.push(match[1]);
}
}
return out.join("");
}
// ---------------------------------------------------------------------------
// Request (input) normalizers - operate on the already-parsed outbound body.
// ---------------------------------------------------------------------------
function normalizeOpenAiChatRequest(body: any): NormalizedAiMessage[] {
const messages = Array.isArray(body?.messages) ? body.messages : [];
return messages.map((m: any) => ({
role: normalizeRole(m?.role),
content: flattenContent(m?.content)
}));
}
function normalizeOpenAiResponsesRequest(body: any): NormalizedAiMessage[] {
const out: NormalizedAiMessage[] = [];
if (typeof body?.instructions === "string" && body.instructions) {
out.push({ role: "system", content: body.instructions });
}
const input = body?.input;
if (typeof input === "string") {
out.push({ role: "user", content: input });
} else if (Array.isArray(input)) {
for (const item of input) {
if (item?.role) {
out.push({
role: normalizeRole(item.role),
content: flattenContent(item.content)
});
} else if (typeof item?.type === "string") {
out.push({ role: "tool", content: `[${item.type}]` });
}
}
}
return out;
}
function normalizeAnthropicRequest(body: any): NormalizedAiMessage[] {
const out: NormalizedAiMessage[] = [];
if (body?.system) {
const sys = flattenContent(body.system);
if (sys) out.push({ role: "system", content: sys });
}
const messages = Array.isArray(body?.messages) ? body.messages : [];
for (const m of messages) {
out.push({
role: normalizeRole(m?.role),
content: flattenContent(m?.content)
});
}
return out;
}
function normalizeGeminiRequest(body: any): NormalizedAiMessage[] {
const out: NormalizedAiMessage[] = [];
const sysParts = body?.systemInstruction?.parts;
if (Array.isArray(sysParts)) {
const text = flattenContentParts(sysParts);
if (text) out.push({ role: "system", content: text });
}
const contents = Array.isArray(body?.contents) ? body.contents : [];
for (const c of contents) {
out.push({
role: normalizeRole(c?.role),
content: Array.isArray(c?.parts) ? flattenContentParts(c.parts) : ""
});
}
return out;
}
function normalizeBedrockConverseRequest(body: any): NormalizedAiMessage[] {
const out: NormalizedAiMessage[] = [];
if (Array.isArray(body?.system)) {
const text = flattenContentParts(body.system);
if (text) out.push({ role: "system", content: text });
}
const messages = Array.isArray(body?.messages) ? body.messages : [];
for (const m of messages) {
out.push({
role: normalizeRole(m?.role),
content: Array.isArray(m?.content)
? flattenContentParts(m.content)
: ""
});
}
return out;
}
/**
* bedrock_model_invoke and google_raw_predict are passthroughs - the body
* shape depends entirely on the underlying model, not the capability. Try
* the two shapes we're most likely to see (Anthropic Claude, then plain
* OpenAI-style) and give up otherwise, same fallback spirit
* aiUsageExtraction.ts uses for these two capabilities' usage extraction.
*/
function normalizeBestEffortRequest(body: any): NormalizedAiMessage[] | null {
if (!Array.isArray(body?.messages)) return null;
const looksAnthropicShaped = body.messages.some((m: any) =>
Array.isArray(m?.content)
);
return looksAnthropicShaped
? normalizeAnthropicRequest(body)
: normalizeOpenAiChatRequest(body);
}
// ---------------------------------------------------------------------------
// Response (output) normalizers - operate on the raw response text, which
// may be a single JSON document (non-streaming) or provider-framed streaming
// text (SSE `data:` frames, a JSON-array stream, or binary event-stream
// framing with JSON payloads embedded in it).
// ---------------------------------------------------------------------------
function normalizeOpenAiChatResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
if (isStream) {
let role: unknown = "assistant";
let content = "";
let found = false;
for (const frame of sseDataFrames(text)) {
const delta = tryParseJson(frame)?.choices?.[0]?.delta;
if (!delta) continue;
found = true;
if (typeof delta.role === "string") role = delta.role;
if (typeof delta.content === "string") content += delta.content;
}
return found ? [{ role: normalizeRole(role), content }] : null;
}
const message = tryParseJson(text)?.choices?.[0]?.message;
if (!message) return null;
return [
{
role: normalizeRole(message.role),
content: flattenContent(message.content)
}
];
}
function extractOpenAiResponsesOutputText(response: any): string | null {
if (typeof response?.output_text === "string") return response.output_text;
const output = Array.isArray(response?.output) ? response.output : [];
const pieces: string[] = [];
for (const item of output) {
if (item?.type === "message" && Array.isArray(item.content)) {
pieces.push(flattenContentParts(item.content));
}
}
return pieces.length > 0 ? pieces.join("") : null;
}
function normalizeOpenAiResponsesResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
if (isStream) {
let content = "";
let found = false;
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (!parsed) continue;
if (
parsed.type === "response.output_text.delta" &&
typeof parsed.delta === "string"
) {
content += parsed.delta;
found = true;
} else if (
parsed.type === "response.completed" &&
parsed.response
) {
const outputText = extractOpenAiResponsesOutputText(
parsed.response
);
if (outputText != null) {
content = outputText;
found = true;
}
}
}
return found ? [{ role: "assistant", content }] : null;
}
const parsed = tryParseJson(text);
const outputText = extractOpenAiResponsesOutputText(
parsed?.response ?? parsed
);
return outputText != null
? [{ role: "assistant", content: outputText }]
: null;
}
function normalizeAnthropicResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
if (isStream) {
let role: unknown = "assistant";
let content = "";
let found = false;
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (!parsed) continue;
if (parsed.type === "message_start" && parsed.message?.role) {
role = parsed.message.role;
}
if (
parsed.type === "content_block_start" &&
parsed.content_block?.type === "tool_use"
) {
const name = parsed.content_block.name ?? "tool";
content += `[tool_call: ${name}]`;
found = true;
}
if (
parsed.type === "content_block_delta" &&
typeof parsed.delta?.text === "string"
) {
content += parsed.delta.text;
found = true;
}
}
return found ? [{ role: normalizeRole(role), content }] : null;
}
const parsed = tryParseJson(text);
if (!parsed || !Array.isArray(parsed.content)) return null;
return [
{
role: normalizeRole(parsed.role ?? "assistant"),
content: flattenContentParts(parsed.content)
}
];
}
function geminiCandidateParts(node: any): string {
const parts = node?.candidates?.[0]?.content?.parts;
return Array.isArray(parts) ? flattenContentParts(parts) : "";
}
function normalizeGeminiResponse(
text: string,
_isStream: boolean
): NormalizedAiMessage[] | null {
const frames = sseDataFrames(text);
let content = "";
let role: unknown = "model";
let found = false;
if (frames.length > 0) {
for (const frame of frames) {
const parsed = tryParseJson(frame);
const piece = geminiCandidateParts(parsed);
if (piece) {
content += piece;
found = true;
}
const r = parsed?.candidates?.[0]?.content?.role;
if (r) role = r;
}
} else {
const parsed = tryParseJson(text);
if (Array.isArray(parsed)) {
for (const chunk of parsed) {
const piece = geminiCandidateParts(chunk);
if (piece) {
content += piece;
found = true;
}
const r = chunk?.candidates?.[0]?.content?.role;
if (r) role = r;
}
} else if (parsed) {
const piece = geminiCandidateParts(parsed);
if (piece) {
content = piece;
found = true;
}
const r = parsed?.candidates?.[0]?.content?.role;
if (r) role = r;
}
}
if (!found) {
const scanned = scanTextFragments(text);
return scanned
? [{ role: normalizeRole(role), content: scanned }]
: null;
}
return [{ role: normalizeRole(role), content }];
}
function normalizeBedrockConverseResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
if (!isStream) {
const message = tryParseJson(text)?.output?.message;
if (!message) return null;
return [
{
role: normalizeRole(message.role ?? "assistant"),
content: Array.isArray(message.content)
? flattenContentParts(message.content)
: ""
}
];
}
// converse-stream uses AWS's binary event-stream framing, but the JSON
// payload of each event survives intact inside it (same assumption
// aiUsageExtraction.ts makes for usage) - scan for the text pieces.
const scanned = scanTextFragments(text);
return scanned ? [{ role: "assistant", content: scanned }] : null;
}
function normalizeBedrockModelInvokeResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
const anthropicStyle = normalizeAnthropicResponse(text, isStream);
if (anthropicStyle) return anthropicStyle;
const scanned = scanTextFragments(text);
return scanned ? [{ role: "assistant", content: scanned }] : null;
}
function normalizeGoogleRawPredictResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
const anthropicStyle = normalizeAnthropicResponse(text, isStream);
if (anthropicStyle) return anthropicStyle;
const scanned = scanTextFragments(text);
return scanned ? [{ role: "assistant", content: scanned }] : null;
}
const REQUEST_NORMALIZERS: Record<
AiCapability,
(body: any) => NormalizedAiMessage[] | null
> = {
openai_chat: normalizeOpenAiChatRequest,
openai_responses: normalizeOpenAiResponsesRequest,
anthropic_messages: normalizeAnthropicRequest,
gemini_generate_content: normalizeGeminiRequest,
google_generate_content: normalizeGeminiRequest,
google_raw_predict: normalizeBestEffortRequest,
bedrock_model_invoke: normalizeBestEffortRequest,
bedrock_converse: normalizeBedrockConverseRequest
};
const RESPONSE_NORMALIZERS: Record<
AiCapability,
(text: string, isStream: boolean) => NormalizedAiMessage[] | null
> = {
openai_chat: normalizeOpenAiChatResponse,
openai_responses: normalizeOpenAiResponsesResponse,
anthropic_messages: normalizeAnthropicResponse,
gemini_generate_content: normalizeGeminiResponse,
google_generate_content: normalizeGeminiResponse,
google_raw_predict: normalizeGoogleRawPredictResponse,
bedrock_model_invoke: normalizeBedrockModelInvokeResponse,
bedrock_converse: normalizeBedrockConverseResponse
};
/**
* Normalizes an outbound AI gateway request body into a uniform message
* transcript, regardless of capability/provider. Returns null if the body
* doesn't contain any recognizable messages (or parsing failed) - callers
* should fall back to showing the raw request body.
*/
export function normalizeAiRequest(
capability: AiCapability,
body: unknown
): NormalizedAiMessage[] | null {
try {
const result = REQUEST_NORMALIZERS[capability](body);
return result && result.length > 0 ? result : null;
} catch (error) {
logger.debug("Failed to normalize AI request messages", {
capability,
error
});
return null;
}
}
/**
* Normalizes a completed (non-streaming or fully-accumulated streaming) AI
* gateway response into a uniform message transcript. Returns null if
* nothing recognizable could be extracted - callers should fall back to
* showing the raw response body.
*/
export function normalizeAiResponse(
capability: AiCapability,
responseText: string,
isStream: boolean
): NormalizedAiMessage[] | null {
try {
const result = RESPONSE_NORMALIZERS[capability](responseText, isStream);
return result && result.length > 0 ? result : null;
} catch (error) {
logger.debug("Failed to normalize AI response messages", {
capability,
error
});
return null;
}
}
+293
View File
@@ -0,0 +1,293 @@
import fs from "node:fs";
import axios from "axios";
import { z } from "zod";
import config from "@server/lib/config";
import logger from "@server/logger";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
export const CATALOG_PROVIDERS = [
"openai",
"anthropic",
"gemini",
"vertex",
"azure",
"bedrock"
] as const;
export type CatalogProvider = (typeof CATALOG_PROVIDERS)[number];
const CATALOG_PROVIDER_SET = new Set<string>(CATALOG_PROVIDERS);
// Each of our provider types maps to at most one catalog provider. Provider
// types that proxy arbitrary underlying models (openRouter, vercelAiGateway,
// custom) have no mapping.
const PROVIDER_CATALOG_MAP: Record<
Exclude<AiProviderType, "custom">,
CatalogProvider | null
> = {
openai: "openai",
anthropic: "anthropic",
googleGemini: "gemini",
vertexAi: "vertex",
bedrock: "bedrock",
microsoftFoundry: "azure",
openRouter: null,
vercelAiGateway: null
};
export function getCatalogProviderForType(
type: AiProviderType
): CatalogProvider | null {
if (type === "custom") {
return null;
}
return PROVIDER_CATALOG_MAP[type];
}
export type AiModelCatalogEntry = {
provider: CatalogProvider;
model: string;
pricing: {
in: number | null;
out: number | null;
cache: number | null;
reasoning: number | null;
};
};
const catalogEntrySchema = z.object({
model: z.string(),
provider: z.string(),
pricing: z
.object({
in: z.number().nullable().optional(),
out: z.number().nullable().optional(),
cache: z.number().nullable().optional(),
reasoning: z.number().nullable().optional()
})
.optional()
});
const catalogFileSchema = z.object({
data: z.array(catalogEntrySchema).optional().default([])
});
type RawCatalogEntry = z.infer<typeof catalogEntrySchema>;
function normalizeCatalogProvider(raw: string): CatalogProvider | null {
if (CATALOG_PROVIDER_SET.has(raw)) {
return raw as CatalogProvider;
}
if (raw.startsWith("bedrock")) {
return "bedrock";
}
if (raw.startsWith("vertex")) {
return "vertex";
}
if (raw.startsWith("azure")) {
return "azure";
}
return null;
}
function normalizeEntry(raw: RawCatalogEntry): AiModelCatalogEntry | null {
const provider = normalizeCatalogProvider(raw.provider);
if (!provider) {
return null;
}
if (!raw.model) {
return null;
}
return {
provider,
model: raw.model,
pricing: {
in: raw.pricing?.in ?? null,
out: raw.pricing?.out ?? null,
cache: raw.pricing?.cache ?? null,
reasoning: raw.pricing?.reasoning ?? null
}
};
}
function providerKey(provider: CatalogProvider, key: string): string {
return `${provider}\0${key}`;
}
export class AiModelCatalog {
private entries: AiModelCatalogEntry[] = [];
private byProvider = new Map<CatalogProvider, AiModelCatalogEntry[]>();
private byProviderAndKey = new Map<string, AiModelCatalogEntry>();
private byKey = new Map<string, AiModelCatalogEntry[]>();
private refreshTimer: NodeJS.Timeout | null = null;
/**
* Loads the catalog into memory and schedules periodic background refreshes.
* Call once at server startup.
*/
async init(): Promise<void> {
await this.refresh();
this.scheduleNextRefresh();
}
/** Exact lookup by catalog provider and model key. */
get(
provider: CatalogProvider,
key: string
): AiModelCatalogEntry | undefined {
return this.byProviderAndKey.get(providerKey(provider, key));
}
/** All models for a catalog provider. */
list(provider: CatalogProvider): AiModelCatalogEntry[] {
return this.byProvider.get(provider) ?? [];
}
/** All catalog entries that share a model key, across providers. */
listByKey(key: string): AiModelCatalogEntry[] {
return this.byKey.get(key) ?? [];
}
/** Full in-memory catalog. */
getAll(): AiModelCatalogEntry[] {
return this.entries;
}
private setEntries(entries: AiModelCatalogEntry[]): void {
const byProvider = new Map<CatalogProvider, AiModelCatalogEntry[]>();
const byProviderAndKey = new Map<string, AiModelCatalogEntry>();
const byKey = new Map<string, AiModelCatalogEntry[]>();
for (const entry of entries) {
const list = byProvider.get(entry.provider) ?? [];
list.push(entry);
byProvider.set(entry.provider, list);
const mapKey = providerKey(entry.provider, entry.model);
if (!byProviderAndKey.has(mapKey)) {
byProviderAndKey.set(mapKey, entry);
}
const keyList = byKey.get(entry.model) ?? [];
keyList.push(entry);
byKey.set(entry.model, keyList);
}
this.entries = entries;
this.byProvider = byProvider;
this.byProviderAndKey = byProviderAndKey;
this.byKey = byKey;
}
private async fetchFromFile(
filePath: string
): Promise<AiModelCatalogEntry[] | null> {
try {
if (!fs.existsSync(filePath)) {
logger.warn(
`AI model catalog file not found at ${filePath}; cost calculation will fall back to unknown pricing`
);
return null;
}
const raw = fs.readFileSync(filePath, "utf-8");
const result = catalogFileSchema.safeParse(JSON.parse(raw));
if (!result.success) {
logger.warn(
`AI model catalog file at ${filePath} failed validation: ${result.error.message}`
);
return null;
}
return result.data.data
.map(normalizeEntry)
.filter((e): e is AiModelCatalogEntry => e != null);
} catch (error) {
logger.warn("Failed to read AI model catalog file", { error });
return null;
}
}
private async fetchFromUpstream(
upstreamUrl: string
): Promise<AiModelCatalogEntry[] | null> {
try {
const res = await axios.get(upstreamUrl, { timeout: 15_000 });
const result = catalogFileSchema.safeParse(res.data);
if (!result.success) {
logger.warn(
`AI model catalog response from ${upstreamUrl} failed validation: ${result.error.message}`
);
return null;
}
return result.data.data
.map(normalizeEntry)
.filter((e): e is AiModelCatalogEntry => e != null);
} catch (error: any) {
logger.warn(
`Failed to fetch AI model catalog from ${upstreamUrl}: ${error.message || error}`
);
return null;
}
}
private async refresh(): Promise<void> {
const { file, merge_file, upstream_url } =
config.getRawConfig().ai.model_catalog;
const fetched = file
? await this.fetchFromFile(file)
: await this.fetchFromUpstream(upstream_url);
if (!fetched) {
logger.debug(
"AI model catalog refresh failed; keeping previously loaded catalog in memory"
);
return;
}
let merged = fetched;
if (merge_file) {
const mergeEntries = await this.fetchFromFile(merge_file);
if (mergeEntries) {
// Entries from the base catalog take precedence; the merge
// file only adds models not already present.
merged = [...fetched, ...mergeEntries];
}
}
this.setEntries(merged);
logger.debug(
`AI model catalog refreshed: ${this.entries.length} models loaded`
);
}
private scheduleNextRefresh(): void {
const { refresh_interval_min_hours, refresh_interval_max_hours } =
config.getRawConfig().ai.model_catalog;
// Jittered rather than fixed so that many self-hosted instances don't
// all hit the upstream catalog endpoint at the same moment.
const minMs = refresh_interval_min_hours * 60 * 60 * 1000;
const maxMs = refresh_interval_max_hours * 60 * 60 * 1000;
const delayMs = minMs + Math.random() * Math.max(0, maxMs - minMs);
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(async () => {
await this.refresh();
this.scheduleNextRefresh();
}, delayMs);
}
}
export const aiModelCatalog = new AiModelCatalog();
/**
* Loads the AI model pricing catalog into memory and schedules periodic
* background refreshes. Call once at server startup.
*/
export async function initAiModelCatalog(): Promise<void> {
await aiModelCatalog.init();
}
+101
View File
@@ -0,0 +1,101 @@
const modelKeyRegexCache = new Map<string, RegExp>();
export function isModelKeyPattern(key: string): boolean {
return key.includes("*") || key.includes("?");
}
function getModelKeyRegex(pattern: string): RegExp {
let regex = modelKeyRegexCache.get(pattern);
if (!regex) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
regex = new RegExp(
`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`
);
modelKeyRegexCache.set(pattern, regex);
}
return regex;
}
export function modelKeyMatches(
pattern: string,
requestedModel: string
): boolean {
return getModelKeyRegex(pattern).test(requestedModel);
}
function wildcardCharCount(key: string): number {
let count = 0;
for (const char of key) {
if (char === "*" || char === "?") {
count += 1;
}
}
return count;
}
function literalLength(key: string): number {
return key.replace(/[*?]/g, "").length;
}
/**
* Sort comparator: more specific patterns sort before less specific ones
* (negative when `a` is more specific than `b`).
*
* 1. Exact keys beat patterns
* 2. Fewer wildcard characters win
* 3. Longer literal length wins
*/
export function compareModelKeySpecificity(a: string, b: string): number {
const aIsPattern = isModelKeyPattern(a);
const bIsPattern = isModelKeyPattern(b);
if (aIsPattern !== bIsPattern) {
return aIsPattern ? 1 : -1;
}
const wildcardDiff = wildcardCharCount(a) - wildcardCharCount(b);
if (wildcardDiff !== 0) {
return wildcardDiff;
}
return literalLength(b) - literalLength(a);
}
/**
* Provider-layer policy: empty allowlist denies all. Blocklist only applies
* after an allow match.
*/
export function isAllowedByLists(
requested: string,
allows: string[],
blocks: string[]
): boolean {
if (allows.length === 0) {
return false;
}
if (!allows.some((pattern) => modelKeyMatches(pattern, requested))) {
return false;
}
if (blocks.some((pattern) => modelKeyMatches(pattern, requested))) {
return false;
}
return true;
}
/**
* Among allow patterns that match `requested`, return the most specific one,
* or null if none match.
*/
export function mostSpecificMatchingAllow(
requested: string,
allows: string[]
): string | null {
const matching = allows.filter((pattern) =>
modelKeyMatches(pattern, requested)
);
if (matching.length === 0) {
return null;
}
matching.sort(compareModelKeySpecificity);
return matching[0];
}
+148
View File
@@ -0,0 +1,148 @@
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
import type { AiUsage } from "@server/lib/aiUsageExtraction";
import {
aiModelCatalog,
getCatalogProviderForType,
type AiModelCatalogEntry,
type CatalogProvider
} from "@server/lib/aiModelCatalog";
export type AiModelPricing = {
inputCostPerToken: number | null;
outputCostPerToken: number | null;
cacheReadInputTokenCost: number | null;
outputCostPerReasoningToken: number | null;
// True when the match came from a different catalog provider than the
// one mapped to this provider's type (e.g. an openRouter/custom model
// id that only matched a global search across every provider). Costs
// found this way are a best-effort approximation, not a guarantee the
// upstream provider bills at the same rate.
approximate: boolean;
};
function stripVendorPrefix(modelId: string): string | null {
const idx = modelId.indexOf("/");
if (idx === -1 || idx === modelId.length - 1) {
return null;
}
return modelId.slice(idx + 1);
}
function toPricing(
entry: AiModelCatalogEntry,
approximate: boolean
): AiModelPricing {
return {
inputCostPerToken: entry.pricing.in,
outputCostPerToken: entry.pricing.out,
cacheReadInputTokenCost: entry.pricing.cache,
outputCostPerReasoningToken: entry.pricing.reasoning,
approximate
};
}
function findEntry(
modelId: string,
provider: CatalogProvider | null
): AiModelCatalogEntry | null {
const candidates = [modelId, stripVendorPrefix(modelId)].filter(
(v): v is string => v != null
);
for (const key of candidates) {
if (provider) {
const match = aiModelCatalog.get(provider, key);
if (match) {
return match;
}
continue;
}
const match = aiModelCatalog.listByKey(key)[0];
if (match) {
return match;
}
}
return null;
}
/**
* Looks up per-token pricing for a model, scoped first to the catalog
* provider that corresponds to our provider type, then falling back to a
* global search across every provider (marked `approximate`) for provider
* types that proxy arbitrary underlying models.
*/
export function getModelPricing(
providerType: AiProviderType,
modelId: string | undefined
): AiModelPricing | null {
if (!modelId) {
return null;
}
const catalogProvider = getCatalogProviderForType(providerType);
if (catalogProvider) {
const scoped = findEntry(modelId, catalogProvider);
if (scoped) {
return toPricing(scoped, false);
}
}
const fallback = findEntry(modelId, null);
if (fallback) {
return toPricing(fallback, true);
}
return null;
}
export type AiCostBreakdown = {
promptCost: number;
cacheReadCost: number;
cacheWriteCost: number;
completionCost: number;
reasoningCost: number;
totalCost: number;
};
/**
* Computes a $ cost breakdown for a usage record given a model's pricing.
* Cache writes and reasoning tokens fall back to the normal input/output
* rate respectively when the catalog has no dedicated rate for them (the
* catalog has no cache-write field at all, and only some models report a
* distinct reasoning rate).
*/
export function calculateAiCost(
pricing: AiModelPricing | null,
usage: AiUsage
): AiCostBreakdown | null {
if (!pricing) {
return null;
}
const inputRate = pricing.inputCostPerToken ?? 0;
const outputRate = pricing.outputCostPerToken ?? 0;
const cacheReadRate = pricing.cacheReadInputTokenCost ?? inputRate;
const reasoningRate = pricing.outputCostPerReasoningToken ?? outputRate;
const promptCost = usage.promptTokens * inputRate;
const cacheReadCost = usage.cacheReadTokens * cacheReadRate;
const cacheWriteCost = usage.cacheWriteTokens * inputRate;
const completionCost = usage.completionTokens * outputRate;
const reasoningCost = usage.reasoningTokens * reasoningRate;
return {
promptCost,
cacheReadCost,
cacheWriteCost,
completionCost,
reasoningCost,
totalCost:
promptCost +
cacheReadCost +
cacheWriteCost +
completionCost +
reasoningCost
};
}
+186
View File
@@ -0,0 +1,186 @@
import { decrypt, encrypt } from "@server/lib/crypto";
import {
parseCapabilities,
type AiCapability
} from "@server/lib/aiCapabilities";
import { stripVirtualApiKeyAuthHeaders } from "@app/lib/virtualApiKeyFormat";
import {
AI_PROVIDER_AUTH_TYPES,
AI_PROVIDER_DEFAULTS,
authTypeRequiresApiKey,
defaultsForProviderType,
providerRequiresUpstreamUrl,
type AiBudgetUnit,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
} from "@app/lib/aiProviderDefaults";
export {
AI_PROVIDER_AUTH_TYPES,
AI_PROVIDER_DEFAULTS,
authTypeRequiresApiKey,
defaultsForProviderType,
providerRequiresUpstreamUrl,
type AiBudgetUnit,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
};
const CONFLICTING_AUTH_HEADERS = [
"authorization",
"x-api-key",
"x-goog-api-key",
"cf-aig-authorization"
] as const;
export function resolveAiProviderCreateFields(input: {
type: AiProviderType;
upstreamUrl?: string | null;
authType?: AiProviderAuthType | null;
routingMode?: AiProviderRoutingMode | null;
}): {
upstreamUrl: string | null;
authType: AiProviderAuthType;
routingMode: AiProviderRoutingMode;
} {
const routingMode =
input.type === "custom" ? (input.routingMode ?? "url") : "url";
if (routingMode === "target") {
return {
upstreamUrl: null,
authType: input.authType ?? "bearer",
routingMode
};
}
if (input.type === "custom") {
return {
upstreamUrl: input.upstreamUrl ?? null,
authType: input.authType ?? "bearer",
routingMode
};
}
const defaults = AI_PROVIDER_DEFAULTS[input.type];
return {
upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl,
authType: input.authType ?? defaults.authType,
routingMode
};
}
export type AiProviderHeader = { name: string; value: string };
export function serializeAiProviderHeaders(
headers: AiProviderHeader[] | null | undefined,
secret: string
): string | null {
if (!headers || headers.length === 0) {
return null;
}
return encrypt(JSON.stringify(headers), secret);
}
export function parseAiProviderHeaders(
raw: string | null | undefined,
secret: string
): AiProviderHeader[] {
if (!raw) {
return [];
}
try {
const decrypted = decrypt(raw, secret);
const parsed = JSON.parse(decrypted);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter(
(h): h is AiProviderHeader =>
h != null &&
typeof h === "object" &&
typeof h.name === "string" &&
typeof h.value === "string"
);
} catch {
return [];
}
}
export function applyAiProviderCustomHeaders(
headers: Record<string, string>,
raw: string | null | undefined,
secret: string
): void {
for (const { name, value } of parseAiProviderHeaders(raw, secret)) {
headers[name] = value;
}
}
/**
* Apply provider auth to upstream headers.
* - Always strips Pangolin virtual API key credentials from client auth headers.
* - Injected modes: strip conflicting client auth headers, then set the provider key.
* - none: strip conflicting client auth headers, send no auth.
* - passthrough: leave remaining client auth headers as-is (after VAK strip).
*/
export function applyAiProviderAuthHeaders(
headers: Record<string, string>,
authType: AiProviderAuthType,
apiKey: string | null
): void {
stripVirtualApiKeyAuthHeaders(headers);
if (authType === "passthrough") {
return;
}
for (const name of CONFLICTING_AUTH_HEADERS) {
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === name) {
delete headers[key];
}
}
}
if (authType === "none") {
return;
}
if (!apiKey) {
throw new Error(`API key required for authType ${authType}`);
}
switch (authType) {
case "bearer":
headers["Authorization"] = `Bearer ${apiKey}`;
break;
case "x-api-key":
headers["x-api-key"] = apiKey;
break;
case "x-goog-api-key":
headers["x-goog-api-key"] = apiKey;
break;
case "hec":
headers["Authorization"] = `Splunk ${apiKey}`;
break;
case "cf-aig-authorization":
headers["cf-aig-authorization"] = `Bearer ${apiKey}`;
break;
}
}
export function resolveCapabilitiesForCreate(input: {
type: AiProviderType;
capabilities?: AiCapability[] | null;
}): AiCapability[] {
if (input.capabilities != null) {
return parseCapabilities(input.capabilities);
}
if (input.type === "custom") {
return [];
}
return [...AI_PROVIDER_DEFAULTS[input.type].capabilities];
}
+97
View File
@@ -0,0 +1,97 @@
import {
aiModelCatalog,
getCatalogProviderForType,
type CatalogProvider
} from "@server/lib/aiModelCatalog";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
function stripVendorPrefix(modelId: string): string | null {
const idx = modelId.indexOf("/");
if (idx === -1 || idx === modelId.length - 1) {
return null;
}
return modelId.slice(idx + 1);
}
function modelKeysToTry(modelId: string): string[] {
const keys = [modelId];
const stripped = stripVendorPrefix(modelId);
if (stripped) {
keys.push(stripped);
}
return keys;
}
function catalogOwnsModel(
catalogProvider: CatalogProvider,
modelId: string
): boolean {
for (const key of modelKeysToTry(modelId)) {
if (aiModelCatalog.get(catalogProvider, key)) {
return true;
}
}
return false;
}
function modelKnownInAnyCatalog(modelId: string): boolean {
for (const key of modelKeysToTry(modelId)) {
if (aiModelCatalog.listByKey(key).length > 0) {
return true;
}
}
return false;
}
/**
* How strongly a provider "owns" a requested model id via the known catalog.
*
* 2 - Typed provider whose catalog contains the model
* 1 - Aggregator/custom that can proxy a catalog-known model
* 0 - No ownership signal (typed miss, or unknown model on aggregator/custom)
*/
export function catalogOwnershipScore(
type: AiProviderType,
modelId: string
): number {
const catalogProvider = getCatalogProviderForType(type);
if (catalogProvider != null) {
return catalogOwnsModel(catalogProvider, modelId) ? 2 : 0;
}
return modelKnownInAnyCatalog(modelId) ? 1 : 0;
}
/**
* Prefer native vendor providers over aggregators over custom when catalog
* ownership is tied.
*
* 2 - Native typed provider (openai, anthropic, gemini, ...)
* 1 - Aggregator gateway (openRouter, vercelAiGateway)
* 0 - Custom
*/
export function providerClassRank(type: AiProviderType): number {
if (type === "custom") {
return 0;
}
if (type === "openRouter" || type === "vercelAiGateway") {
return 1;
}
return 2;
}
export function keepBestScored<T>(
items: T[],
scoreFn: (item: T) => number
): T[] {
if (items.length <= 1) {
return items;
}
let best = Number.NEGATIVE_INFINITY;
for (const item of items) {
const score = scoreFn(item);
if (score > best) {
best = score;
}
}
return items.filter((item) => scoreFn(item) === best);
}
+482
View File
@@ -0,0 +1,482 @@
import { encode } from "gpt-tokenizer";
import type { AiCapability } from "@server/lib/aiCapabilities";
import logger from "@server/logger";
export type AiUsage = {
// Input tokens billed at the normal input rate (i.e. NOT already
// covered by cacheReadTokens/cacheWriteTokens below).
promptTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
// Output tokens billed at the normal output rate (i.e. NOT already
// covered by reasoningTokens below).
completionTokens: number;
reasoningTokens: number;
// True when these numbers are our own best-guess estimate (the upstream
// response didn't report usage), rather than provider-reported figures.
estimated: boolean;
};
function emptyUsage(): AiUsage {
return {
promptTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
completionTokens: 0,
reasoningTokens: 0,
estimated: false
};
}
/**
* Scans raw (possibly binary-framed, e.g. Bedrock's vnd.amazon.eventstream)
* text for `"fieldName":123` occurrences and returns the last value seen for
* each field. Used as a best-effort fallback for response shapes we can't
* fully parse as JSON/SSE (streaming Bedrock, raw predict passthroughs).
*/
function scanNumericFields(
text: string,
fields: string[]
): Record<string, number> {
const out: Record<string, number> = {};
for (const field of fields) {
const re = new RegExp(`"${field}"\\s*:\\s*(\\d+)`, "g");
let match: RegExpExecArray | null;
while ((match = re.exec(text)) !== null) {
out[field] = Number(match[1]);
}
}
return out;
}
// Exported for reuse by server/lib/aiMessageNormalization.ts, which needs
// the same SSE-frame/JSON-parsing groundwork to extract message content
// instead of usage numbers.
export function sseDataFrames(text: string): string[] {
const frames: string[] = [];
for (const rawFrame of text.split(/\r?\n\r?\n/)) {
for (const line of rawFrame.split(/\r?\n/)) {
if (!line.startsWith("data:")) continue;
const data = line.slice("data:".length).trim();
if (data && data !== "[DONE]") {
frames.push(data);
}
}
}
return frames;
}
export function tryParseJson(text: string): any | null {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function extractOpenAiChat(text: string, isStream: boolean): AiUsage | null {
let usage: any = null;
if (isStream) {
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (parsed?.usage) {
usage = parsed.usage;
}
}
} else {
usage = tryParseJson(text)?.usage ?? null;
}
if (!usage) {
return null;
}
const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0;
const reasoningTokens =
usage.completion_tokens_details?.reasoning_tokens ?? 0;
return {
promptTokens: Math.max(0, (usage.prompt_tokens ?? 0) - cacheReadTokens),
cacheReadTokens,
cacheWriteTokens: 0,
completionTokens: Math.max(
0,
(usage.completion_tokens ?? 0) - reasoningTokens
),
reasoningTokens,
estimated: false
};
}
function extractOpenAiResponses(
text: string,
isStream: boolean
): AiUsage | null {
let usage: any = null;
if (isStream) {
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (
parsed?.type === "response.completed" &&
parsed?.response?.usage
) {
usage = parsed.response.usage;
} else if (parsed?.usage) {
usage = parsed.usage;
}
}
} else {
const parsed = tryParseJson(text);
usage = parsed?.usage ?? parsed?.response?.usage ?? null;
}
if (!usage) {
return null;
}
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? 0;
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? 0;
return {
promptTokens: Math.max(0, (usage.input_tokens ?? 0) - cacheReadTokens),
cacheReadTokens,
cacheWriteTokens: 0,
completionTokens: Math.max(
0,
(usage.output_tokens ?? 0) - reasoningTokens
),
reasoningTokens,
estimated: false
};
}
function extractAnthropicMessages(
text: string,
isStream: boolean
): AiUsage | null {
let inputTokens = 0;
let cacheReadTokens = 0;
let cacheWriteTokens = 0;
let outputTokens = 0;
let found = false;
const applyUsage = (usage: any) => {
if (!usage) return;
found = true;
if (typeof usage.input_tokens === "number") {
inputTokens = usage.input_tokens;
}
if (typeof usage.cache_read_input_tokens === "number") {
cacheReadTokens = usage.cache_read_input_tokens;
}
if (typeof usage.cache_creation_input_tokens === "number") {
cacheWriteTokens = usage.cache_creation_input_tokens;
}
if (typeof usage.output_tokens === "number") {
outputTokens = usage.output_tokens;
}
};
if (isStream) {
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (!parsed) continue;
applyUsage(parsed.message?.usage);
applyUsage(parsed.usage);
}
} else {
applyUsage(tryParseJson(text)?.usage);
}
if (!found) {
return null;
}
return {
promptTokens: inputTokens,
cacheReadTokens,
cacheWriteTokens,
completionTokens: outputTokens,
// Anthropic bills extended-thinking output at the normal output
// rate, so there's no separate reasoning bucket to report.
reasoningTokens: 0,
estimated: false
};
}
function extractGoogleGenerateContent(
text: string,
_isStream: boolean
): AiUsage | null {
// Both the plain-JSON-array stream format and the SSE (?alt=sse) format
// repeat a cumulative `usageMetadata` object per chunk; the regex scan
// below naturally picks up the last (most complete) one either way.
const fields = scanNumericFields(text, [
"promptTokenCount",
"candidatesTokenCount",
"cachedContentTokenCount",
"thoughtsTokenCount"
]);
if (fields.promptTokenCount === undefined) {
return null;
}
const cacheReadTokens = fields.cachedContentTokenCount ?? 0;
const reasoningTokens = fields.thoughtsTokenCount ?? 0;
return {
promptTokens: Math.max(0, fields.promptTokenCount - cacheReadTokens),
cacheReadTokens,
cacheWriteTokens: 0,
completionTokens: fields.candidatesTokenCount ?? 0,
reasoningTokens,
estimated: false
};
}
function extractBedrockConverse(
text: string,
_isStream: boolean
): AiUsage | null {
// Non-streaming responses are plain JSON; converse-stream frames the
// final `metadata` event's usage object inside binary event-stream
// framing, but the JSON text survives intact inside that binary
// envelope, so the same field scan works for both.
const parsed = tryParseJson(text);
const usage = parsed?.usage;
if (usage) {
const cacheReadTokens = usage.cacheReadInputTokens ?? 0;
return {
promptTokens: Math.max(
0,
(usage.inputTokens ?? 0) - cacheReadTokens
),
cacheReadTokens,
cacheWriteTokens: usage.cacheWriteInputTokens ?? 0,
completionTokens: usage.outputTokens ?? 0,
reasoningTokens: 0,
estimated: false
};
}
const fields = scanNumericFields(text, [
"inputTokens",
"outputTokens",
"cacheReadInputTokens",
"cacheWriteInputTokens"
]);
if (fields.inputTokens === undefined) {
return null;
}
const cacheReadTokens = fields.cacheReadInputTokens ?? 0;
return {
promptTokens: Math.max(0, fields.inputTokens - cacheReadTokens),
cacheReadTokens,
cacheWriteTokens: fields.cacheWriteInputTokens ?? 0,
completionTokens: fields.outputTokens ?? 0,
reasoningTokens: 0,
estimated: false
};
}
function extractBedrockModelInvoke(
text: string,
_isStream: boolean,
headers: Headers
): AiUsage | null {
// Non-streaming invoke reports counts via response headers regardless
// of the underlying model's payload format.
const headerInput = headers.get("x-amzn-bedrock-input-token-count");
const headerOutput = headers.get("x-amzn-bedrock-output-token-count");
if (headerInput !== null || headerOutput !== null) {
return {
promptTokens: Number(headerInput ?? 0),
cacheReadTokens: 0,
cacheWriteTokens: 0,
completionTokens: Number(headerOutput ?? 0),
reasoningTokens: 0,
estimated: false
};
}
// invoke-with-response-stream has no equivalent headers; the model's
// own usage shape (frequently Anthropic-style on Bedrock) is embedded
// inside binary event-stream framing, so fall back to a couple of
// known field-name shapes via regex.
const anthropicStyle = extractAnthropicMessages(text, true);
if (anthropicStyle) {
return anthropicStyle;
}
const fields = scanNumericFields(text, [
"inputTokenCount",
"outputTokenCount"
]);
if (fields.inputTokenCount === undefined) {
return null;
}
return {
promptTokens: fields.inputTokenCount,
cacheReadTokens: 0,
cacheWriteTokens: 0,
completionTokens: fields.outputTokenCount ?? 0,
reasoningTokens: 0,
estimated: false
};
}
const EXTRACTORS: Record<
AiCapability,
(text: string, isStream: boolean, headers: Headers) => AiUsage | null
> = {
openai_chat: extractOpenAiChat,
openai_responses: extractOpenAiResponses,
anthropic_messages: extractAnthropicMessages,
gemini_generate_content: extractGoogleGenerateContent,
google_generate_content: extractGoogleGenerateContent,
// rawPredict is a passthrough to whatever the underlying publisher
// model speaks (often Anthropic-shaped on Vertex); try that, then give
// up to the token-count estimate.
google_raw_predict: (text, isStream) =>
extractAnthropicMessages(text, isStream),
bedrock_model_invoke: extractBedrockModelInvoke,
bedrock_converse: extractBedrockConverse
};
/**
* Attempts to pull provider-reported token usage out of an upstream AI
* gateway response. Returns null if the response didn't contain (or we
* couldn't find) usage data, in which case callers should fall back to
* `estimateUsage`.
*/
export function extractUsage(
capability: AiCapability,
responseText: string,
isStream: boolean,
headers: Headers
): AiUsage | null {
try {
return EXTRACTORS[capability](responseText, isStream, headers);
} catch (error) {
logger.debug("Failed to extract AI usage from response", {
capability,
error
});
return null;
}
}
/**
* Best-guess token estimate for when the provider doesn't report usage.
* Uses OpenAI's BPE tokenizer as a stand-in for whatever tokenizer the
* actual model uses - close enough for an approximate cost figure, not
* exact for non-OpenAI models.
*/
export function estimateUsage(
promptText: string,
completionText: string
): AiUsage {
const usage = emptyUsage();
usage.estimated = true;
try {
usage.promptTokens = promptText ? encode(promptText).length : 0;
} catch (error) {
logger.debug("Failed to estimate prompt tokens", { error });
}
try {
usage.completionTokens = completionText
? encode(completionText).length
: 0;
} catch (error) {
logger.debug("Failed to estimate completion tokens", { error });
}
return usage;
}
/**
* OpenAI's Chat Completions API only includes a `usage` field in a
* streaming response when the request opts in via `stream_options:
* {include_usage: true}` - unlike the Responses API, Anthropic, Gemini and
* Bedrock, which report usage in a streaming response by default. Returns
* whether we need to inject that option ourselves to be able to track cost.
*/
export function needsStreamUsageInjection(
capability: AiCapability,
body: any
): boolean {
return (
capability === "openai_chat" &&
body?.stream === true &&
body?.stream_options?.include_usage !== true
);
}
/**
* Returns a shallow-cloned body with `stream_options.include_usage`
* injected, for capabilities/requests where `needsStreamUsageInjection`
* is true. Leaves the original body untouched.
*/
export function withStreamUsageOption(body: any): any {
return {
...body,
stream_options: { ...body.stream_options, include_usage: true }
};
}
/**
* When we injected stream_options.include_usage ourselves (the caller
* didn't ask for it), OpenAI appends an extra terminal SSE frame with an
* empty `choices: []` array carrying only the usage data. Callers that
* don't expect that shape (most minimal SSE parsers assume a non-empty
* choices array) shouldn't see it, so it's stripped back out of the bytes
* forwarded to the client.
*/
export function stripInjectedUsageFrame(sseText: string): string {
const parts = sseText.split(/(\r?\n\r?\n)/);
let out = "";
for (let i = 0; i < parts.length; i += 2) {
const frame = parts[i];
const separator = parts[i + 1] ?? "";
const dataLine = frame
.split(/\r?\n/)
.find((line) => line.startsWith("data:"));
if (dataLine) {
const data = dataLine.slice("data:".length).trim();
const parsed = data !== "[DONE]" ? tryParseJson(data) : null;
if (
parsed &&
Array.isArray(parsed.choices) &&
parsed.choices.length === 0 &&
parsed.usage
) {
continue;
}
}
out += frame + separator;
}
return out;
}
/**
* Best-effort extraction of the model the upstream provider actually
* served, which some gateways/routers echo back and which may differ from
* the model the caller requested (e.g. an alias resolving to a dated
* snapshot). Falls back to the caller's requested model when absent.
*/
export function extractResponseModel(responseText: string): string | null {
const match = responseText.match(/"model"\s*:\s*"([^"]+)"/);
return match ? match[1] : null;
}
export function isUsageEmpty(usage: AiUsage): boolean {
return (
usage.promptTokens === 0 &&
usage.cacheReadTokens === 0 &&
usage.cacheWriteTokens === 0 &&
usage.completionTokens === 0 &&
usage.reasoningTokens === 0
);
}
@@ -202,6 +202,10 @@ async function handleResource(
return;
}
if (!target.resourceId) {
return;
}
const [resource] = await trx
.select()
.from(resources)
@@ -227,9 +231,7 @@ async function handleResource(
let health = "healthy";
const allUnknown = monitoredTargets.length === 0;
const allHealthy = monitoredTargets.every(
(t) => t.hcHealth === "healthy"
);
const allHealthy = monitoredTargets.every((t) => t.hcHealth === "healthy");
const allUnhealthy = monitoredTargets.every(
(t) => t.hcHealth === "unhealthy"
);
-1
View File
@@ -632,7 +632,6 @@ export const ResourcePolicySchema = z.object({
})
)
)
.max(50)
.transform((v) => v.map((e) => e.toLowerCase()))
.optional()
.default([]),
+15 -15
View File
@@ -10,12 +10,12 @@ export const localCache = new NodeCache({
});
// Log cache statistics periodically for monitoring
setInterval(() => {
const stats = localCache.getStats();
logger.debug(
`Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
);
}, 300000); // Every 5 minutes
// setInterval(() => {
// const stats = localCache.getStats();
// logger.debug(
// `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
// );
// }, 300000); // Every 5 minutes
/**
* Adaptive cache that uses Redis when available in multi-node environments,
@@ -34,9 +34,9 @@ class AdaptiveCache {
// Use local cache as fallback or primary
const success = localCache.set(key, value, effectiveTtl || 0);
if (success) {
logger.debug(`Set key in local cache: ${key}`);
}
// if (success) {
// logger.debug(`Set key in local cache: ${key}`);
// }
return success;
}
@@ -48,11 +48,11 @@ class AdaptiveCache {
async get<T = any>(key: string): Promise<T | undefined> {
// Use local cache as fallback or primary
const value = localCache.get<T>(key);
if (value !== undefined) {
logger.debug(`Cache hit in local cache: ${key}`);
} else {
logger.debug(`Cache miss in local cache: ${key}`);
}
// if (value !== undefined) {
// logger.debug(`Cache hit in local cache: ${key}`);
// } else {
// logger.debug(`Cache miss in local cache: ${key}`);
// }
return value;
}
@@ -168,5 +168,5 @@ class AdaptiveCache {
// Export singleton instance
export const cache = new AdaptiveCache();
export const regionalCache = cache; // Alias for compatability with the private version
export const regionalCache = cache; // Alias for compatibility with the private version
export default cache;
-14
View File
@@ -339,19 +339,6 @@ export async function calculateUserClientsForOrgs(
continue;
}
// Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
);
continue;
}
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
// Get next available subnet
const { value: newSubnet, release: releaseSubnetLock } =
await getNextAvailableClientSubnet(orgId, trx);
@@ -370,7 +357,6 @@ export async function calculateUserClientsForOrgs(
const newClientData: InferInsertModel<typeof clients> = {
userId,
orgId: userOrg.orgId,
exitNodeId: randomExitNode.exitNodeId,
name: olm.name || "User Client",
subnet: updatedSubnet,
olmId: olm.olmId,
+17 -4
View File
@@ -3,12 +3,14 @@ import { cleanUpOldLogs as cleanUpOldAccessLogs } from "#dynamic/lib/logAccessAu
import { cleanUpOldLogs as cleanUpOldActionLogs } from "#dynamic/middlewares/logActionAudit";
import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit";
import { cleanUpOldLogs as cleanUpOldConnectionLogs } from "#dynamic/routers/newt";
import { cleanUpOldLogs as cleanUpOldAiSessionLogs } from "@server/routers/aiGateway/logAiSession";
import { gt, or } from "drizzle-orm";
import { cleanUpOldFingerprintSnapshots } from "@server/routers/olm/fingerprintingUtils";
import { build } from "@server/build";
export function initLogCleanupInterval() {
if (build == "saas") { // skip log cleanup for saas builds
if (build == "saas") {
// skip log cleanup for saas builds
return null;
}
return setInterval(
@@ -23,7 +25,9 @@ export function initLogCleanupInterval() {
settingsLogRetentionDaysRequest:
orgs.settingsLogRetentionDaysRequest,
settingsLogRetentionDaysConnection:
orgs.settingsLogRetentionDaysConnection
orgs.settingsLogRetentionDaysConnection,
settingsLogRetentionDaysAISessions:
orgs.settingsLogRetentionDaysAISessions
})
.from(orgs)
.where(
@@ -31,7 +35,8 @@ export function initLogCleanupInterval() {
gt(orgs.settingsLogRetentionDaysAction, 0),
gt(orgs.settingsLogRetentionDaysAccess, 0),
gt(orgs.settingsLogRetentionDaysRequest, 0),
gt(orgs.settingsLogRetentionDaysConnection, 0)
gt(orgs.settingsLogRetentionDaysConnection, 0),
gt(orgs.settingsLogRetentionDaysAISessions, 0)
)
);
@@ -42,7 +47,8 @@ export function initLogCleanupInterval() {
settingsLogRetentionDaysAction,
settingsLogRetentionDaysAccess,
settingsLogRetentionDaysRequest,
settingsLogRetentionDaysConnection
settingsLogRetentionDaysConnection,
settingsLogRetentionDaysAISessions
} = org;
if (settingsLogRetentionDaysAction > 0) {
@@ -72,6 +78,13 @@ export function initLogCleanupInterval() {
settingsLogRetentionDaysConnection
);
}
if (settingsLogRetentionDaysAISessions > 0) {
await cleanUpOldAiSessionLogs(
orgId,
settingsLogRetentionDaysAISessions
);
}
}
await cleanUpOldFingerprintSnapshots(365);
+3
View File
@@ -93,6 +93,9 @@ export async function deleteOrgById(
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
}
for (const client of orgClients) {
if (client.exitNodeId && client.pubKey) {
await deletePeer(client.exitNodeId, client.pubKey);
}
const [olm] = await trx
.select()
.from(olms)
+8 -1
View File
@@ -64,13 +64,20 @@ export async function performDeleteResources(
const targetsByResourceId = new Map<number, Target[]>();
for (const target of targetsToBeRemoved) {
if (target.resourceId == null) {
continue;
}
const existing = targetsByResourceId.get(target.resourceId) ?? [];
existing.push(target);
targetsByResourceId.set(target.resourceId, existing);
}
const targetIdToResourceId = new Map(
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId])
targetsToBeRemoved.flatMap((target) =>
target.resourceId == null
? []
: [[target.targetId, target.resourceId] as const]
)
);
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
+5 -3
View File
@@ -1,4 +1,4 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
import {
db,
resources,
@@ -33,9 +33,11 @@ export async function getResourceIdsForSite(
const rows = await trx
.selectDistinct({ resourceId: targets.resourceId })
.from(targets)
.where(eq(targets.siteId, siteId));
.where(and(eq(targets.siteId, siteId), isNotNull(targets.resourceId)));
return rows.map((row) => row.resourceId);
return rows
.map((row) => row.resourceId)
.filter((resourceId): resourceId is number => resourceId != null);
}
export async function getSiteResourceIdsForSite(
@@ -0,0 +1,40 @@
import { db, sites, clients } from "@server/db";
import { and, eq, count } from "drizzle-orm";
// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS)
// higher = more desirable
// like saying, this node has x% of its capacity left
export async function calculateExitNodeWeight(
exitNodeId: number,
maxConnections: number | null | undefined
): Promise<number | null> {
if (maxConnections === null || maxConnections === undefined) {
return 1;
}
const [[siteConnections], [clientConnections]] = await Promise.all([
db
.select({ count: count() })
.from(sites)
.where(
and(eq(sites.exitNodeId, exitNodeId), eq(sites.online, true))
),
db
.select({ count: count() })
.from(clients)
.where(
and(
eq(clients.exitNodeId, exitNodeId),
eq(clients.online, true)
)
)
]);
const currentConnections = siteConnections.count + clientConnections.count;
if (currentConnections >= maxConnections) {
return null;
}
return (maxConnections - currentConnections) / maxConnections;
}
+10 -1
View File
@@ -1,6 +1,5 @@
import { db, exitNodes, Transaction } from "@server/db";
import logger from "@server/logger";
import { ExitNodePingResult } from "@server/routers/newt";
import { eq } from "drizzle-orm";
export async function verifyExitNodeOrgAccess(
@@ -52,6 +51,16 @@ export async function listExitNodes(
return allExitNodes;
}
export type ExitNodePingResult = {
exitNodeId: number;
latencyMs: number;
weight: number;
error?: string;
exitNodeName: string;
endpoint: string;
wasPreviouslyConnected: boolean;
};
export function selectBestExitNode(
pingResults: ExitNodePingResult[]
): ExitNodePingResult | null {
@@ -0,0 +1,41 @@
import { db, ExitNode, Transaction, sites, clients } from "@server/db";
import { eq } from "drizzle-orm";
import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip";
import { lockManager } from "#dynamic/lib/lock";
export async function getUniqueSubnetForExitNode(
exitNode: ExitNode,
trx: Transaction | typeof db = db
): Promise<string | null> {
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
return await lockManager.withLock(
lockKey,
async () => {
const [sitesQuery, clientsQuery] = await Promise.all([
trx
.select({ subnet: sites.exitNodeSubnet })
.from(sites)
.where(eq(sites.exitNodeId, exitNode.exitNodeId)),
trx
.select({ subnet: clients.exitNodeSubnet })
.from(clients)
.where(eq(clients.exitNodeId, exitNode.exitNodeId))
]);
const blockSize = config.getRawConfig().gerbil.site_block_size;
const subnets = [...sitesQuery, ...clientsQuery]
.map((row) => row.subnet)
.filter(
(subnet): subnet is string =>
!!subnet &&
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
);
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
return findNextAvailableCidr(subnets, blockSize, exitNode.address);
},
5000 // 5 second lock TTL - subnet allocation should be quick
);
}
+2
View File
@@ -2,3 +2,5 @@ export * from "./exitNodes";
export * from "./exitNodeComms";
export * from "./subnet";
export * from "./getCurrentExitNodeId";
export * from "./calculateExitNodeWeight";
export * from "./getUniqueSubnetForExitNode";
+2 -1
View File
@@ -1,8 +1,9 @@
import { db, idp, idpOrg, Transaction } from "@server/db";
import { and, eq } from "drizzle-orm";
import { build } from "@server/build";
export function isOrgIdentityProviderMode(): boolean {
return process.env.IDENTITY_PROVIDER_MODE === "org";
return build === "saas" || process.env.IDENTITY_PROVIDER_MODE === "org";
}
/**
+21 -8
View File
@@ -528,7 +528,10 @@ export function generateRemoteSubnets(
export type Alias = { alias: string | null; aliasAddress: string | null };
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
export function generateAliasConfig(
allSiteResources: SiteResource[],
overrideIp?: string
): Alias[] {
return allSiteResources
.filter(
(sr) =>
@@ -539,7 +542,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
)
.map((sr) => ({
alias: sr.alias || sr.fullDomain,
aliasAddress: sr.aliasAddress
aliasAddress: overrideIp || sr.aliasAddress
}));
}
@@ -660,9 +663,10 @@ export type CertRef = { id: string; cert: string; key: string };
* certificate (e.g. a wildcard cert used by thousands of site resources)
* only need that certificate sent once per sync message.
*/
export function dedupeCertsForTargets(
targetsV2: SubnetProxyTargetV2[]
): { targets: SubnetProxyTargetV2[]; certs: CertRef[] } {
export function dedupeCertsForTargets(targetsV2: SubnetProxyTargetV2[]): {
targets: SubnetProxyTargetV2[];
certs: CertRef[];
} {
const idByContent = new Map<string, string>();
const certs: CertRef[] = [];
@@ -674,7 +678,10 @@ export function dedupeCertsForTargets(
const contentKey = `${target.tlsCert}|${target.tlsKey}`;
let id = idByContent.get(contentKey);
if (!id) {
id = createHash("sha1").update(contentKey).digest("hex").slice(0, 16);
id = createHash("sha1")
.update(contentKey)
.digest("hex")
.slice(0, 16);
idByContent.set(contentKey, id);
certs.push({ id, cert: target.tlsCert, key: target.tlsKey });
}
@@ -708,7 +715,9 @@ export async function batchFetchCertsForSiteResources(
): Promise<CertByDomain> {
const domains = new Set(
allSiteResources
.filter((r) => r.enabled && r.mode === "http" && r.ssl && r.fullDomain)
.filter(
(r) => r.enabled && r.mode === "http" && r.ssl && r.fullDomain
)
.map((r) => r.fullDomain as string)
);
@@ -852,7 +861,11 @@ export async function generateSubnetProxyTargetV2(
new Set([siteResource.fullDomain]),
true
);
if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) {
if (
certs.length > 0 &&
certs[0].certFile &&
certs[0].keyFile
) {
tlsCert = certs[0].certFile;
tlsKey = certs[0].keyFile;
} else {
+87 -1
View File
@@ -79,7 +79,13 @@ export const configSchema = z
.default(3001)
.transform(stoi)
.pipe(portSchema),
ai_gateway_port: portSchema
.optional()
.default(3005)
.transform(stoi)
.pipe(portSchema),
badger_override: z.string().optional(),
ai_gateway_override: z.string().optional(),
next_port: portSchema
.optional()
.default(3002)
@@ -105,6 +111,23 @@ export const configSchema = z
})
.optional()
.prefault({}),
remote_headers: z
.object({
user_id: z
.string()
.optional()
.default("Remote-User-Id"),
virtual_api_key_id: z
.string()
.optional()
.default("Remote-Virtual-Api-Key-Id"),
user: z.string().optional().default("Remote-User"),
email: z.string().optional().default("Remote-Email"),
name: z.string().optional().default("Remote-Name"),
role: z.string().optional().default("Remote-Role")
})
.optional()
.prefault({}),
resource_session_request_param: z
.string()
.optional()
@@ -130,6 +153,25 @@ export const configSchema = z
})
.optional(),
trust_proxy: z.int().gte(0).optional().default(1),
// Opt-in: have Traefik/Badger stamp the resolved client IP
// into a dedicated header (X-Pangolin-Client-Ip) on the
// site-resource AI gateway route, so it survives an
// intermediary proxy between Traefik and the gateway that
// overwrites X-Forwarded-For/X-Real-Ip instead of appending
// to them. Off by default since it requires a Badger
// version that supports realIpHeader.
enable_ai_gateway_client_ip_header: z
.boolean()
.optional()
.default(false)
.transform((val) =>
process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER !==
undefined
? process.env
.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER ===
"true"
: val
),
secret: z.string().pipe(z.string().min(8)).optional(),
maxmind_db_path: z.string().optional(),
maxmind_asn_path: z.string().optional()
@@ -139,6 +181,7 @@ export const configSchema = z
integration_port: 3003,
external_port: 3000,
internal_port: 3001,
ai_gateway_port: 3005,
next_port: 3002,
internal_hostname: "pangolin",
session_cookie_name: "p_session_token",
@@ -147,11 +190,20 @@ export const configSchema = z
id: "P-Access-Token-Id",
token: "P-Access-Token"
},
remote_headers: {
user_id: "Remote-User-Id",
virtual_api_key_id: "Remote-Virtual-Api-Key-Id",
user: "Remote-User",
email: "Remote-Email",
name: "Remote-Name",
role: "Remote-Role"
},
resource_session_request_param:
"resource_session_request_param",
dashboard_session_length_hours: 720,
resource_session_length_hours: 720,
trust_proxy: 1
trust_proxy: 1,
enable_ai_gateway_client_ip_header: false
}),
postgres: z
.object({
@@ -394,6 +446,40 @@ export const configSchema = z
disable_enterprise_features: z.boolean().optional()
})
.optional(),
ai: z
.object({
model_catalog: z
.object({
upstream_url: z
.url()
.optional()
.default("https://api.fossorial.io/api/v1/models"),
// No default - only used when an operator wants to
// pin the catalog to a local file instead of
// fetching it from upstream_url.
file: z.string().optional(),
// No default - only used when an operator wants to
// merge the content of the json file with the upstream catalog. This is useful for adding
// custom models to the catalog without having to maintain a separate fork of the upstream catalog.
merge_file: z.string().optional(),
refresh_interval_min_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(6),
refresh_interval_max_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(12)
})
.optional()
.prefault({})
})
.optional()
.prefault({}),
dns: z
.object({
nameservers: z
+325 -6
View File
@@ -19,7 +19,7 @@ import {
userOrgRoles,
userSiteResources
} from "@server/db";
import { and, count, eq, inArray, ne } from "drizzle-orm";
import { and, count, eq, inArray, isNotNull, ne } from "drizzle-orm";
import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers";
import {
@@ -27,6 +27,9 @@ import {
deletePeersBatch as olmDeletePeersBatch
} from "@server/routers/olm/peers";
import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { sendToClientsBatch } from "#dynamic/routers/ws";
import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
import logger from "@server/logger";
import {
generateAliasConfig,
@@ -187,7 +190,12 @@ export async function getClientSiteResourceAccess(
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]`
);
if (sitesList.length === 0) {
if (sitesList.length === 0 && siteResource.networkId !== null) {
// A site resource with a networkId is expected to have at least one
// site attached via siteNetworks. Resources with no networkId (e.g.
// inference-mode resources, which connect clients directly to the
// exit node instead of any site) are expected to have no sites, so
// don't warn for those.
logger.warn(
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
);
@@ -687,6 +695,22 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
clientSiteResourcesToRemove,
trx
);
// If this resource requires clients to be connected to the exit node
// (e.g. an inference resource), re-sync the connect/disconnect state for
// every client whose access to it may have changed - both those who
// currently have access and those who just lost it.
if (siteResource.requiresExitNodeConnection) {
await syncClientExitNodeConnections(
Array.from(
new Set([
...mergedAllClientIds,
...existingClientSiteResourceIds
])
),
trx
);
}
}
async function handleMessagesForSiteClients(
@@ -966,7 +990,7 @@ export async function updateClientSiteDestinations(
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
for (const site of sitesData) {
if (!site.sites.subnet) {
if (!site.sites.exitNodeSubnet) {
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
continue;
}
@@ -1002,7 +1026,7 @@ export async function updateClientSiteDestinations(
sourcePort: parsedEndpoint.port,
destinations: [
{
destinationIP: site.sites.subnet.split("/")[0],
destinationIP: site.sites.exitNodeSubnet.split("/")[0],
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
}
]
@@ -1010,7 +1034,7 @@ export async function updateClientSiteDestinations(
} else {
// add to the existing destinations
destinations.destinations.push({
destinationIP: site.sites.subnet.split("/")[0],
destinationIP: site.sites.exitNodeSubnet.split("/")[0],
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
});
}
@@ -1052,6 +1076,265 @@ export async function updateClientSiteDestinations(
}
}
// Determines, for each of the given clients, whether they currently have
// access to any enabled site resource with requiresExitNodeConnection set
// (e.g. an inference-mode resource) and tells the client's olm to connect to
// or disconnect from its assigned exit node accordingly. Site resources with
// requiresExitNodeConnection don't belong to any site/network, so this can't
// be derived from the per-site peer logic above - it has to be recomputed
// from the client's full current resource access every time that access
// changes.
async function syncClientExitNodeConnections(
clientIds: number[],
trx: Transaction | typeof db = db
): Promise<void> {
const uniqueClientIds = Array.from(new Set(clientIds));
if (uniqueClientIds.length === 0) {
return;
}
// Only clients with an exit node assigned can be told to connect/disconnect.
const clientsData = await trx
.select({
clientId: clients.clientId,
exitNodeId: clients.exitNodeId,
exitNodeSubnet: clients.exitNodeSubnet
})
.from(clients)
.where(
and(
inArray(clients.clientId, uniqueClientIds),
isNotNull(clients.exitNodeId)
)
);
if (clientsData.length === 0) {
return;
}
const clientIdsWithExitNode = clientsData.map((c) => c.clientId);
const requiresExitNodeRows = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId,
alias: siteResources.alias,
fullDomain: siteResources.fullDomain
})
.from(clientSiteResourcesAssociationsCache)
.innerJoin(
siteResources,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
inArray(
clientSiteResourcesAssociationsCache.clientId,
clientIdsWithExitNode
),
eq(siteResources.enabled, true),
eq(siteResources.requiresExitNodeConnection, true)
)
);
const needsConnectSet = new Set(
requiresExitNodeRows.map((r) => r.clientId)
);
// Aliases for every exit-node-backed resource this client can reach, so
// the live connect push carries the same alias list the register/reconnect
// path (buildSiteConfigurationForOlmClient) would compute.
const exitNodeAliasesByClientId = new Map<number, (string | null)[]>();
for (const row of requiresExitNodeRows) {
if (row.alias == null && row.fullDomain == null) continue;
const existing = exitNodeAliasesByClientId.get(row.clientId);
if (existing) {
existing.push(row.fullDomain || row.alias); // accept both for now in case we have other resource types that dont use the full domain
} else {
exitNodeAliasesByClientId.set(row.clientId, [
row.fullDomain || row.alias
]);
}
}
const exitNodeIds = Array.from(
new Set(
clientsData
.map((c) => c.exitNodeId)
.filter((id): id is number => id !== null)
)
);
const exitNodeRows =
exitNodeIds.length > 0
? await trx
.select()
.from(exitNodes)
.where(inArray(exitNodes.exitNodeId, exitNodeIds))
: [];
const exitNodeById = new Map(exitNodeRows.map((n) => [n.exitNodeId, n]));
const olmRows = await trx
.select({
clientId: olms.clientId,
olmId: olms.olmId,
version: olms.version
})
.from(olms)
.where(inArray(olms.clientId, clientIdsWithExitNode));
const olmByClientId = new Map(
olmRows
.filter((r) => r.clientId !== null)
.map((r) => [r.clientId as number, r])
);
const relayPort = config.getRawConfig().gerbil.clients_start_port;
const connectPayloads: {
clientId: string;
message: { type: string; data: any };
options: { compress: boolean; incrementConfigVersion: boolean };
}[] = [];
const disconnectPayloads: {
clientId: string;
message: { type: string; data: any };
options: { compress: boolean; incrementConfigVersion: boolean };
}[] = [];
for (const client of clientsData) {
const olm = olmByClientId.get(client.clientId);
if (!olm) {
// No olm registered for this client yet/anymore, nothing to send.
continue;
}
const needsConnect = needsConnectSet.has(client.clientId);
if (needsConnect) {
const exitNode = client.exitNodeId
? exitNodeById.get(client.exitNodeId)
: undefined;
if (!exitNode || !client.exitNodeSubnet) {
logger.warn(
`rebuildClientAssociations: [syncClientExitNodeConnections] client ${client.clientId} needs an exit node connection but has no exit node or subnet assigned`
);
continue;
}
connectPayloads.push({
clientId: olm.olmId,
message: {
type: "olm/wg/exitnode/connect",
data: {
connect: true,
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
relayPort,
publicKey: exitNode.publicKey,
serverIP: exitNode.address.split("/")[0],
tunnelIP: client.exitNodeSubnet.split("/")[0],
aliases:
exitNodeAliasesByClientId.get(client.clientId) ?? []
}
},
options: {
compress: canCompress(olm.version, "olm"),
incrementConfigVersion: true
}
});
} else {
disconnectPayloads.push({
clientId: olm.olmId,
message: {
type: "olm/wg/exitnode/disconnect",
data: {}
},
options: {
compress: canCompress(olm.version, "olm"),
incrementConfigVersion: true
}
});
}
}
if (connectPayloads.length > 0) {
await sendToClientsBatch(connectPayloads).catch((error) => {
logger.error(
`rebuildClientAssociations: Error sending exit node connect messages:`,
error
);
});
}
if (disconnectPayloads.length > 0) {
await sendToClientsBatch(disconnectPayloads).catch((error) => {
logger.error(
`rebuildClientAssociations: Error sending exit node disconnect messages:`,
error
);
});
}
}
// Notifies the olms of every given client that the alias of the site resource
// they're using an exit node connection for has changed, via the dedicated
// exit node data-update message. Unlike syncClientExitNodeConnections, this
// doesn't touch connect/disconnect state - it's purely a rename for clients
// that are (and remain) connected to the exit node for this resource.
async function syncClientExitNodeAliasUpdate(
clientIds: number[],
oldAlias: string | null,
newAlias: string | null,
trx: Transaction | typeof db = db
): Promise<void> {
const uniqueClientIds = Array.from(new Set(clientIds));
if (uniqueClientIds.length === 0) {
return;
}
const oldAliases = oldAlias ? [oldAlias] : [];
const newAliases = newAlias ? [newAlias] : [];
if (oldAliases.length === 0 && newAliases.length === 0) {
return;
}
const olmRows = await trx
.select({
clientId: olms.clientId,
olmId: olms.olmId,
version: olms.version
})
.from(olms)
.where(inArray(olms.clientId, uniqueClientIds));
const updatePayloads = olmRows
.filter((r) => r.clientId !== null)
.map((olm) => ({
clientId: olm.olmId,
message: {
type: "olm/wg/exitnode/data/update",
data: {
oldAliases,
newAliases
}
},
options: {
compress: canCompress(olm.version, "olm"),
incrementConfigVersion: true // this is important information we would need to sync
}
}));
if (updatePayloads.length > 0) {
await sendToClientsBatch(updatePayloads).catch((error) => {
logger.error(
`rebuildClientAssociations: Error sending exit node alias update messages:`,
error
);
});
}
}
async function handleSubnetProxyTargetUpdates(
siteResource: SiteResource,
sitesList: Site[],
@@ -1282,7 +1565,7 @@ export async function handleMessagingForUpdatedSiteResource(
`handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)`
);
// WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED
// !!!!!!!!!!!!!!!!!! WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED !!!!!!!!!!!!!!!!!!
const mergedAllClients = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId,
@@ -1709,6 +1992,38 @@ export async function handleMessagingForUpdatedSiteResource(
);
}
// For a resource that stays on an exit node connection across the update,
// the alias is the only field that affects already-connected clients (the
// exit node itself, its endpoint, etc. are not per-resource). Tell those
// clients' olms about the rename directly via the exit node data-update
// message rather than a full connect/disconnect cycle.
if (
existingSiteResource?.requiresExitNodeConnection &&
updatedSiteResource.requiresExitNodeConnection &&
aliasChanged
) {
await syncClientExitNodeAliasUpdate(
mergedAllClients.map((c) => c.clientId),
existingSiteResource.alias,
updatedSiteResource.alias,
trx
);
}
// If this resource requires (or required) clients to be connected to the
// exit node (e.g. an inference resource), re-sync connect/disconnect
// state for every client currently associated with it - covers toggling
// requiresExitNodeConnection on update as well as enabling/disabling it.
if (
updatedSiteResource.requiresExitNodeConnection ||
existingSiteResource?.requiresExitNodeConnection
) {
await syncClientExitNodeConnections(
mergedAllClients.map((c) => c.clientId),
trx
);
}
logger.debug(
`handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}`
);
@@ -1990,6 +2305,10 @@ async function rebuildClientAssociationsFromClientImpl(
resourcesToRemove,
trx
);
// Re-sync exit node connect/disconnect state based on this client's
// current full set of resource access (e.g. inference resources).
await syncClientExitNodeConnections([client.clientId], trx);
}
async function handleMessagesForClientSites(
+32 -2
View File
@@ -516,6 +516,11 @@ export class TraefikConfigManager {
const maintenanceHost =
config.getRawConfig().server.internal_hostname;
const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`;
const aiGatewayUrl =
config.getRawConfig().server.ai_gateway_override ||
`http://${maintenanceHost}:${
config.getRawConfig().server.ai_gateway_port
}`;
// logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`);
traefikConfig = await getTraefikConfig(
@@ -528,7 +533,8 @@ export class TraefikConfigManager {
? false
: config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config
pangolinUIUrl, // generate maintenance pages on cloud and hybrid
pangolinUIUrl // generate browser gateway targets on cloud and hybrid
pangolinUIUrl, // generate browser gateway targets on cloud and hybrid
aiGatewayUrl
);
const domains = new Set<string>();
@@ -599,7 +605,31 @@ export class TraefikConfigManager {
resourceSessionRequestParam:
config.getRawConfig().server
.resource_session_request_param
.resource_session_request_param,
remoteUserIdHeader:
config.getRawConfig().server.remote_headers
.user_id,
remoteVirtualApiKeyIdHeader:
config.getRawConfig().server.remote_headers
.virtual_api_key_id,
remoteUserHeader:
config.getRawConfig().server.remote_headers
.user,
remoteEmailHeader:
config.getRawConfig().server.remote_headers
.email,
remoteNameHeader:
config.getRawConfig().server.remote_headers
.name,
remoteRoleHeader:
config.getRawConfig().server.remote_headers
.role
}
}
};
+353 -4
View File
@@ -1,4 +1,12 @@
import { db, targetHealthCheck, domains } from "@server/db";
import {
db,
targetHealthCheck,
domains,
aiProviders,
resourceAiProviders,
siteResources,
exitNodes
} from "@server/db";
import {
and,
eq,
@@ -15,6 +23,13 @@ import config from "@server/lib/config";
import { resources, sites, Target, targets } from "@server/db";
import createPathRewriteMiddleware from "./middleware";
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
import regionalCache from "@server/lib/cache";
import {
AI_GATEWAY_TRUST_HEADER,
AI_GATEWAY_RESOURCE_TYPE_HEADER,
AI_GATEWAY_CLIENT_IP_HEADER,
getAiGatewayTrustToken
} from "@server/lib/aiGatewayTrust";
const redirectHttpsMiddlewareName = "redirect-to-https";
const badgerMiddlewareName = "badger";
@@ -45,8 +60,24 @@ export async function getTraefikConfig(
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
allowRawResources = true,
maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
browserGatewayUiUrl: string | null = null // UNUSED BUT USED IN PRIVATE
browserGatewayUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
aiGatewayUrl: string | null = null
): Promise<any> {
// Get the exit node but cache it for 5 minutes to avoid hitting the DB too often
const exitNodeCacheKey = `exitNode:${exitNodeId}`;
let exitNode =
await regionalCache.get<typeof exitNodes.$inferSelect>(
exitNodeCacheKey
);
if (!exitNode) {
[exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, exitNodeId))
.limit(1);
await regionalCache.set(exitNodeCacheKey, exitNode, 300);
}
// Get resources with their targets and sites in a single optimized query
// Start from sites on this exit node, then join to targets and resources
const resourcesWithTargetsAndSites = await db
@@ -87,7 +118,7 @@ export async function getTraefikConfig(
siteId: sites.siteId,
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
subnet: sites.exitNodeSubnet,
exitNodeId: sites.exitNodeId,
// Domain cert resolver fields
domainCertResolver: domains.certResolver,
@@ -209,8 +240,42 @@ export async function getTraefikConfig(
});
});
// Inference-mode resources have no targets/sites (their "backend" is the
// central AI gateway), so they can't be reached via the targets->sites
// join above - query them separately and include them on every exit node.
const inferenceResources = await db
.selectDistinct({
resourceId: resources.resourceId,
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
wildcard: resources.wildcard,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert
})
.from(resources)
// .innerJoin(
// resourceAiProviders,
// eq(resources.resourceId, resourceAiProviders.resourceId)
// )
// .innerJoin(
// aiProviders,
// eq(resourceAiProviders.providerId, aiProviders.providerId)
// )
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.where(
and(
eq(resources.mode, "inference"),
eq(resources.enabled, true)
// eq(aiProviders.enabled, true)
)
);
// make sure we have at least one resource
if (resourcesMap.size === 0) {
if (resourcesMap.size === 0 && inferenceResources.length === 0) {
return {};
}
@@ -673,5 +738,289 @@ export async function getTraefikConfig(
};
}
}
if (aiGatewayUrl) {
// The AI gateway may live on a different host than the inference
// resource itself (e.g. a remote exit node forwarding to the
// central dashboard over a tunnel). passHostHeader would forward
// the resource's own Host, which that external host won't
// recognize, so we pin the Host header to the gateway's own host
// and smuggle the original resource host through in "p-host"
// instead.
let aiGatewayHost: string | undefined;
try {
aiGatewayHost = new URL(aiGatewayUrl).host;
} catch {
aiGatewayHost = undefined;
}
// The trust token is the same for every inference route on this exit
// node, so it's defined once here and attached to each router below
// instead of being duplicated into a per-resource middleware. Two
// variants exist (public resource vs. siteResource) so the resource
// type header lets the gateway know which kind of router the
// request came through without re-deriving it from resourceId.
const aiGatewayTrustMiddlewareNameResource =
"ai-gateway-trust-headers-resource";
const aiGatewayTrustMiddlewareNameSiteResource =
"ai-gateway-trust-headers-site-resource";
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[aiGatewayTrustMiddlewareNameResource] =
{
headers: {
customRequestHeaders: {
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(),
[AI_GATEWAY_RESOURCE_TYPE_HEADER]: "resource"
}
}
};
config_output.http.middlewares[
aiGatewayTrustMiddlewareNameSiteResource
] = {
headers: {
customRequestHeaders: {
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(),
[AI_GATEWAY_RESOURCE_TYPE_HEADER]: "site-resource"
}
}
};
// Opt-in: a Badger instance with forward auth disabled, used only
// to stamp the resolved client IP into a dedicated header before
// the request reaches whatever sits between Traefik and the AI
// gateway. Only the site-resource router below needs this - it's
// the only path that resolves request identity from the client IP
// (see resolveRequestUser in aiGateway/pipeline.ts) - and it's the
// only inference router that doesn't already run Badger.
const aiGatewayClientIpMiddlewareName = "ai-gateway-client-ip";
const enableAiGatewayClientIpHeader =
config.getRawConfig().server.enable_ai_gateway_client_ip_header;
if (enableAiGatewayClientIpHeader) {
config_output.http.middlewares[aiGatewayClientIpMiddlewareName] =
{
plugin: {
badger: {
disableForwardAuth: true,
realIpHeader: AI_GATEWAY_CLIENT_IP_HEADER
}
}
};
}
// Public inference resources: same TLS/cert-resolver handling as
// plain http-mode resources, but the service points at the AI
// gateway instead of any real backend targets.
for (const ir of inferenceResources) {
if (!ir.enabled) continue;
if (!ir.domainId || !ir.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = ir.fullDomain;
const irKey = `inference-r${ir.resourceId}`;
const routerName = `${irKey}-router`;
const serviceName = `${irKey}-service`;
let rule: string;
if (ir.wildcard && fullDomain.startsWith("*.")) {
const escaped = fullDomain.slice(2).replace(/\./g, "\\.");
rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
} else {
rule = `Host(\`${fullDomain}\`)`;
}
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!ir.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = ir.domainCertResolver
? ir.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
ir.preferWildcardCert !== undefined &&
ir.preferWildcardCert !== null
? ir.preferWildcardCert
: globalDefaultPreferWildcard;
const tls = {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
const irHeadersMiddlewareName = `${irKey}-headers-middleware`;
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[irHeadersMiddlewareName] = {
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": fullDomain
}
}
};
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
aiGatewayTrustMiddlewareNameResource,
irHeadersMiddlewareName,
...additionalMiddlewares
];
if (ir.ssl) {
config_output.http.routers[routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority: 100
};
}
config_output.http.routers[routerName] = {
entryPoints: [
ir.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: serviceName,
rule,
priority: 100,
...(ir.ssl ? { tls } : {})
};
config_output.http.services[serviceName] = {
loadBalancer: {
servers: [{ url: aiGatewayUrl }]
}
};
}
// Private (siteResource) inference resources: routed by their alias
// instead of a public fullDomain, and deliberately WITHOUT the
// badger middleware - no per-user auth/policy stack exists for
// siteResources today, so gating here is reachability-only for now.
const siteResourcesInference = await db
.selectDistinct({
siteResourceId: siteResources.siteResourceId,
fullDomain: siteResources.fullDomain,
ssl: siteResources.ssl,
enabled: siteResources.enabled
})
.from(siteResources)
.where(
and(
eq(siteResources.mode, "inference"),
eq(siteResources.enabled, true),
isNotNull(siteResources.fullDomain)
)
);
for (const sr of siteResourcesInference) {
if (!sr.enabled || !sr.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = sr.fullDomain;
const srKey = `inference-sr${sr.siteResourceId}`;
const routerName = `${srKey}-router`;
const serviceName = `${srKey}-service`;
const rule = `Host(\`${fullDomain}\`) && ClientIP(${exitNode.address})`; // restrict to coming from the exit node ip range that the client is connected to
const domainParts = fullDomain.split(".");
const wildCard =
domainParts.length <= 2
? `*.${domainParts.join(".")}`
: `*.${domainParts.slice(1).join(".")}`;
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const tls = {
certResolver: globalDefaultResolver,
...(globalDefaultPreferWildcard
? { domains: [{ main: wildCard }] }
: {})
};
const srHeadersMiddlewareName = `${srKey}-headers-middleware`;
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[srHeadersMiddlewareName] = {
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": fullDomain
}
}
};
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
...(enableAiGatewayClientIpHeader
? [aiGatewayClientIpMiddlewareName]
: []),
aiGatewayTrustMiddlewareNameSiteResource,
srHeadersMiddlewareName,
...additionalMiddlewares
];
if (sr.ssl) {
config_output.http.routers[routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority: 200 // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first.
};
}
config_output.http.routers[routerName] = {
entryPoints: [
sr.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: serviceName,
rule,
priority: 200, // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first.
...(sr.ssl ? { tls } : {})
};
config_output.http.services[serviceName] = {
loadBalancer: {
servers: [{ url: aiGatewayUrl }]
}
};
}
}
return config_output;
}
+228
View File
@@ -0,0 +1,228 @@
import {
generateId,
generateIdFromEntropySize
} from "@server/auth/sessions/app";
import {
db,
resources,
User,
virtualApiKeyResources,
virtualApiKeys,
type Transaction,
type VirtualApiKey
} from "@server/db";
import config from "@server/lib/config";
import { decrypt, encrypt } from "@server/lib/crypto";
import { and, eq, inArray } from "drizzle-orm";
export {
VIRTUAL_API_KEY_PREFIX,
formatVirtualApiKeyCredential,
formatVirtualApiKeyPreview,
looksLikeVirtualApiKeyCredential,
stripVirtualApiKeyAuthHeaders
} from "@app/lib/virtualApiKeyFormat";
export type MintedVirtualApiKeySecret = {
virtualApiKeyId: string;
secret: string;
lastChars: string;
};
export type PublicVirtualApiKey = Omit<VirtualApiKey, "token"> & {
secret?: string;
};
export function mintVirtualApiKeySecret(): MintedVirtualApiKeySecret {
const secret = generateIdFromEntropySize(16);
return {
virtualApiKeyId: generateId(8),
secret,
lastChars: secret.slice(-4)
};
}
export function encryptVirtualApiKeyToken(secret: string): string {
return encrypt(secret, config.getRawConfig().server.secret!);
}
export function decryptVirtualApiKeyToken(ciphertext: string): string {
return decrypt(ciphertext, config.getRawConfig().server.secret!);
}
export function toPublicVirtualApiKey(
row: VirtualApiKey,
options?: { includeSecret?: boolean }
): PublicVirtualApiKey {
const { token, ...rest } = row;
if (!options?.includeSecret) {
return rest;
}
return {
...rest,
secret: decryptVirtualApiKeyToken(token)
};
}
export async function assertManualKeyResourcesInOrg(params: {
allResources: boolean;
resourceIds: number[];
orgId: string;
}): Promise<{ ok: true } | { ok: false; message: string }> {
const { allResources, resourceIds, orgId } = params;
if (allResources) {
return { ok: true };
}
if (resourceIds.length === 0) {
return {
ok: false,
message:
"Select at least one public inference resource, or enable all public inference resources"
};
}
const uniqueIds = [...new Set(resourceIds)];
const rows = await db
.select({ resourceId: resources.resourceId })
.from(resources)
.where(
and(
eq(resources.orgId, orgId),
eq(resources.mode, "inference"),
inArray(resources.resourceId, uniqueIds)
)
);
if (rows.length !== uniqueIds.length) {
return {
ok: false,
message:
"One or more resources are invalid public inference resources for this organization"
};
}
return { ok: true };
}
export async function replaceVirtualApiKeyResources(
trx: Transaction | typeof db,
virtualApiKeyId: string,
resourceIds: number[]
): Promise<void> {
await trx
.delete(virtualApiKeyResources)
.where(eq(virtualApiKeyResources.virtualApiKeyId, virtualApiKeyId));
const uniqueIds = [...new Set(resourceIds)];
if (uniqueIds.length === 0) {
return;
}
await trx.insert(virtualApiKeyResources).values(
uniqueIds.map((resourceId) => ({
virtualApiKeyId,
resourceId
}))
);
}
async function selectUserVirtualApiKey(
orgId: string,
userId: string
): Promise<VirtualApiKey | null> {
const [existing] = await db
.select()
.from(virtualApiKeys)
.where(
and(
eq(virtualApiKeys.orgId, orgId),
eq(virtualApiKeys.userId, userId),
eq(virtualApiKeys.kind, "user")
)
)
.limit(1);
return existing ?? null;
}
export async function getOrCreateUserVirtualApiKey(params: {
orgId: string;
user: User;
createdByUserId?: string | null;
}): Promise<{ key: VirtualApiKey; secret: string }> {
const { orgId, user, createdByUserId } = params;
const existing = await selectUserVirtualApiKey(orgId, user.userId);
if (existing) {
return {
key: existing,
secret: decryptVirtualApiKeyToken(existing.token)
};
}
const minted = mintVirtualApiKeySecret();
const now = Date.now();
try {
const [created] = await db
.insert(virtualApiKeys)
.values({
virtualApiKeyId: minted.virtualApiKeyId,
orgId,
kind: "user",
userId: user.userId,
name: `${user.name ?? user.username}'s API Key`,
description: null,
token: encryptVirtualApiKeyToken(minted.secret),
lastChars: minted.lastChars,
allResources: false,
expiresAt: null,
lastUsedAt: null,
createdAt: now,
createdByUserId: createdByUserId ?? null
})
.returning();
return { key: created, secret: minted.secret };
} catch {
const raced = await selectUserVirtualApiKey(orgId, user.userId);
if (raced) {
return {
key: raced,
secret: decryptVirtualApiKeyToken(raced.token)
};
}
throw new Error("Failed to create user virtual API key");
}
}
export async function rotateUserVirtualApiKey(params: {
orgId: string;
user: User;
createdByUserId?: string | null;
}): Promise<{ key: VirtualApiKey; secret: string }> {
const { orgId, user, createdByUserId } = params;
const existing = await selectUserVirtualApiKey(orgId, user.userId);
if (!existing) {
return getOrCreateUserVirtualApiKey(params);
}
const minted = mintVirtualApiKeySecret();
const [updated] = await db
.update(virtualApiKeys)
.set({
token: encryptVirtualApiKeyToken(minted.secret),
lastChars: minted.lastChars,
createdByUserId:
createdByUserId !== undefined
? createdByUserId
: existing.createdByUserId
})
.where(eq(virtualApiKeys.virtualApiKeyId, existing.virtualApiKeyId))
.returning();
return { key: updated, secret: minted.secret };
}
+4
View File
@@ -27,6 +27,10 @@ export * from "./verifyUserHasAction";
export * from "./verifyApiKeyAccess";
export * from "./verifySiteProvisioningKeyAccess";
export * from "./verifyDomainAccess";
export * from "./verifyAiProviderAccess";
export * from "./verifyAiModelAccess";
export * from "./verifyAiBudgetAccess";
export * from "./verifyVirtualApiKeyAccess";
export * from "./verifyUserIsOrgOwner";
export * from "./verifyUserFromResourceSession";
export * from "./verifySiteResourceAccess";
+3
View File
@@ -16,5 +16,8 @@ export * from "./verifyApiKeyClientAccess";
export * from "./verifyApiKeySiteResourceAccess";
export * from "./verifyApiKeyIdpAccess";
export * from "./verifyApiKeyDomainAccess";
export * from "./verifyApiKeyAiProviderAccess";
export * from "./verifyApiKeyAiModelAccess";
export * from "./verifyApiKeyResourcePolicyAccess";
export * from "./verifyApiKeySiteProvisioningKeyAccess";
export * from "./verifyVirtualApiKeyAccess";
@@ -0,0 +1,94 @@
import { Request, Response, NextFunction } from "express";
import { aiModels, aiProviders, apiKeyOrg, db } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyAiModelAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const modelIdRaw = getFirstString(req.params.modelId);
const modelId = Number.parseInt(modelIdRaw ?? "", 10);
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (Number.isNaN(modelId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID")
);
}
const [row] = await db
.select({
model: aiModels,
provider: aiProviders
})
.from(aiModels)
.innerJoin(
aiProviders,
eq(aiModels.providerId, aiProviders.providerId)
)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!row) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
if (apiKey.isRoot) {
req.aiProvider = row.provider;
req.aiModel = row.model;
return next();
}
const orgId = row.provider.orgId;
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
req.apiKeyOrg = apiKeyOrgRes[0];
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
req.aiProvider = row.provider;
req.aiModel = row.model;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying AI model access"
)
);
}
}
@@ -0,0 +1,85 @@
import { Request, Response, NextFunction } from "express";
import { aiProviders, apiKeyOrg, db } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyAiProviderAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const providerIdRaw = getFirstString(req.params.providerId);
const providerId = Number.parseInt(providerIdRaw ?? "", 10);
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (Number.isNaN(providerId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
);
}
const [provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
if (apiKey.isRoot) {
req.aiProvider = provider;
return next();
}
const orgId = provider.orgId;
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
req.apiKeyOrg = apiKeyOrgRes[0];
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
req.aiProvider = provider;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying AI provider access"
)
);
}
}
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { resources, targets, apiKeyOrg } from "@server/db";
import { aiProviders, resources, targets, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
@@ -43,43 +43,65 @@ export async function verifyApiKeyTargetAccess(
);
}
const resourceId = target.resourceId;
if (!resourceId) {
const { resourceId, providerId } = target;
if ((!resourceId && !providerId) || (resourceId && providerId)) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Target with ID ${targetId} does not have a resource ID`
)
);
}
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
`Target with ID ${targetId} has invalid ownership`
)
);
}
if (apiKey.isRoot) {
// Root keys can access any key in any org
// Root keys can access any target
return next();
}
if (!resource.orgId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Resource with ID ${resourceId} does not have an organization ID`
)
);
let orgId: string;
if (resourceId) {
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
if (!resource.orgId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Resource with ID ${resourceId} does not have an organization ID`
)
);
}
orgId = resource.orgId;
} else {
const [provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId!))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
orgId = provider.orgId;
}
if (!req.apiKeyOrg) {
@@ -89,7 +111,7 @@ export async function verifyApiKeyTargetAccess(
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, resource.orgId)
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
@@ -98,7 +120,7 @@ export async function verifyApiKeyTargetAccess(
}
}
if (!req.apiKeyOrg) {
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@@ -0,0 +1,87 @@
import { Request, Response, NextFunction } from "express";
import { apiKeyOrg, db, virtualApiKeys } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyVirtualApiKeyAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const virtualApiKeyId = getFirstString(req.params.virtualApiKeyId);
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (!virtualApiKeyId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid virtual API key ID"
)
);
}
const [key] = await db
.select()
.from(virtualApiKeys)
.where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId))
.limit(1);
if (!key || key.kind !== "manual") {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Virtual API key with ID ${virtualApiKeyId} not found`
)
);
}
if (apiKey.isRoot) {
req.virtualApiKey = key;
return next();
}
const orgId = key.orgId;
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
req.apiKeyOrg = apiKeyOrgRes[0];
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
req.virtualApiKey = key;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying virtual API key access"
)
);
}
}
@@ -0,0 +1,99 @@
import { Request, Response, NextFunction } from "express";
import { aiBudgets, db, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyAiBudgetAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const userId = req.user!.userId;
const budgetIdRaw = getFirstString(req.params.budgetId);
const budgetId = Number.parseInt(budgetIdRaw ?? "", 10);
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
if (Number.isNaN(budgetId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid budget ID")
);
}
const [budget] = await db
.select()
.from(aiBudgets)
.where(eq(aiBudgets.budgetId, budgetId))
.limit(1);
if (!budget) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI budget with ID ${budgetId} not found`
)
);
}
const orgId = budget.orgId;
if (!req.userOrg || req.userOrg.orgId !== orgId) {
const userOrgRole = await db
.select()
.from(userOrgs)
.where(
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
)
.limit(1);
req.userOrg = userOrgRole[0];
}
if (!req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
const policyCheck = await checkOrgAccessPolicy({
orgId: req.userOrg.orgId,
userId,
session: req.session
});
req.orgPolicyAllowed = policyCheck.allowed;
if (!policyCheck.allowed || policyCheck.error) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"" + (policyCheck.error || "Unknown error")
)
);
}
}
req.userOrgId = orgId;
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.aiBudget = budget;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying AI budget access"
)
);
}
}
+107
View File
@@ -0,0 +1,107 @@
import { Request, Response, NextFunction } from "express";
import { aiModels, aiProviders, db, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyAiModelAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const userId = req.user!.userId;
const modelIdRaw = getFirstString(req.params.modelId);
const modelId = Number.parseInt(modelIdRaw ?? "", 10);
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
if (Number.isNaN(modelId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID")
);
}
const [row] = await db
.select({
model: aiModels,
provider: aiProviders
})
.from(aiModels)
.innerJoin(
aiProviders,
eq(aiModels.providerId, aiProviders.providerId)
)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!row) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
const orgId = row.provider.orgId;
if (!req.userOrg || req.userOrg.orgId !== orgId) {
const userOrgRole = await db
.select()
.from(userOrgs)
.where(
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
)
.limit(1);
req.userOrg = userOrgRole[0];
}
if (!req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
const policyCheck = await checkOrgAccessPolicy({
orgId: req.userOrg.orgId,
userId,
session: req.session
});
req.orgPolicyAllowed = policyCheck.allowed;
if (!policyCheck.allowed || policyCheck.error) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"" + (policyCheck.error || "Unknown error")
)
);
}
}
req.userOrgId = orgId;
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.aiProvider = row.provider;
req.aiModel = row.model;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying AI model access"
)
);
}
}
@@ -0,0 +1,99 @@
import { Request, Response, NextFunction } from "express";
import { aiProviders, db, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyAiProviderAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const userId = req.user!.userId;
const providerIdRaw = getFirstString(req.params.providerId);
const providerId = Number.parseInt(providerIdRaw ?? "", 10);
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
if (Number.isNaN(providerId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
);
}
const [provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
const orgId = provider.orgId;
if (!req.userOrg || req.userOrg.orgId !== orgId) {
const userOrgRole = await db
.select()
.from(userOrgs)
.where(
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
)
.limit(1);
req.userOrg = userOrgRole[0];
}
if (!req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
const policyCheck = await checkOrgAccessPolicy({
orgId: req.userOrg.orgId,
userId,
session: req.session
});
req.orgPolicyAllowed = policyCheck.allowed;
if (!policyCheck.allowed || policyCheck.error) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"" + (policyCheck.error || "Unknown error")
)
);
}
}
req.userOrgId = orgId;
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.aiProvider = provider;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying AI provider access"
)
);
}
}
+71 -56
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { resources, targets, userOrgs } from "@server/db";
import { aiProviders, resources, targets, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
@@ -25,9 +25,7 @@ export async function verifyTargetAccess(
}
if (isNaN(targetId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID"));
}
const target = await db
@@ -45,73 +43,88 @@ export async function verifyTargetAccess(
);
}
const resourceId = target[0].resourceId;
const { resourceId, providerId } = target[0];
if (!resourceId) {
if ((!resourceId && !providerId) || (resourceId && providerId)) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Target with ID ${targetId} does not have a resource ID`
`Target with ID ${targetId} has invalid ownership`
)
);
}
try {
const resource = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId!))
.limit(1);
let orgId: string;
if (resource.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
if (resourceId) {
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource[0].orgId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`resource with ID ${resourceId} does not have an organization ID`
)
);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
if (!resource.orgId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Resource with ID ${resourceId} does not have an organization ID`
)
);
}
orgId = resource.orgId;
} else {
const [provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId!))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
orgId = provider.orgId;
}
if (!req.userOrg) {
const res = await db
const userOrgResult = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, resource[0].orgId)
)
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
);
req.userOrg = res[0];
req.userOrg = userOrgResult[0];
}
if (!req.userOrg) {
next(
if (!req.userOrg || req.userOrg.orgId !== orgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
} else {
req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrg.userId,
resource[0].orgId!
);
req.userOrgId = resource[0].orgId!;
}
const orgId = req.userOrg.orgId;
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.userOrgId = orgId;
if (req.orgPolicyAllowed === undefined && orgId) {
if (req.orgPolicyAllowed === undefined) {
const policyCheck = await checkOrgAccessPolicy({
orgId,
userId,
@@ -128,22 +141,24 @@ export async function verifyTargetAccess(
}
}
const resourceAllowed = await canUserAccessResource({
userId,
resourceId,
roleIds: req.userOrgRoleIds ?? []
});
if (resourceId) {
const resourceAllowed = await canUserAccessResource({
userId,
resourceId,
roleIds: req.userOrgRoleIds ?? []
});
if (!resourceAllowed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this resource"
)
);
if (!resourceAllowed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this resource"
)
);
}
}
next();
return next();
} catch (e) {
return next(
createHttpError(
@@ -0,0 +1,101 @@
import { Request, Response, NextFunction } from "express";
import { db, userOrgs, virtualApiKeys } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyVirtualApiKeyAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const userId = req.user!.userId;
const virtualApiKeyId = getFirstString(req.params.virtualApiKeyId);
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
if (!virtualApiKeyId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid virtual API key ID"
)
);
}
const [key] = await db
.select()
.from(virtualApiKeys)
.where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId))
.limit(1);
if (!key || key.kind !== "manual") {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Virtual API key with ID ${virtualApiKeyId} not found`
)
);
}
const orgId = key.orgId;
if (!req.userOrg || req.userOrg.orgId !== orgId) {
const userOrgRole = await db
.select()
.from(userOrgs)
.where(
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
)
.limit(1);
req.userOrg = userOrgRole[0];
}
if (!req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
const policyCheck = await checkOrgAccessPolicy({
orgId: req.userOrg.orgId,
userId,
session: req.session
});
req.orgPolicyAllowed = policyCheck.allowed;
if (!policyCheck.allowed || policyCheck.error) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"" + (policyCheck.error || "Unknown error")
)
);
}
}
req.userOrgId = orgId;
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.virtualApiKey = key;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying virtual API key access"
)
);
}
}
+5 -1
View File
@@ -28,7 +28,11 @@ export enum OpenAPITags {
HealthCheck = "Health Check",
PublicResourcePolicyLegacy = "Public Resource Policy (Legacy)",
PublicResourceLegacy = "Public Resource (Legacy)",
PrivateResourceLegacy = "Private Resource (Legacy)"
PrivateResourceLegacy = "Private Resource (Legacy)",
AiProvider = "AI Provider",
AiModel = "AI Model",
AiBudget = "AI Budget",
VirtualApiKey = "Virtual API Key"
}
// Order here controls the order tags are displayed in Swagger UI
+4
View File
@@ -18,12 +18,16 @@ import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthM
import { flushConnectionLogToDb } from "#private/routers/newt";
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement";
import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession";
async function cleanup() {
await stopPingAccumulator();
await flushBandwidthToDb();
await flushConnectionLogToDb();
await flushSiteBandwidthToDb();
await shutdownUsageRecorder();
await shutdownAiSessionLogger();
await rateLimitService.cleanup();
await wsCleanup();
await logStreamingManager.shutdown();
+20 -20
View File
@@ -24,12 +24,12 @@ export const localCache = new NodeCache({
});
// Log cache statistics periodically for monitoring
setInterval(() => {
const stats = localCache.getStats();
logger.debug(
`Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
);
}, 300000); // Every 5 minutes
// setInterval(() => {
// const stats = localCache.getStats();
// logger.debug(
// `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
// );
// }, 300000); // Every 5 minutes
/**
* Adaptive cache that uses Redis when available in multi-node environments,
@@ -80,9 +80,9 @@ class AdaptiveCache {
// Use local cache as fallback or primary
const success = localCache.set(key, value, effectiveTtl || 0);
if (success) {
logger.debug(`Set key in local cache: ${key}`);
}
// if (success) {
// logger.debug(`Set key in local cache: ${key}`);
// }
return success;
}
@@ -111,11 +111,11 @@ class AdaptiveCache {
// Use local cache as fallback or primary
const value = localCache.get<T>(key);
if (value !== undefined) {
logger.debug(`Cache hit in local cache: ${key}`);
} else {
logger.debug(`Cache miss in local cache: ${key}`);
}
// if (value !== undefined) {
// logger.debug(`Cache hit in local cache: ${key}`);
// } else {
// logger.debug(`Cache miss in local cache: ${key}`);
// }
return value;
}
@@ -344,7 +344,7 @@ class RegionalAdaptiveCache {
}
const success = regionalLocalCache.set(key, value, effectiveTtl || 0);
if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
// if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
return success;
}
@@ -367,11 +367,11 @@ class RegionalAdaptiveCache {
}
const value = regionalLocalCache.get<T>(key);
if (value !== undefined) {
logger.debug(`[regional] Cache hit in local cache: ${key}`);
} else {
logger.debug(`[regional] Cache miss in local cache: ${key}`);
}
// if (value !== undefined) {
// logger.debug(`[regional] Cache hit in local cache: ${key}`);
// } else {
// logger.debug(`[regional] Cache miss in local cache: ${key}`);
// }
return value;
}
+10 -1
View File
@@ -25,7 +25,6 @@ import {
Transaction
} from "@server/db";
import logger from "@server/logger";
import { ExitNodePingResult } from "@server/routers/newt";
import { eq, and, or, ne, isNull, inArray } from "drizzle-orm";
import axios from "axios";
import config from "../config";
@@ -330,6 +329,16 @@ export async function listExitNodes(
return exitNodesList;
}
export type ExitNodePingResult = {
exitNodeId: number;
latencyMs: number;
weight: number;
error?: string;
exitNodeName: string;
endpoint: string;
wasPreviouslyConnected: boolean;
};
/**
* Selects the most suitable exit node from a list of ping results.
*
+391 -5
View File
@@ -18,6 +18,7 @@ import {
domains,
exitNodes,
loginPage,
SiteResource,
targetHealthCheck
} from "@server/db";
import {
@@ -40,7 +41,10 @@ import {
siteNetworks,
siteResources,
Target,
targets
targets,
aiProviders,
resourceAiProviders,
siteResourceAiProviders
} from "@server/db";
import {
sanitize,
@@ -54,6 +58,13 @@ import {
getValidCertificatesForDomains
} from "#private/lib/certificates";
import { build } from "@server/build";
import regionalCache from "#private/lib/cache";
import {
AI_GATEWAY_TRUST_HEADER,
AI_GATEWAY_RESOURCE_TYPE_HEADER,
AI_GATEWAY_CLIENT_IP_HEADER,
getAiGatewayTrustToken
} from "@server/lib/aiGatewayTrust";
const redirectHttpsMiddlewareName = "redirect-to-https";
const redirectToRootMiddlewareName = "redirect-to-root";
@@ -87,8 +98,24 @@ export async function getTraefikConfig(
generateLoginPageRouters = false,
allowRawResources = true,
maintenancePageUiUrl: string | null = null,
browserGatewayUiUrl: string | null = null
browserGatewayUiUrl: string | null = null,
aiGatewayUrl: string | null = null
): Promise<any> {
// Get the exit node but cache it for 5 minutes to avoid hitting the DB too often
const exitNodeCacheKey = `exitNode:${exitNodeId}`;
let exitNode =
await regionalCache.get<typeof exitNodes.$inferSelect>(
exitNodeCacheKey
);
if (!exitNode) {
[exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, exitNodeId))
.limit(1);
await regionalCache.set(exitNodeCacheKey, exitNode, 300);
}
// Get resources with their targets and sites in a single optimized query
// Start from sites on this exit node, then join to targets and resources
const resourcesWithTargetsAndSites = await db
@@ -136,7 +163,7 @@ export async function getTraefikConfig(
siteId: sites.siteId,
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
subnet: sites.exitNodeSubnet,
exitNodeId: sites.exitNodeId,
// Namespace
domainNamespaceId: domainNamespaces.domainNamespaceId,
@@ -361,7 +388,7 @@ export async function getTraefikConfig(
let siteResourcesWithFullDomain: {
siteResourceId: number;
fullDomain: string | null;
mode: "http" | "host" | "cidr" | "ssh";
mode: SiteResource["mode"];
}[] = [];
if (
build == "enterprise" &&
@@ -386,7 +413,7 @@ export async function getTraefikConfig(
and(
eq(siteResources.enabled, true),
isNotNull(siteResources.fullDomain),
eq(siteResources.mode, "http"),
eq(siteResources.mode, "http"), // important so we dont double get the inference siteResources below
eq(siteResources.ssl, true),
eq(sites.exitNodeId, exitNodeId),
inArray(sites.type, siteTypes)
@@ -394,6 +421,56 @@ export async function getTraefikConfig(
);
}
// Inference-mode resources/siteResources have no targets/sites/network
// (their "backend" is the central AI gateway, not something on a site),
// so they can't be reached via the joins above - query them separately
// and include them on every exit node.
const inferenceResources = await db
.selectDistinct({
resourceId: resources.resourceId,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
wildcard: resources.wildcard,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert
})
.from(resources)
.innerJoin(
resourceAiProviders,
eq(resources.resourceId, resourceAiProviders.resourceId)
)
.innerJoin(
aiProviders,
eq(resourceAiProviders.providerId, aiProviders.providerId)
)
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.where(
and(
eq(resources.mode, "inference"),
eq(resources.enabled, true),
eq(aiProviders.enabled, true)
)
);
const siteResourcesInference = await db
.selectDistinct({
siteResourceId: siteResources.siteResourceId,
fullDomain: siteResources.fullDomain,
ssl: siteResources.ssl,
enabled: siteResources.enabled
})
.from(siteResources)
.where(
and(
eq(siteResources.mode, "inference"),
eq(siteResources.enabled, true),
isNotNull(siteResources.fullDomain)
)
);
let validCerts: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
// create a list of all domains to get certs for
@@ -415,6 +492,17 @@ export async function getTraefikConfig(
domains.add(bgResource.fullDomain);
}
}
// Include inference resource/siteResource domains
for (const ir of inferenceResources) {
if (ir.enabled && ir.ssl && ir.fullDomain) {
domains.add(ir.fullDomain);
}
}
for (const sr of siteResourcesInference) {
if (sr.enabled && sr.ssl && sr.fullDomain) {
domains.add(sr.fullDomain);
}
}
// get the valid certs for these domains
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
@@ -1461,6 +1549,304 @@ export async function getTraefikConfig(
}
}
if (aiGatewayUrl) {
// The AI gateway may live on a different host than the inference
// resource itself (e.g. a remote exit node forwarding to the
// central dashboard over a tunnel). passHostHeader would forward
// the resource's own Host, which that external host won't
// recognize, so we pin the Host header to the gateway's own host
// and smuggle the original resource host through in "p-host"
// instead (same pattern as the maintenance-page routes above).
let aiGatewayHost: string | undefined;
try {
aiGatewayHost = new URL(aiGatewayUrl).host;
} catch {
aiGatewayHost = undefined;
}
// The p-host smuggling above is only necessary when the AI gateway
// is overridden to a different host than the resource's own. In the
// default case, leave the Host header untouched so it's visible on
// the other end.
const aiGatewayOverride =
config.getRawConfig().server.ai_gateway_override;
// The trust token is the same for every inference route on this exit
// node, so it's defined once here and attached to each router below
// instead of being duplicated into a per-resource middleware. Two
// variants exist (public resource vs. siteResource) so the resource
// type header lets the gateway know which kind of router the
// request came through without re-deriving it from resourceId.
const aiGatewayTrustMiddlewareNameResource =
"ai-gateway-trust-headers-resource";
const aiGatewayTrustMiddlewareNameSiteResource =
"ai-gateway-trust-headers-site-resource";
config_output.http.middlewares[aiGatewayTrustMiddlewareNameResource] =
{
headers: {
customRequestHeaders: {
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(),
[AI_GATEWAY_RESOURCE_TYPE_HEADER]: "resource"
}
}
};
config_output.http.middlewares[
aiGatewayTrustMiddlewareNameSiteResource
] = {
headers: {
customRequestHeaders: {
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(),
[AI_GATEWAY_RESOURCE_TYPE_HEADER]: "site-resource"
}
}
};
// Opt-in: a Badger instance with forward auth disabled, used only
// to stamp the resolved client IP into a dedicated header before
// the request reaches whatever sits between Traefik and the AI
// gateway. Only the site-resource router below needs this - it's
// the only path that resolves request identity from the client IP
// (see resolveRequestUser in aiGateway/pipeline.ts) - and it's the
// only inference router that doesn't already run Badger.
const aiGatewayClientIpMiddlewareName = "ai-gateway-client-ip";
const enableAiGatewayClientIpHeader =
config.getRawConfig().server.enable_ai_gateway_client_ip_header;
if (enableAiGatewayClientIpHeader) {
config_output.http.middlewares[aiGatewayClientIpMiddlewareName] =
{
plugin: {
badger: {
disableForwardAuth: true,
realIpHeader: AI_GATEWAY_CLIENT_IP_HEADER
}
}
};
}
// Public inference resources: same TLS/cert-resolver handling as
// plain http-mode resources, but the service points at the AI
// gateway instead of any real backend targets.
for (const ir of inferenceResources) {
if (!ir.enabled) continue;
if (!ir.domainId || !ir.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = ir.fullDomain;
const irKey = `inference-r${ir.resourceId}`;
const routerName = `${irKey}-router`;
const serviceName = `${irKey}-service`;
let rule: string;
if (ir.wildcard && fullDomain.startsWith("*.")) {
const escaped = fullDomain.slice(2).replace(/\./g, "\\.");
rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
} else {
rule = `Host(\`${fullDomain}\`)`;
}
let tls: any = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!ir.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = ir.domainCertResolver
? ir.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
ir.preferWildcardCert !== undefined &&
ir.preferWildcardCert !== null
? ir.preferWildcardCert
: globalDefaultPreferWildcard;
tls = {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
} else {
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for inference resource domain: ${fullDomain}`
);
continue;
}
}
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
aiGatewayTrustMiddlewareNameResource
];
if (aiGatewayOverride) {
const irHeadersMiddlewareName = `${irKey}-headers-middleware`;
config_output.http.middlewares[irHeadersMiddlewareName] = {
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": fullDomain
}
}
};
routerMiddlewares.push(irHeadersMiddlewareName);
}
routerMiddlewares.push(...additionalMiddlewares);
if (ir.ssl) {
config_output.http.routers[routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority: 100
};
}
config_output.http.routers[routerName] = {
entryPoints: [
ir.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: serviceName,
rule,
priority: 100,
...(ir.ssl ? { tls } : {})
};
config_output.http.services[serviceName] = {
loadBalancer: {
servers: [{ url: aiGatewayUrl }]
}
};
}
// Private (siteResource) inference resources: routed by their alias
// instead of a public fullDomain, and deliberately WITHOUT the
// badger middleware - no per-user auth/policy stack exists for
// siteResources today (see plan doc), so gating here is
// reachability-only for now.
for (const sr of siteResourcesInference) {
if (!sr.enabled || !sr.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = sr.fullDomain;
const srKey = `inference-sr${sr.siteResourceId}`;
const routerName = `${srKey}-router`;
const serviceName = `${srKey}-service`;
const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to
let tls: any = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
const wildCard =
domainParts.length <= 2
? `*.${domainParts.join(".")}`
: `*.${domainParts.slice(1).join(".")}`;
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
tls = {
certResolver: globalDefaultResolver,
...(globalDefaultPreferWildcard
? { domains: [{ main: wildCard }] }
: {})
};
} else {
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for inference siteResource fullDomain: ${fullDomain}`
);
continue;
}
}
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares: string[] = [
...(enableAiGatewayClientIpHeader
? [aiGatewayClientIpMiddlewareName]
: []),
aiGatewayTrustMiddlewareNameSiteResource
];
if (aiGatewayOverride) {
const srHeadersMiddlewareName = `${srKey}-headers-middleware`;
config_output.http.middlewares[srHeadersMiddlewareName] = {
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": fullDomain
}
}
};
routerMiddlewares.push(srHeadersMiddlewareName);
}
routerMiddlewares.push(...additionalMiddlewares);
if (sr.ssl) {
config_output.http.routers[routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority: 200 // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first.
};
}
config_output.http.routers[routerName] = {
entryPoints: [
sr.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: serviceName,
rule,
priority: 200, // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first.
...(sr.ssl ? { tls } : {})
};
config_output.http.services[serviceName] = {
loadBalancer: {
servers: [{ url: aiGatewayUrl }]
}
};
}
}
if (generateLoginPageRouters) {
const exitNodeLoginPages = await db
.select({
@@ -134,6 +134,17 @@ async function capRetentionDays(
);
}
if (
org.settingsLogRetentionDaysAISessions !== null &&
org.settingsLogRetentionDaysAISessions > maxRetentionDays
) {
updates.settingsLogRetentionDaysAISessions = maxRetentionDays;
needsUpdate = true;
logger.info(
`Capping AI session log retention from ${org.settingsLogRetentionDaysAISessions} to ${maxRetentionDays} days for org ${orgId}`
);
}
// Apply updates if needed
if (needsUpdate) {
await db.update(orgs).set(updates).where(eq(orgs.orgId, orgId));
@@ -53,6 +53,15 @@ export async function handleSubscriptionDeleted(
return;
}
// If the subscription has been manually overridden, we lock it down
// so Stripe can no longer change (or delete) its status locally.
if (existingSubscription.override === true) {
logger.info(
`Subscription ${subscription.id} is locked (override=true). Ignoring deletion event from Stripe.`
);
return;
}
await db
.delete(subscriptions)
.where(eq(subscriptions.subscriptionId, subscription.id));
@@ -68,13 +68,27 @@ export async function handleSubscriptionUpdated(
const type = getSubType(fullSubscription);
const previousType = existingSubscription.type as SubscriptionType | null;
// If the subscription has been manually overridden, we lock the
// status down so Stripe webhooks can no longer change it.
const isLocked = existingSubscription.override === true;
if (isLocked) {
logger.info(
`Subscription ${subscription.id} is locked (override=true). Ignoring status change from Stripe (would have been ${subscription.status}).`
);
}
const effectiveStatus = isLocked
? existingSubscription.status
: subscription.status;
await db
.update(subscriptions)
.set({
status: subscription.status,
canceledAt: subscription.canceled_at
? subscription.canceled_at
: null,
status: effectiveStatus,
canceledAt: isLocked
? existingSubscription.canceledAt
: subscription.canceled_at
? subscription.canceled_at
: null,
updatedAt: Math.floor(Date.now() / 1000),
billingCycleAnchor: subscription.billing_cycle_anchor,
type: type
@@ -275,23 +289,23 @@ export async function handleSubscriptionUpdated(
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
await handleSubscriptionLifesycle(
customer.orgId,
subscription.status,
effectiveStatus,
type
);
// Handle feature lifecycle when subscription is canceled or becomes unpaid
if (
subscription.status === "canceled" ||
subscription.status === "unpaid" ||
subscription.status === "incomplete_expired"
effectiveStatus === "canceled" ||
effectiveStatus === "unpaid" ||
effectiveStatus === "incomplete_expired"
) {
logger.info(
`Subscription ${subscription.id} for org ${customer.orgId} is ${subscription.status}, disabling paid features`
`Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features`
);
await handleTierChange(customer.orgId, null, previousType ?? undefined);
}
} else if (type === "license") {
if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") {
if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") {
try {
// WARNING:
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
+3 -1
View File
@@ -351,6 +351,7 @@ hybridRouter.get(
}
const pangolinUIUrl = config.getRawConfig().app.dashboard_url; // points to the dashboard to serve from there
const aiGatewayUrl = `${config.getRawConfig().app.dashboard_url}/api/v1/ai-gateway`;
try {
const traefikConfig = await getTraefikConfig(
@@ -360,7 +361,8 @@ hybridRouter.get(
false, // Dont include login pages,
true, // allow raw resources
pangolinUIUrl, // dont generate maintenance page
pangolinUIUrl // generate browser gateway targets
pangolinUIUrl, // generate browser gateway targets
aiGatewayUrl
);
return response(res, {
@@ -107,7 +107,6 @@ const createResourcePolicyBodySchema = z.strictObject({
})
)
)
.max(50)
.transform((v) => v.map((e) => e.toLowerCase()))
.optional()
.default([]),
@@ -178,7 +178,7 @@ export async function reGenerateSiteSecret(
);
}
if (site.exitNodeId && site.subnet) {
if (site.exitNodeId && site.exitNodeSubnet) {
await deletePeer(site.exitNodeId, site.pubKey!); // the old pubkey
await addPeer(site.exitNodeId, {
publicKey: pubKey,
+126
View File
@@ -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.
+288
View File
@@ -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")
);
}
}
+79
View File
@@ -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")
);
}
}
+81
View File
@@ -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")
);
}
}
+12
View File
@@ -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";
+130
View File
@@ -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")
);
}
}
+18
View File
@@ -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;
};
+384
View File
@@ -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")
);
}
}
+48
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
export { handleAiGatewayProxy } from "./pipeline";
export { createAiGatewayRouter } from "./createAiGatewayRouter";
+275
View File
@@ -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 };
}
+343
View File
@@ -0,0 +1,343 @@
import { Request, Response } from "express";
import { and, eq } from "drizzle-orm";
import {
AiBudget,
AiProvider,
db,
exitNodes,
sites,
targets
} from "@server/db";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import { localCache } from "@server/lib/cache";
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
})
.from(targets)
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.innerJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
.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;
}
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
});
}
}
+141
View File
@@ -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,171 @@
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";
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 [provider] = await db
.insert(aiProviders)
.values({
orgId,
name,
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")
);
}
}
+81
View File
@@ -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,84 @@
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 type { GetAiProviderResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "get",
path: "/ai-provider/{providerId}",
description: "Get an AI provider by ID.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function getAiProvider(
req: Request,
res: Response,
next: NextFunction
): Promise<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 [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`
)
);
}
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")
);
}
}

Some files were not shown because too many files have changed in this diff Show More