mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-12 15:30:53 +02:00
Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9369e60695 | |||
| 295e38d2af | |||
| 11595f5f96 | |||
| b08e875b37 | |||
| 0016b8fce7 | |||
| f5b10df7cf | |||
| 1782f31075 | |||
| f7aca85417 | |||
| c42df737b0 | |||
| 75c6af3b11 | |||
| e734cc93a1 | |||
| e3ccc4f8d4 | |||
| 896c362dce | |||
| 864ab97293 | |||
| bc2f291352 | |||
| 98f5e39a7f | |||
| e0a66e79bb | |||
| c781be4e70 | |||
| 48c4b44f72 | |||
| 68a84181d9 | |||
| b60390d805 | |||
| b33e776072 | |||
| 2187f23588 | |||
| 732fd4eba1 | |||
| 495c772d6b | |||
| 02e4fe8b48 | |||
| 6d45486bb5 | |||
| 4c1f7f6243 | |||
| 1e3a9fb921 | |||
| 8b8e7913dc | |||
| 88515a7dad | |||
| e012063f5b | |||
| e76377d3c7 | |||
| a357f42c48 | |||
| 2becb15916 | |||
| 186eeed784 | |||
| 216c932cb9 | |||
| 52de5eb539 | |||
| eec3119297 | |||
| f2e7f83b3d | |||
| 93dea0525e | |||
| 7e6e0a8a9c | |||
| 211d3a53f5 | |||
| 187936e5dd | |||
| b180d064d1 | |||
| ed46afd81a | |||
| 0050fad70d | |||
| 3dc9c100e9 | |||
| 969e7c9296 | |||
| 591caab45a | |||
| 02e97d6ae4 | |||
| 996160fadc | |||
| dc1923ab1f | |||
| fe5831eb48 | |||
| ca79abc9d4 | |||
| 9eafa067b9 | |||
| 5e5e172d39 | |||
| bc7a883f6c | |||
| 12056aebc6 | |||
| 297cb9c8f2 | |||
| d699455b38 | |||
| 9e7b4afaec | |||
| e91c344e64 | |||
| 07f628b928 | |||
| 184e1425a4 | |||
| 22f2990f56 | |||
| 6c28c5f383 | |||
| e9f9cf54f4 | |||
| 751d1b083d | |||
| a4d77a4fd3 | |||
| f72252552f | |||
| c5d68675c9 | |||
| 36b8ef5fba | |||
| 6cca5e0472 | |||
| b4d2974e19 | |||
| fb896d6c0f | |||
| 6564bfe8ae | |||
| 75ce7e91d7 | |||
| 656eea5bb1 | |||
| aba27a7bbf | |||
| 3d7e322bf9 | |||
| 1f3fff4a9d | |||
| 39e06f2b6d | |||
| b775c5b674 | |||
| 796bf37141 | |||
| c5b0e1e876 | |||
| 2e8bd7a8c7 | |||
| 790daba796 | |||
| bcf6b86b84 | |||
| 2e9bd50172 | |||
| c673dce484 | |||
| e1dd8965dc | |||
| 346a0bdc98 | |||
| 8c1169738e | |||
| bc80f91a45 | |||
| 3d062389e9 | |||
| 7afddb5eb5 | |||
| 425a99e5ee | |||
| a5e9339af9 | |||
| 83e20c2dfa | |||
| f85d41945a | |||
| 149eb17b27 | |||
| 72d469b19c | |||
| 6526d7f225 | |||
| 36c0edc62e | |||
| aad26b9ae4 | |||
| 80dcdfe251 | |||
| 1696fc37a8 | |||
| 7759d87835 | |||
| c085de1e9e | |||
| e38359c74f | |||
| 2cfd7e867b | |||
| ec5a2b0cbe | |||
| 2bf426bc22 | |||
| ed8545f8a2 | |||
| 9811492a0b | |||
| 973925b35d | |||
| 0b30cfc341 | |||
| a7e44944fb | |||
| 9b25ebd6cd | |||
| 33b775e263 | |||
| b0edc6302e | |||
| 1a644b131d | |||
| f7689b7a5a | |||
| 1073011a2a | |||
| 6fa0009ebf | |||
| 42c0abedb7 | |||
| 56e59a93f3 | |||
| 9d581f3897 | |||
| e5ac6ec7cd | |||
| 694fe98131 | |||
| 730078597e | |||
| 32ac8db803 | |||
| aad2ed2719 | |||
| a790fed297 | |||
| deb2d5ce2a | |||
| 093097c619 | |||
| 2cdeb7c104 | |||
| 4dbb04bfb8 | |||
| fa8b921635 | |||
| 33dd10c670 | |||
| ba24e1c4f5 |
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
FROM node:24-alpine
|
||||
FROM node:24.18.1-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
|
||||
+7559
File diff suppressed because it is too large
Load Diff
@@ -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"`
|
||||
|
||||
1–5 all leave both candidates (same capability, same specificity, same
|
||||
catalog ownership, same class).
|
||||
|
||||
Result → **ambiguous error**
|
||||
|
||||
Disambiguate with different allow patterns, disable one attachment, or
|
||||
split across resources.
|
||||
|
||||
### Example H: Unknown Model Across Native + Aggregator
|
||||
|
||||
| Provider | Allow |
|
||||
|----------|-------|
|
||||
| OpenAI | `*` |
|
||||
| OpenRouter | `*` |
|
||||
|
||||
**Request:** `model: "my-fine-tune-v3"` (not in catalog)
|
||||
|
||||
1. Capability → both
|
||||
2. Allow / specificity → tie
|
||||
3. Catalog → both score `0` (typed miss + unknown aggregator model)
|
||||
4. Class → OpenAI (`2`) beats OpenRouter (`1`)
|
||||
5. Result → **OpenAI**
|
||||
|
||||
## Practical Guidance
|
||||
|
||||
- Native OpenAI + Anthropic with `*` is safe. Different default APIs never
|
||||
collide.
|
||||
- OpenAI + OpenRouter with `*` is usually fine for catalog-known OpenAI
|
||||
models. Native wins.
|
||||
- Prefer specific allow patterns (`gpt-4o`, `gpt-*`) when two providers share
|
||||
a capability.
|
||||
- Two providers of the same type both using `*` will stay ambiguous. Narrow
|
||||
at least one allow list.
|
||||
- Custom providers only win ties when no stronger native/aggregator signal
|
||||
remains.
|
||||
|
||||
## Related Behavior
|
||||
|
||||
- **Saving providers on a resource does not reject overlapping allows.**
|
||||
Collisions are resolved (or rejected) per request.
|
||||
- Budgets, auth, and upstream URL / target routing run after a single
|
||||
provider has been selected.
|
||||
@@ -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/`.
|
||||
+365
-1
@@ -855,12 +855,16 @@
|
||||
"authMethodsSave": "Save Settings",
|
||||
"policyAuthStackTitle": "Authentication",
|
||||
"policyAuthStackDescription": "Control which authentication methods are required to access this resource",
|
||||
"policyAuthInferenceStackDescription": "Choose which users and roles can authenticate to this AI gateway",
|
||||
"policyAuthOrLogicTitle": "Multiple authentication methods active",
|
||||
"policyAuthOrLogicBanner": "Visitors may authenticate using any one of the active methods below. They do not need to complete all of them.",
|
||||
"policyAuthMethodActive": "Active",
|
||||
"policyAuthMethodOff": "Off",
|
||||
"policyAuthSsoTitle": "Platform SSO",
|
||||
"policyAuthSsoDescription": "Require sign-in through your organization's identity provider",
|
||||
"policyAuthInferenceSsoDescription": "Selected users and roles can authenticate to the gateway using their identity API key",
|
||||
"policyAuthInferenceIdentityKeyHelp": "Every user already has an identity API key, so you only need to create virtual API keys for non-user clients or shared access. Users can retrieve their key by signing in with their identity provider at <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 +1083,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 +1430,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 +1470,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 +1646,298 @@
|
||||
"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",
|
||||
"myVirtualApiKeysTitle": "Your API Keys",
|
||||
"myVirtualApiKeysDescription": "View your identity key and any virtual API keys attributed to you in this organization",
|
||||
"myVirtualApiKeysResourceTitle": "Your API Keys for This Resource",
|
||||
"myVirtualApiKeysResourceDescription": "View your identity key and virtual API keys attributed to you that can access this resource",
|
||||
"myVirtualApiKeysIdentityTitle": "Identity Key",
|
||||
"myVirtualApiKeysIdentityHeadline": "Your Personal API Key",
|
||||
"myVirtualApiKeysIdentityDescription": "Your personal key for this organization. It is unique to your account and used to identify you when calling AI Gateway resources.",
|
||||
"myVirtualApiKeysIdentityResourceHeadline": "Your Personal API Key for This Resource",
|
||||
"myVirtualApiKeysIdentityResourceDescription": "Your personal key for this organization. Use it to call this AI Gateway resource.",
|
||||
"myVirtualApiKeysManualTitle": "Attributed Keys",
|
||||
"myVirtualApiKeysManualDescription": "Manual virtual API keys an admin associated with your account",
|
||||
"myVirtualApiKeysManualResourceDescription": "Manual virtual API keys associated with your account that can access this resource",
|
||||
"myVirtualApiKeysManualEmpty": "No attributed keys yet",
|
||||
"myVirtualApiKeysKindUser": "Identity",
|
||||
"myVirtualApiKeysKindManual": "Manual",
|
||||
"myVirtualApiKeysUnnamed": "Unnamed key",
|
||||
"myVirtualApiKeysRevealSecret": "Reveal Secret",
|
||||
"myVirtualApiKeysViewSecretDescription": "This secret authenticates you to AI Gateway resources",
|
||||
"aiProvidersTitle": "AI Providers",
|
||||
"aiProvidersDescription": "Connect model providers for AI workloads in this organization",
|
||||
"aiProvidersAdd": "Add Provider",
|
||||
"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.",
|
||||
"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 +2509,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 +2564,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 +2720,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeInference": "Inference",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Scheme",
|
||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
||||
@@ -2460,6 +2780,7 @@
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"createInternalResourceDialogModeInference": "Inference",
|
||||
"scheme": "Scheme",
|
||||
"createInternalResourceDialogScheme": "Scheme",
|
||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
||||
@@ -3072,6 +3393,36 @@
|
||||
"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",
|
||||
"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 +3432,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 +4088,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 +4190,4 @@
|
||||
"sessionToolbarShow": "Show toolbar",
|
||||
"sessionToolbarHide": "Hide toolbar",
|
||||
"actionUpdateSiteApprovals": "Update Site Approvals"
|
||||
}
|
||||
}
|
||||
Generated
+15
-8
@@ -65,6 +65,7 @@
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.5.2",
|
||||
"glob": "13.0.6",
|
||||
"gpt-tokenizer": "^3.4.0",
|
||||
"helmet": "8.2.0",
|
||||
"http-errors": "2.0.1",
|
||||
"input-otp": "1.4.2",
|
||||
@@ -146,7 +147,7 @@
|
||||
"esbuild-node-externals": "1.22.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"postcss": "8.5.23",
|
||||
"postcss": "8.5.15",
|
||||
"prettier": "3.8.3",
|
||||
"react-email": "6.5.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
@@ -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",
|
||||
@@ -14537,9 +14544,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -15660,9 +15667,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -15679,7 +15686,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
||||
+4
-3
@@ -33,9 +33,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||
"@aws-sdk/client-s3": "3.1056.0",
|
||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||
"@aws-sdk/client-s3": "3.1056.0",
|
||||
"@headlessui/react": "2.2.10",
|
||||
"@hookform/resolvers": "5.4.0",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
@@ -88,6 +88,7 @@
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.5.2",
|
||||
"glob": "13.0.6",
|
||||
"gpt-tokenizer": "^3.4.0",
|
||||
"helmet": "8.2.0",
|
||||
"http-errors": "2.0.1",
|
||||
"input-otp": "1.4.2",
|
||||
@@ -169,7 +170,7 @@
|
||||
"esbuild-node-externals": "1.22.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"postcss": "8.5.23",
|
||||
"postcss": "8.5.15",
|
||||
"prettier": "3.8.3",
|
||||
"react-email": "6.5.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
@@ -181,6 +182,6 @@
|
||||
"overrides": {
|
||||
"esbuild": "0.28.1",
|
||||
"dompurify": "3.4.0",
|
||||
"postcss": "8.5.23"
|
||||
"postcss": "8.5.15"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,305 @@ 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"
|
||||
}),
|
||||
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)
|
||||
]
|
||||
);
|
||||
|
||||
export const aiUsageRecords = pgTable(
|
||||
"aiUsageRecords",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
providerId: integer("providerId")
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
// 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_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: "cascade" }),
|
||||
capability: varchar("capability").notNull(),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
requestedModel: varchar("requestedModel"),
|
||||
isStream: boolean("isStream").notNull().default(false),
|
||||
requestBody: text("requestBody"),
|
||||
responseBody: text("responseBody"),
|
||||
// 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_session").on(t.sessionId)
|
||||
]
|
||||
);
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -1595,6 +2038,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 +2071,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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,315 @@ 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"
|
||||
}),
|
||||
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)
|
||||
]
|
||||
);
|
||||
|
||||
export const aiUsageRecords = sqliteTable(
|
||||
"aiUsageRecords",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
providerId: integer("providerId")
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
// 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_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: "cascade" }),
|
||||
capability: text("capability").notNull(),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
requestedModel: text("requestedModel"),
|
||||
isStream: integer("isStream", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
requestBody: text("requestBody"),
|
||||
responseBody: text("responseBody"),
|
||||
// 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_session").on(t.sessionId)
|
||||
]
|
||||
);
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -1577,6 +2028,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 +2063,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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
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
|
||||
].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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
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,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { Request } from "express";
|
||||
|
||||
export const AI_CAPABILITIES = [
|
||||
"openai_chat",
|
||||
"openai_responses",
|
||||
"anthropic_messages",
|
||||
"gemini_generate_content",
|
||||
"bedrock_model_invoke",
|
||||
"google_generate_content",
|
||||
"google_raw_predict",
|
||||
"bedrock_converse"
|
||||
] as const;
|
||||
|
||||
export type AiCapability = (typeof AI_CAPABILITIES)[number];
|
||||
|
||||
export type AiCapabilityRoute = {
|
||||
method: "POST";
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type AiCapabilityDefinition = {
|
||||
id: AiCapability;
|
||||
routes: AiCapabilityRoute[];
|
||||
extractModel: (req: Request) => string | undefined;
|
||||
resolveUpstreamUrl: (
|
||||
baseUrl: string,
|
||||
req: Request,
|
||||
model: string
|
||||
) => string;
|
||||
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",
|
||||
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",
|
||||
routes: [{ method: "POST", path: "/v1/responses" }],
|
||||
extractModel: bodyModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: isBodyOrSseStreaming
|
||||
},
|
||||
anthropic_messages: {
|
||||
id: "anthropic_messages",
|
||||
routes: [{ method: "POST", path: "/v1/messages" }],
|
||||
extractModel: bodyModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: isBodyOrSseStreaming
|
||||
},
|
||||
gemini_generate_content: {
|
||||
id: "gemini_generate_content",
|
||||
routes: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1beta/models/:model\\:generateContent"
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1beta/models/:model\\:streamGenerateContent"
|
||||
}
|
||||
],
|
||||
extractModel: paramModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: isGeminiStyleStreaming
|
||||
},
|
||||
google_generate_content: {
|
||||
id: "google_generate_content",
|
||||
routes: [
|
||||
{
|
||||
method: "POST",
|
||||
// Vertex publisher model generateContent
|
||||
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:generateContent"
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamGenerateContent"
|
||||
}
|
||||
],
|
||||
extractModel: paramModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: isGeminiStyleStreaming
|
||||
},
|
||||
google_raw_predict: {
|
||||
id: "google_raw_predict",
|
||||
routes: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:rawPredict"
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamRawPredict"
|
||||
}
|
||||
],
|
||||
extractModel: paramModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: (req, contentType) =>
|
||||
pathIncludes(req, "streamRawPredict") ||
|
||||
pathIncludes(req, "alt=sse") ||
|
||||
contentTypeIsSse(contentType)
|
||||
},
|
||||
bedrock_model_invoke: {
|
||||
id: "bedrock_model_invoke",
|
||||
routes: [
|
||||
{ method: "POST", path: "/model/:model/invoke" },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/model/:model/invoke-with-response-stream"
|
||||
}
|
||||
],
|
||||
extractModel: paramModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: (req, contentType) =>
|
||||
pathIncludes(req, "invoke-with-response-stream") ||
|
||||
contentTypeIsAmazonEventStream(contentType) ||
|
||||
contentTypeIsSse(contentType)
|
||||
},
|
||||
bedrock_converse: {
|
||||
id: "bedrock_converse",
|
||||
routes: [
|
||||
{ method: "POST", path: "/model/:model/converse" },
|
||||
{ method: "POST", path: "/model/:model/converse-stream" }
|
||||
],
|
||||
extractModel: paramModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import fs from "node:fs";
|
||||
import axios from "axios";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
|
||||
|
||||
export const CATALOG_PROVIDERS = [
|
||||
"openai",
|
||||
"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: {
|
||||
input: number | null;
|
||||
output: number | null;
|
||||
cacheRead: number | null;
|
||||
reasoningOutput: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
type RawCatalogEntry = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
provider: string;
|
||||
input_cost_per_token?: number | null;
|
||||
output_cost_per_token?: number | null;
|
||||
cache_read_input_token_cost?: number | null;
|
||||
output_cost_per_reasoning_token?: number | null;
|
||||
pricing?: {
|
||||
input?: number | null;
|
||||
output?: number | null;
|
||||
cacheRead?: number | null;
|
||||
reasoningOutput?: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
function normalizeCatalogProvider(raw: string): CatalogProvider | null {
|
||||
if (CATALOG_PROVIDER_SET.has(raw)) {
|
||||
return raw as CatalogProvider;
|
||||
}
|
||||
if (raw.startsWith("bedrock")) {
|
||||
return "bedrock";
|
||||
}
|
||||
if (raw.startsWith("vertex")) {
|
||||
return "vertex";
|
||||
}
|
||||
if (raw.startsWith("azure")) {
|
||||
return "azure";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeEntry(raw: RawCatalogEntry): AiModelCatalogEntry | null {
|
||||
const provider = normalizeCatalogProvider(raw.provider);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const model = raw.model ?? raw.name ?? raw.id;
|
||||
if (!model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
pricing: {
|
||||
input: raw.pricing?.input ?? raw.input_cost_per_token ?? null,
|
||||
output: raw.pricing?.output ?? raw.output_cost_per_token ?? null,
|
||||
cacheRead:
|
||||
raw.pricing?.cacheRead ??
|
||||
raw.cache_read_input_token_cost ??
|
||||
null,
|
||||
reasoningOutput:
|
||||
raw.pricing?.reasoningOutput ??
|
||||
raw.output_cost_per_reasoning_token ??
|
||||
null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function providerKey(provider: CatalogProvider, key: string): string {
|
||||
return `${provider}\0${key}`;
|
||||
}
|
||||
|
||||
export class AiModelCatalog {
|
||||
private entries: AiModelCatalogEntry[] = [];
|
||||
private byProvider = new Map<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 parsed = JSON.parse(raw) as { data: RawCatalogEntry[] };
|
||||
return (parsed.data ?? [])
|
||||
.map(normalizeEntry)
|
||||
.filter((e): e is AiModelCatalogEntry => e != null);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to read AI model catalog file", { error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchFromUpstream(
|
||||
upstreamUrl: string
|
||||
): Promise<AiModelCatalogEntry[] | null> {
|
||||
try {
|
||||
const res = await axios.get<{ data: RawCatalogEntry[] }>(
|
||||
upstreamUrl,
|
||||
{ timeout: 15_000 }
|
||||
);
|
||||
return (res.data?.data ?? [])
|
||||
.map(normalizeEntry)
|
||||
.filter((e): e is AiModelCatalogEntry => e != null);
|
||||
} catch (error: any) {
|
||||
logger.warn(
|
||||
`Failed to fetch AI model catalog from ${upstreamUrl}: ${error.message || error}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
const { file, upstream_url } = config.getRawConfig().ai.model_catalog;
|
||||
|
||||
const fetched = file
|
||||
? await this.fetchFromFile(file)
|
||||
: await this.fetchFromUpstream(upstream_url);
|
||||
|
||||
if (fetched) {
|
||||
this.setEntries(fetched);
|
||||
logger.debug(
|
||||
`AI model catalog refreshed: ${this.entries.length} models loaded`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
"AI model catalog refresh failed; keeping previously loaded catalog in memory"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNextRefresh(): void {
|
||||
const { refresh_interval_min_hours, refresh_interval_max_hours } =
|
||||
config.getRawConfig().ai.model_catalog;
|
||||
|
||||
// Jittered rather than fixed so that many self-hosted instances don't
|
||||
// all hit the upstream catalog endpoint at the same moment.
|
||||
const minMs = refresh_interval_min_hours * 60 * 60 * 1000;
|
||||
const maxMs = refresh_interval_max_hours * 60 * 60 * 1000;
|
||||
const delayMs = minMs + Math.random() * Math.max(0, maxMs - minMs);
|
||||
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
}
|
||||
this.refreshTimer = setTimeout(async () => {
|
||||
await this.refresh();
|
||||
this.scheduleNextRefresh();
|
||||
}, delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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.input,
|
||||
outputCostPerToken: entry.pricing.output,
|
||||
cacheReadInputTokenCost: entry.pricing.cacheRead,
|
||||
outputCostPerReasoningToken: entry.pricing.reasoningOutput,
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { decrypt, encrypt } from "@server/lib/crypto";
|
||||
import {
|
||||
parseCapabilities,
|
||||
type AiCapability
|
||||
} from "@server/lib/aiCapabilities";
|
||||
|
||||
export type AiProviderType =
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "googleGemini"
|
||||
| "vertexAi"
|
||||
| "bedrock"
|
||||
| "microsoftFoundry"
|
||||
| "openRouter"
|
||||
| "vercelAiGateway"
|
||||
| "custom";
|
||||
|
||||
export const AI_PROVIDER_AUTH_TYPES = [
|
||||
"bearer",
|
||||
"x-api-key",
|
||||
"x-goog-api-key",
|
||||
"hec",
|
||||
"cf-aig-authorization",
|
||||
"none",
|
||||
"passthrough"
|
||||
] as const;
|
||||
|
||||
export type AiProviderAuthType = (typeof AI_PROVIDER_AUTH_TYPES)[number];
|
||||
export type AiBudgetUnit = "usd" | "tokens";
|
||||
export type AiProviderRoutingMode = "url" | "target";
|
||||
|
||||
type AiProviderDefaults = {
|
||||
upstreamUrl: string | null;
|
||||
authType: AiProviderAuthType;
|
||||
capabilities: readonly AiCapability[];
|
||||
};
|
||||
|
||||
export const AI_PROVIDER_DEFAULTS: Record<
|
||||
Exclude<AiProviderType, "custom">,
|
||||
AiProviderDefaults
|
||||
> = {
|
||||
openai: {
|
||||
upstreamUrl: "https://api.openai.com/v1",
|
||||
authType: "bearer",
|
||||
capabilities: ["openai_chat", "openai_responses"]
|
||||
},
|
||||
anthropic: {
|
||||
upstreamUrl: "https://api.anthropic.com",
|
||||
authType: "x-api-key",
|
||||
capabilities: ["anthropic_messages"]
|
||||
},
|
||||
googleGemini: {
|
||||
upstreamUrl: "https://generativelanguage.googleapis.com",
|
||||
authType: "x-goog-api-key",
|
||||
capabilities: ["gemini_generate_content"]
|
||||
},
|
||||
vertexAi: {
|
||||
upstreamUrl: null,
|
||||
authType: "bearer",
|
||||
capabilities: ["google_generate_content", "google_raw_predict"]
|
||||
},
|
||||
bedrock: {
|
||||
upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
authType: "bearer",
|
||||
capabilities: ["bedrock_converse"]
|
||||
},
|
||||
microsoftFoundry: {
|
||||
upstreamUrl: null,
|
||||
authType: "bearer",
|
||||
capabilities: ["openai_chat", "openai_responses", "anthropic_messages"]
|
||||
},
|
||||
openRouter: {
|
||||
upstreamUrl: "https://openrouter.ai/api/v1",
|
||||
authType: "bearer",
|
||||
capabilities: ["openai_chat"]
|
||||
},
|
||||
vercelAiGateway: {
|
||||
upstreamUrl: "https://ai-gateway.vercel.sh/v1",
|
||||
authType: "bearer",
|
||||
capabilities: ["openai_chat", "openai_responses"]
|
||||
}
|
||||
};
|
||||
|
||||
const CONFLICTING_AUTH_HEADERS = [
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"x-goog-api-key",
|
||||
"cf-aig-authorization"
|
||||
] as const;
|
||||
|
||||
export function authTypeRequiresApiKey(authType: AiProviderAuthType): boolean {
|
||||
return authType !== "none" && authType !== "passthrough";
|
||||
}
|
||||
|
||||
export function providerRequiresUpstreamUrl(
|
||||
type: AiProviderType,
|
||||
routingMode: AiProviderRoutingMode = "url"
|
||||
): boolean {
|
||||
if (routingMode === "target") {
|
||||
return false;
|
||||
}
|
||||
if (type === "custom") {
|
||||
return true;
|
||||
}
|
||||
return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null;
|
||||
}
|
||||
|
||||
export function resolveAiProviderCreateFields(input: {
|
||||
type: AiProviderType;
|
||||
upstreamUrl?: string | null;
|
||||
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.
|
||||
* - Injected modes: strip client auth headers, then set the provider key.
|
||||
* - none: strip client auth headers, send no auth.
|
||||
* - passthrough: leave client auth headers as-is.
|
||||
*/
|
||||
export function applyAiProviderAuthHeaders(
|
||||
headers: Record<string, string>,
|
||||
authType: AiProviderAuthType,
|
||||
apiKey: string | null
|
||||
): void {
|
||||
if (authType === "passthrough") {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const name of CONFLICTING_AUTH_HEADERS) {
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === name) {
|
||||
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];
|
||||
}
|
||||
|
||||
export function defaultsForProviderType(
|
||||
type: AiProviderType
|
||||
): readonly AiCapability[] {
|
||||
if (type === "custom") {
|
||||
return [];
|
||||
}
|
||||
return AI_PROVIDER_DEFAULTS[type].capabilities;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -632,7 +632,6 @@ export const ResourcePolicySchema = z.object({
|
||||
})
|
||||
)
|
||||
)
|
||||
.max(50)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
.optional()
|
||||
.default([]),
|
||||
|
||||
+15
-15
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,3 +2,5 @@ export * from "./exitNodes";
|
||||
export * from "./exitNodeComms";
|
||||
export * from "./subnet";
|
||||
export * from "./getCurrentExitNodeId";
|
||||
export * from "./calculateExitNodeWeight";
|
||||
export * from "./getUniqueSubnetForExitNode";
|
||||
|
||||
+21
-8
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -139,6 +145,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",
|
||||
@@ -394,6 +401,36 @@ export const configSchema = z
|
||||
disable_enterprise_features: z.boolean().optional()
|
||||
})
|
||||
.optional(),
|
||||
ai: z
|
||||
.object({
|
||||
model_catalog: z
|
||||
.object({
|
||||
upstream_url: z
|
||||
.url()
|
||||
.optional()
|
||||
.default("https://api.fossorial.io/api/v1/models"),
|
||||
// No default - only used when an operator wants to
|
||||
// pin the catalog to a local file instead of
|
||||
// fetching it from upstream_url.
|
||||
file: z.string().optional(),
|
||||
refresh_interval_min_hours: z
|
||||
.number()
|
||||
.positive()
|
||||
.gt(0)
|
||||
.optional()
|
||||
.default(6),
|
||||
refresh_interval_max_hours: z
|
||||
.number()
|
||||
.positive()
|
||||
.gt(0)
|
||||
.optional()
|
||||
.default(12)
|
||||
})
|
||||
.optional()
|
||||
.prefault({})
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
dns: z
|
||||
.object({
|
||||
nameservers: z
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,7 @@ 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";
|
||||
|
||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||
const badgerMiddlewareName = "badger";
|
||||
@@ -45,8 +54,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 +112,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 +234,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 +732,229 @@ export async function getTraefikConfig(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (aiGatewayUrl) {
|
||||
// The AI gateway may live on a different host than the inference
|
||||
// resource itself (e.g. a remote exit node forwarding to the
|
||||
// central dashboard over a tunnel). passHostHeader would forward
|
||||
// the resource's own Host, which that external host won't
|
||||
// recognize, so we pin the Host header to the gateway's own host
|
||||
// and smuggle the original resource host through in "p-host"
|
||||
// instead.
|
||||
let aiGatewayHost: string | undefined;
|
||||
try {
|
||||
aiGatewayHost = new URL(aiGatewayUrl).host;
|
||||
} catch {
|
||||
aiGatewayHost = undefined;
|
||||
}
|
||||
|
||||
// Public inference resources: same TLS/cert-resolver handling as
|
||||
// plain http-mode resources, but the service points at the AI
|
||||
// gateway instead of any real backend targets.
|
||||
for (const ir of inferenceResources) {
|
||||
if (!ir.enabled) continue;
|
||||
if (!ir.domainId || !ir.fullDomain) continue;
|
||||
|
||||
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,
|
||||
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 = [
|
||||
srHeadersMiddlewareName,
|
||||
...additionalMiddlewares
|
||||
];
|
||||
|
||||
if (sr.ssl) {
|
||||
config_output.http.routers[routerName + "-redirect"] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: serviceName,
|
||||
rule,
|
||||
priority: 100
|
||||
};
|
||||
}
|
||||
|
||||
config_output.http.routers[routerName] = {
|
||||
entryPoints: [
|
||||
sr.ssl
|
||||
? config.getRawConfig().traefik.https_entrypoint
|
||||
: config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: routerMiddlewares,
|
||||
service: serviceName,
|
||||
rule,
|
||||
priority: 100,
|
||||
...(sr.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
config_output.http.services[serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: [{ url: aiGatewayUrl }]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return config_output;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
generateId,
|
||||
generateIdFromEntropySize
|
||||
} from "@server/auth/sessions/app";
|
||||
import {
|
||||
db,
|
||||
resources,
|
||||
virtualApiKeyResources,
|
||||
virtualApiKeys,
|
||||
type Transaction,
|
||||
type VirtualApiKey
|
||||
} from "@server/db";
|
||||
import config from "@server/lib/config";
|
||||
import { decrypt, encrypt } from "@server/lib/crypto";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
|
||||
export type MintedVirtualApiKeySecret = {
|
||||
virtualApiKeyId: string;
|
||||
secret: string;
|
||||
lastChars: string;
|
||||
};
|
||||
|
||||
export type PublicVirtualApiKey = Omit<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;
|
||||
userId: string;
|
||||
createdByUserId?: string | null;
|
||||
}): Promise<{ key: VirtualApiKey; secret: string }> {
|
||||
const { orgId, userId, createdByUserId } = params;
|
||||
|
||||
const existing = await selectUserVirtualApiKey(orgId, userId);
|
||||
if (existing) {
|
||||
return {
|
||||
key: existing,
|
||||
secret: decryptVirtualApiKeyToken(existing.token)
|
||||
};
|
||||
}
|
||||
|
||||
const minted = mintVirtualApiKeySecret();
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
const [created] = await db
|
||||
.insert(virtualApiKeys)
|
||||
.values({
|
||||
virtualApiKeyId: minted.virtualApiKeyId,
|
||||
orgId,
|
||||
kind: "user",
|
||||
userId,
|
||||
name: null,
|
||||
description: null,
|
||||
token: encryptVirtualApiKeyToken(minted.secret),
|
||||
lastChars: minted.lastChars,
|
||||
allResources: false,
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
createdAt: now,
|
||||
createdByUserId: createdByUserId ?? null
|
||||
})
|
||||
.returning();
|
||||
|
||||
return { key: created, secret: minted.secret };
|
||||
} catch {
|
||||
const raced = await selectUserVirtualApiKey(orgId, userId);
|
||||
if (raced) {
|
||||
return {
|
||||
key: raced,
|
||||
secret: decryptVirtualApiKeyToken(raced.token)
|
||||
};
|
||||
}
|
||||
throw new Error("Failed to create user virtual API key");
|
||||
}
|
||||
}
|
||||
|
||||
export async function rotateUserVirtualApiKey(params: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
createdByUserId?: string | null;
|
||||
}): Promise<{ key: VirtualApiKey; secret: string }> {
|
||||
const { orgId, userId, createdByUserId } = params;
|
||||
const existing = await selectUserVirtualApiKey(orgId, userId);
|
||||
|
||||
if (!existing) {
|
||||
return getOrCreateUserVirtualApiKey(params);
|
||||
}
|
||||
|
||||
const minted = mintVirtualApiKeySecret();
|
||||
const [updated] = await db
|
||||
.update(virtualApiKeys)
|
||||
.set({
|
||||
token: encryptVirtualApiKeyToken(minted.secret),
|
||||
lastChars: minted.lastChars,
|
||||
createdByUserId:
|
||||
createdByUserId !== undefined
|
||||
? createdByUserId
|
||||
: existing.createdByUserId
|
||||
})
|
||||
.where(eq(virtualApiKeys.virtualApiKeyId, existing.virtualApiKeyId))
|
||||
.returning();
|
||||
|
||||
return { key: updated, secret: minted.secret };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,7 @@ import {
|
||||
getValidCertificatesForDomains
|
||||
} from "#private/lib/certificates";
|
||||
import { build } from "@server/build";
|
||||
import regionalCache from "#private/lib/cache";
|
||||
|
||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||
const redirectToRootMiddlewareName = "redirect-to-root";
|
||||
@@ -87,8 +92,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 +157,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 +382,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 +407,7 @@ export async function getTraefikConfig(
|
||||
and(
|
||||
eq(siteResources.enabled, true),
|
||||
isNotNull(siteResources.fullDomain),
|
||||
eq(siteResources.mode, "http"),
|
||||
eq(siteResources.mode, "http"), // important so we dont double get the inference siteResources below
|
||||
eq(siteResources.ssl, true),
|
||||
eq(sites.exitNodeId, exitNodeId),
|
||||
inArray(sites.type, siteTypes)
|
||||
@@ -394,6 +415,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 +486,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 +1543,244 @@ 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;
|
||||
|
||||
// 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];
|
||||
|
||||
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[] = [];
|
||||
|
||||
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: 100
|
||||
};
|
||||
}
|
||||
|
||||
config_output.http.routers[routerName] = {
|
||||
entryPoints: [
|
||||
sr.ssl
|
||||
? config.getRawConfig().traefik.https_entrypoint
|
||||
: config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: routerMiddlewares,
|
||||
service: serviceName,
|
||||
rule,
|
||||
priority: 100,
|
||||
...(sr.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
config_output.http.services[serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: [{ url: aiGatewayUrl }]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# AI Budget API
|
||||
|
||||
Public/OSS CRUD entity (`server/routers/aiBudget/`, not enterprise-gated).
|
||||
Table: `aiBudgets` in `server/db/{pg,sqlite}/schema/schema.ts`, type `AiBudget`.
|
||||
|
||||
## What a budget is
|
||||
|
||||
A row is a spend/usage cap of `amount` `unit` (`usd` | `tokens`) per `period`
|
||||
(`hourly` | `daily` | `weekly` | `monthly` | `yearly` | `lifetime`), with
|
||||
`enforcement` (`hard` | `soft`) and an `enabled` flag.
|
||||
|
||||
Every budget belongs to an org (`orgId`, required) and is optionally further
|
||||
scoped to **exactly one** of:
|
||||
|
||||
- `providerId` → an `aiProviders` row
|
||||
- `modelId` → an `aiModels` row
|
||||
- `resourceId` → a `resources` row
|
||||
- `siteResourceId` → a `siteResources` row
|
||||
- `roleId` → a `roles` row
|
||||
|
||||
If none of those five are set, the budget is **org-wide**. Setting more than
|
||||
one at once is rejected by `validation.ts`'s `refineBudgetScopeFields`
|
||||
(`400`, "Only one of providerId, modelId, resourceId, siteResourceId, or
|
||||
roleId may be set on a budget").
|
||||
|
||||
## Uniqueness / conflict rule
|
||||
|
||||
A given scope (one specific provider, or model, or resource, or site
|
||||
resource, or role, or "org-wide") may have **multiple** budgets, but at most
|
||||
**one per `(unit, period)` combination** — e.g. one `weekly`/`usd` budget and
|
||||
one `hourly`/`usd` budget can coexist on the same provider, but two
|
||||
`weekly`/`usd` budgets cannot. This is enforced at two levels:
|
||||
|
||||
- DB: composite `unique` constraints in both schema files —
|
||||
`ai_budget_provider_uniq (providerId, unit, period)`,
|
||||
`ai_budget_model_uniq (modelId, unit, period)`,
|
||||
`ai_budget_resource_uniq (resourceId, unit, period)`,
|
||||
`ai_budget_site_resource_uniq (siteResourceId, unit, period)`,
|
||||
`ai_budget_role_uniq (roleId, unit, period)`. (NULL scope columns never
|
||||
collide under a plain unique index, so this does *not* cover the org-wide
|
||||
case — see next bullet.)
|
||||
- App: `createAiBudget`/`updateAiBudget` both run an explicit pre-check
|
||||
query keyed on `(scopeCondition, unit, period)` before insert/update,
|
||||
where `scopeCondition` is `eq(<scopeColumn>, id)` for whichever scope
|
||||
field is set, or — when none is set — `orgId = X AND` all five scope
|
||||
columns `IS NULL`, so org-wide budgets get the same one-per-`(unit,
|
||||
period)` guarantee even though the DB constraint can't express it.
|
||||
Violating this returns `409` with
|
||||
`` `A ${period} ${unit} budget already exists for this scope` ``.
|
||||
|
||||
Because only one row can ever exist for a given `(scope, unit, period)`,
|
||||
there is no separate check needed to prevent a `hard` and a `soft` budget
|
||||
from coexisting on the same `(scope, unit, period)` — the conflict check
|
||||
above already blocks the second row regardless of its `enforcement` value.
|
||||
|
||||
On `updateAiBudget`, the conflict/ownership checks are run against the
|
||||
**merged** next-state (existing row's scope/unit/period overlaid with
|
||||
whatever the request body changes), not just the fields present in the
|
||||
body — so e.g. changing only `unit` on a budget that already has
|
||||
`providerId` set re-validates against that provider's other budgets at the
|
||||
new unit.
|
||||
|
||||
## Ownership validation
|
||||
|
||||
`providerId`/`modelId`/`resourceId`/`siteResourceId`/`roleId` are validated
|
||||
to belong to the same `orgId` as the budget (`modelId` via an
|
||||
`aiModels ⋈ aiProviders` join, since `aiModels` has no `orgId` column
|
||||
directly). A mismatch returns `404`, not `403` — this matches how the
|
||||
sibling `aiProvider`/`aiModel` routers report cross-org references.
|
||||
|
||||
## Routes
|
||||
|
||||
All under `server/routers/external.ts`, registered right after the
|
||||
`aiProvider`/`aiModel` block. `PUT` = create, `POST` = update (repo
|
||||
convention, not standard REST).
|
||||
|
||||
| Method | Path | Middleware | Action | Handler |
|
||||
|---|---|---|---|---|
|
||||
| PUT | `/org/:orgId/ai-budget` | `verifyOrgAccess` | `createAiBudget` | `createAiBudget` |
|
||||
| GET | `/org/:orgId/ai-budgets` | `verifyOrgAccess` | `listAiBudgets` | `listAiBudgets` (paginated) |
|
||||
| GET | `/ai-budget/:budgetId` | `verifyAiBudgetAccess` | `getAiBudget` | `getAiBudget` |
|
||||
| POST | `/ai-budget/:budgetId` | `verifyAiBudgetAccess` | `updateAiBudget` | `updateAiBudget` |
|
||||
| DELETE | `/ai-budget/:budgetId` | `verifyAiBudgetAccess` | `deleteAiBudget` | `deleteAiBudget` |
|
||||
| GET | `/ai-provider/:providerId/ai-budgets` | `verifyAiProviderAccess` | `listAiBudgets` | `listAiBudgetsForProvider` |
|
||||
| GET | `/ai-model/:modelId/ai-budgets` | `verifyAiModelAccess` | `listAiBudgets` | `listAiBudgetsForModel` |
|
||||
| GET | `/resource/:resourceId/ai-budgets` | `verifyResourceAccess` | `listAiBudgets` | `listAiBudgetsForResource` |
|
||||
| GET | `/site-resource/:siteResourceId/ai-budgets` | `verifySiteResourceAccess` | `listAiBudgets` | `listAiBudgetsForSiteResource` |
|
||||
| GET | `/role/:roleId/ai-budgets` | `verifyRoleAccess` | `listAiBudgets` | `listAiBudgetsForRole` |
|
||||
|
||||
The five scope-filtered `GET .../ai-budgets` routes intentionally reuse the
|
||||
single `ActionsEnum.listAiBudgets` action rather than getting one action
|
||||
each — access control is already fully handled by the entity-specific
|
||||
middleware (a user who can see the provider/resource/etc. can see its
|
||||
budgets), so per-scope actions would just be enum bloat. They also skip
|
||||
pagination (unlike the org-wide list) since a single entity realistically
|
||||
has only a handful of `(unit, period)` budgets — response shape is a flat
|
||||
`{ budgets: AiBudget[] }` (`ListAiBudgetsByScopeResponse`), not
|
||||
`PaginatedResponse`.
|
||||
|
||||
`verifyAiBudgetAccess` (`server/middlewares/verifyAiBudgetAccess.ts`) loads
|
||||
the budget by `budgetId`, resolves its `orgId` directly off the row (no
|
||||
join needed, unlike `verifyAiModelAccess`), and stashes it on
|
||||
`req.aiBudget` so `getAiBudget`/`updateAiBudget` can skip a re-fetch.
|
||||
|
||||
## Request/response shapes
|
||||
|
||||
- Create body: `providerId?`, `modelId?`, `resourceId?`, `siteResourceId?`,
|
||||
`roleId?` (all `number`, mutually exclusive), `amount` (positive
|
||||
`number`, required), `unit` (required), `period` (default `"monthly"`),
|
||||
`enforcement` (default `"hard"`), `enabled?` (default `true`).
|
||||
- Update body: same fields, all optional; the five scope fields are
|
||||
`nullable().optional()` so a client can explicitly send `null` to clear
|
||||
a scope (turning a scoped budget into an org-wide one).
|
||||
- All five CRUD responses wrap a single `budget: AiBudget` (or
|
||||
`budgets: AiBudget[]` + `pagination` for the org-wide list). No public/
|
||||
private mapper exists for `AiBudget` — unlike `AiProvider`, there's no
|
||||
secret field to strip, so the raw DB row is returned as-is.
|
||||
|
||||
## Not yet migrated
|
||||
|
||||
Schema changes here (composite unique constraints) were made directly in
|
||||
`schema.ts` without hand-writing a `server/migrations/*.sql` file — this
|
||||
repo's CI (`.github/workflows/test.yml`) runs `drizzle-kit generate`
|
||||
against `schema.ts` fresh, and other recent schema-only commits (e.g. "Remove
|
||||
budget periods") follow the same pattern of not committing a matching
|
||||
migration by hand.
|
||||
@@ -0,0 +1,265 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
aiBudgets,
|
||||
aiModels,
|
||||
aiProviders,
|
||||
db,
|
||||
resources,
|
||||
roles,
|
||||
siteResources
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq, 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(),
|
||||
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,
|
||||
amount,
|
||||
unit,
|
||||
period,
|
||||
enforcement,
|
||||
enabled
|
||||
} = parsedBody.data;
|
||||
|
||||
if (providerId !== undefined) {
|
||||
const [provider] = await db
|
||||
.select({ orgId: aiProviders.orgId })
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.limit(1);
|
||||
if (!provider || provider.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found in this organization`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (modelId !== undefined) {
|
||||
const [model] = await db
|
||||
.select({ orgId: aiProviders.orgId })
|
||||
.from(aiModels)
|
||||
.innerJoin(
|
||||
aiProviders,
|
||||
eq(aiModels.providerId, aiProviders.providerId)
|
||||
)
|
||||
.where(eq(aiModels.modelId, modelId))
|
||||
.limit(1);
|
||||
if (!model || model.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI model with ID ${modelId} not found in this organization`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceId !== undefined) {
|
||||
const [resource] = await db
|
||||
.select({ orgId: resources.orgId })
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
if (!resource || resource.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found in this organization`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (siteResourceId !== undefined) {
|
||||
const [siteResource] = await db
|
||||
.select({ orgId: siteResources.orgId })
|
||||
.from(siteResources)
|
||||
.where(eq(siteResources.siteResourceId, siteResourceId))
|
||||
.limit(1);
|
||||
if (!siteResource || siteResource.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site resource with ID ${siteResourceId} not found in this organization`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (roleId !== undefined) {
|
||||
const [role] = await db
|
||||
.select({ orgId: roles.orgId })
|
||||
.from(roles)
|
||||
.where(eq(roles.roleId, roleId))
|
||||
.limit(1);
|
||||
if (!role || role.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Role with ID ${roleId} not found in this organization`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const 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)
|
||||
: and(
|
||||
eq(aiBudgets.orgId, orgId),
|
||||
isNull(aiBudgets.providerId),
|
||||
isNull(aiBudgets.modelId),
|
||||
isNull(aiBudgets.resourceId),
|
||||
isNull(aiBudgets.siteResourceId),
|
||||
isNull(aiBudgets.roleId)
|
||||
);
|
||||
|
||||
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,
|
||||
amount,
|
||||
unit,
|
||||
period,
|
||||
enforcement,
|
||||
enabled: enabled ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return response<CreateOrEditAiBudgetResponse>(res, {
|
||||
data: { budget },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI budget created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiBudgets, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
budgetId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/ai-budget/{budgetId}",
|
||||
description: "Delete an AI budget.",
|
||||
tags: [OpenAPITags.AiBudget],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function deleteAiBudget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { budgetId } = parsedParams.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ budgetId: aiBudgets.budgetId })
|
||||
.from(aiBudgets)
|
||||
.where(eq(aiBudgets.budgetId, budgetId))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI budget with ID ${budgetId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.delete(aiBudgets).where(eq(aiBudgets.budgetId, budgetId));
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI budget deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiBudgets, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { GetAiBudgetResponse } from "@server/routers/aiBudget/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
budgetId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/ai-budget/{budgetId}",
|
||||
description: "Get an AI budget by ID.",
|
||||
tags: [OpenAPITags.AiBudget],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function getAiBudget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { budgetId } = parsedParams.data;
|
||||
|
||||
const [budget] =
|
||||
req.aiBudget && req.aiBudget.budgetId === budgetId
|
||||
? [req.aiBudget]
|
||||
: await db
|
||||
.select()
|
||||
.from(aiBudgets)
|
||||
.where(eq(aiBudgets.budgetId, budgetId))
|
||||
.limit(1);
|
||||
|
||||
if (!budget) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI budget with ID ${budgetId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetAiBudgetResponse>(res, {
|
||||
data: { budget },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI budget retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from "./createAiBudget";
|
||||
export * from "./listAiBudgets";
|
||||
export * from "./listAiBudgetsForProvider";
|
||||
export * from "./listAiBudgetsForModel";
|
||||
export * from "./listAiBudgetsForResource";
|
||||
export * from "./listAiBudgetsForSiteResource";
|
||||
export * from "./listAiBudgetsForRole";
|
||||
export * from "./getAiBudget";
|
||||
export * from "./updateAiBudget";
|
||||
export * from "./deleteAiBudget";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiBudgets, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { ListAiBudgetsResponse } from "@server/routers/aiBudget/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const listSchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
})
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/ai-budgets",
|
||||
description: "List AI budgets for an organization.",
|
||||
tags: [OpenAPITags.AiBudget],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
query: listSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function listAiBudgets(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { pageSize, page } = parsedQuery.data;
|
||||
|
||||
const baseQuery = db
|
||||
.select()
|
||||
.from(aiBudgets)
|
||||
.where(eq(aiBudgets.orgId, orgId));
|
||||
|
||||
const countQuery = db.$count(
|
||||
db
|
||||
.select()
|
||||
.from(aiBudgets)
|
||||
.where(eq(aiBudgets.orgId, orgId))
|
||||
.as("filtered_ai_budgets")
|
||||
);
|
||||
|
||||
const [totalCount, rows] = await Promise.all([
|
||||
countQuery,
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(asc(aiBudgets.budgetId))
|
||||
]);
|
||||
|
||||
return response<ListAiBudgetsResponse>(res, {
|
||||
data: {
|
||||
budgets: rows,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI budgets retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiBudgets, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
modelId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/ai-model/{modelId}/ai-budgets",
|
||||
description: "List AI budgets scoped to an AI model.",
|
||||
tags: [OpenAPITags.AiBudget],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function listAiBudgetsForModel(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { modelId } = parsedParams.data;
|
||||
|
||||
const budgets = await db
|
||||
.select()
|
||||
.from(aiBudgets)
|
||||
.where(eq(aiBudgets.modelId, modelId))
|
||||
.orderBy(asc(aiBudgets.budgetId));
|
||||
|
||||
return response<ListAiBudgetsByScopeResponse>(res, {
|
||||
data: { budgets },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI budgets retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiBudgets, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/ai-provider/{providerId}/ai-budgets",
|
||||
description: "List AI budgets scoped to an AI provider.",
|
||||
tags: [OpenAPITags.AiBudget],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function listAiBudgetsForProvider(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { providerId } = parsedParams.data;
|
||||
|
||||
const budgets = await db
|
||||
.select()
|
||||
.from(aiBudgets)
|
||||
.where(eq(aiBudgets.providerId, providerId))
|
||||
.orderBy(asc(aiBudgets.budgetId));
|
||||
|
||||
return response<ListAiBudgetsByScopeResponse>(res, {
|
||||
data: { budgets },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI budgets retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiBudgets, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
resourceId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/resource/{resourceId}/ai-budgets",
|
||||
description: "List AI budgets scoped to a resource.",
|
||||
tags: [OpenAPITags.AiBudget],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function listAiBudgetsForResource(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
|
||||
const budgets = await db
|
||||
.select()
|
||||
.from(aiBudgets)
|
||||
.where(eq(aiBudgets.resourceId, resourceId))
|
||||
.orderBy(asc(aiBudgets.budgetId));
|
||||
|
||||
return response<ListAiBudgetsByScopeResponse>(res, {
|
||||
data: { budgets },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI budgets retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiBudgets, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
roleId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/role/{roleId}/ai-budgets",
|
||||
description: "List AI budgets scoped to a role.",
|
||||
tags: [OpenAPITags.AiBudget],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function listAiBudgetsForRole(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { roleId } = parsedParams.data;
|
||||
|
||||
const budgets = await db
|
||||
.select()
|
||||
.from(aiBudgets)
|
||||
.where(eq(aiBudgets.roleId, roleId))
|
||||
.orderBy(asc(aiBudgets.budgetId));
|
||||
|
||||
return response<ListAiBudgetsByScopeResponse>(res, {
|
||||
data: { budgets },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI budgets retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiBudgets, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
siteResourceId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/site-resource/{siteResourceId}/ai-budgets",
|
||||
description: "List AI budgets scoped to a site resource.",
|
||||
tags: [OpenAPITags.AiBudget],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function listAiBudgetsForSiteResource(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteResourceId } = parsedParams.data;
|
||||
|
||||
const budgets = await db
|
||||
.select()
|
||||
.from(aiBudgets)
|
||||
.where(eq(aiBudgets.siteResourceId, siteResourceId))
|
||||
.orderBy(asc(aiBudgets.budgetId));
|
||||
|
||||
return response<ListAiBudgetsByScopeResponse>(res, {
|
||||
data: { budgets },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI budgets retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AiBudget } from "@server/db";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
|
||||
export type ListAiBudgetsResponse = PaginatedResponse<{
|
||||
budgets: AiBudget[];
|
||||
}>;
|
||||
|
||||
export type ListAiBudgetsByScopeResponse = {
|
||||
budgets: AiBudget[];
|
||||
};
|
||||
|
||||
export type GetAiBudgetResponse = {
|
||||
budget: AiBudget;
|
||||
};
|
||||
|
||||
export type CreateOrEditAiBudgetResponse = {
|
||||
budget: AiBudget;
|
||||
};
|
||||
@@ -0,0 +1,343 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
aiBudgets,
|
||||
aiModels,
|
||||
aiProviders,
|
||||
db,
|
||||
resources,
|
||||
roles,
|
||||
siteResources
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq, 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(),
|
||||
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 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()
|
||||
})
|
||||
.superRefine((data, ctx) => refineBudgetScopeFields(data, ctx))
|
||||
.safeParse({
|
||||
providerId: nextProviderId,
|
||||
modelId: nextModelId,
|
||||
resourceId: nextResourceId,
|
||||
siteResourceId: nextSiteResourceId,
|
||||
roleId: nextRoleId
|
||||
});
|
||||
|
||||
if (!scopeValidation.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(scopeValidation.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (body.providerId !== undefined && body.providerId !== null) {
|
||||
const [provider] = await db
|
||||
.select({ orgId: aiProviders.orgId })
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, body.providerId))
|
||||
.limit(1);
|
||||
if (!provider || provider.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${body.providerId} not found in this organization`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (body.modelId !== undefined && body.modelId !== null) {
|
||||
const [model] = await db
|
||||
.select({ orgId: aiProviders.orgId })
|
||||
.from(aiModels)
|
||||
.innerJoin(
|
||||
aiProviders,
|
||||
eq(aiModels.providerId, aiProviders.providerId)
|
||||
)
|
||||
.where(eq(aiModels.modelId, body.modelId))
|
||||
.limit(1);
|
||||
if (!model || model.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI model with ID ${body.modelId} not found in this organization`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (body.resourceId !== undefined && body.resourceId !== null) {
|
||||
const [resource] = await db
|
||||
.select({ orgId: resources.orgId })
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, body.resourceId))
|
||||
.limit(1);
|
||||
if (!resource || resource.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${body.resourceId} not found in this organization`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
body.siteResourceId !== undefined &&
|
||||
body.siteResourceId !== null
|
||||
) {
|
||||
const [siteResource] = await db
|
||||
.select({ orgId: siteResources.orgId })
|
||||
.from(siteResources)
|
||||
.where(eq(siteResources.siteResourceId, body.siteResourceId))
|
||||
.limit(1);
|
||||
if (!siteResource || siteResource.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site resource with ID ${body.siteResourceId} not found in this organization`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (body.roleId !== undefined && body.roleId !== null) {
|
||||
const [role] = await db
|
||||
.select({ orgId: roles.orgId })
|
||||
.from(roles)
|
||||
.where(eq(roles.roleId, body.roleId))
|
||||
.limit(1);
|
||||
if (!role || role.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Role with ID ${body.roleId} not found in this organization`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const scopeCondition =
|
||||
nextProviderId !== null
|
||||
? eq(aiBudgets.providerId, nextProviderId)
|
||||
: nextModelId !== null
|
||||
? eq(aiBudgets.modelId, nextModelId)
|
||||
: nextResourceId !== null
|
||||
? eq(aiBudgets.resourceId, nextResourceId)
|
||||
: nextSiteResourceId !== null
|
||||
? eq(aiBudgets.siteResourceId, nextSiteResourceId)
|
||||
: nextRoleId !== null
|
||||
? eq(aiBudgets.roleId, nextRoleId)
|
||||
: and(
|
||||
eq(aiBudgets.orgId, orgId),
|
||||
isNull(aiBudgets.providerId),
|
||||
isNull(aiBudgets.modelId),
|
||||
isNull(aiBudgets.resourceId),
|
||||
isNull(aiBudgets.siteResourceId),
|
||||
isNull(aiBudgets.roleId)
|
||||
);
|
||||
|
||||
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.amount !== undefined) {
|
||||
updateData.amount = body.amount;
|
||||
}
|
||||
if (body.unit !== undefined) {
|
||||
updateData.unit = body.unit;
|
||||
}
|
||||
if (body.period !== undefined) {
|
||||
updateData.period = body.period;
|
||||
}
|
||||
if (body.enforcement !== undefined) {
|
||||
updateData.enforcement = body.enforcement;
|
||||
}
|
||||
if (body.enabled !== undefined) {
|
||||
updateData.enabled = body.enabled;
|
||||
}
|
||||
|
||||
const [budget] = await db
|
||||
.update(aiBudgets)
|
||||
.set(updateData)
|
||||
.where(eq(aiBudgets.budgetId, budgetId))
|
||||
.returning();
|
||||
|
||||
return response<CreateOrEditAiBudgetResponse>(res, {
|
||||
data: { budget },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI budget updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const aiBudgetUnitSchema = z.enum(["usd", "tokens"]);
|
||||
|
||||
export const aiBudgetPeriodSchema = z.enum([
|
||||
"monthly",
|
||||
"yearly",
|
||||
"lifetime",
|
||||
"daily",
|
||||
"hourly",
|
||||
"weekly"
|
||||
]);
|
||||
|
||||
export const aiBudgetEnforcementSchema = z.enum(["hard", "soft"]);
|
||||
|
||||
export function refineBudgetScopeFields(
|
||||
data: {
|
||||
providerId?: number | null;
|
||||
modelId?: number | null;
|
||||
resourceId?: number | null;
|
||||
siteResourceId?: number | null;
|
||||
roleId?: number | null;
|
||||
},
|
||||
ctx: z.RefinementCtx
|
||||
) {
|
||||
const scopeFields = [
|
||||
data.providerId,
|
||||
data.modelId,
|
||||
data.resourceId,
|
||||
data.siteResourceId,
|
||||
data.roleId
|
||||
];
|
||||
|
||||
const setCount = scopeFields.filter(
|
||||
(value) => value !== null && value !== undefined
|
||||
).length;
|
||||
|
||||
if (setCount > 1) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Only one of providerId, modelId, resourceId, siteResourceId, or roleId may be set on a budget",
|
||||
path: ["providerId"]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Router } from "express";
|
||||
import {
|
||||
AI_CAPABILITY_DEFS,
|
||||
type AiCapability
|
||||
} from "@server/lib/aiCapabilities";
|
||||
import { handleAiGatewayProxy } from "@server/routers/aiGateway/pipeline";
|
||||
|
||||
export function createAiGatewayRouter() {
|
||||
const router = Router();
|
||||
|
||||
for (const def of Object.values(AI_CAPABILITY_DEFS)) {
|
||||
const capability = def.id as AiCapability;
|
||||
for (const route of def.routes) {
|
||||
router.post(route.path, (req, res) =>
|
||||
handleAiGatewayProxy(req, res, capability)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { handleAiGatewayProxy } from "./pipeline";
|
||||
export { createAiGatewayRouter } from "./createAiGatewayRouter";
|
||||
@@ -0,0 +1,271 @@
|
||||
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;
|
||||
}): 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),
|
||||
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 });
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { Request, Response } from "express";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
AiBudget,
|
||||
AiProvider,
|
||||
aiModels,
|
||||
aiProviders,
|
||||
clients,
|
||||
db,
|
||||
exitNodes,
|
||||
resourceAiModels,
|
||||
resourceAiProviders,
|
||||
resources,
|
||||
siteResourceAiModels,
|
||||
siteResourceAiProviders,
|
||||
siteResources,
|
||||
users
|
||||
} from "@server/db";
|
||||
import config from "@server/lib/config";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import {
|
||||
AiProviderAuthType,
|
||||
AiProviderType,
|
||||
applyAiProviderAuthHeaders,
|
||||
applyAiProviderCustomHeaders,
|
||||
authTypeRequiresApiKey
|
||||
} from "@server/lib/aiProviderDefaults";
|
||||
import {
|
||||
AI_CAPABILITY_DEFS,
|
||||
providerHasCapability,
|
||||
type AiCapability
|
||||
} from "@server/lib/aiCapabilities";
|
||||
import { proxyAiGatewayToSiteTarget } from "@server/routers/aiGateway/targetRouting";
|
||||
import {
|
||||
SESSION_COOKIE_NAME,
|
||||
validateSessionToken
|
||||
} from "@server/auth/sessions/app";
|
||||
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
|
||||
import { isIpInCidr } from "@server/lib/ip";
|
||||
import { localCache } from "@server/lib/cache";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
resolveEffectiveLists,
|
||||
type AccessMode,
|
||||
type ModelListType
|
||||
} from "@server/lib/aiInferenceResource";
|
||||
import {
|
||||
compareModelKeySpecificity,
|
||||
isAllowedByLists,
|
||||
mostSpecificMatchingAllow
|
||||
} from "@server/lib/aiModelKeyMatch";
|
||||
import {
|
||||
catalogOwnershipScore,
|
||||
keepBestScored,
|
||||
providerClassRank
|
||||
} from "@server/lib/aiProviderSelection";
|
||||
import { aiGatewayUpstreamFetch } from "@server/lib/aiGatewayUpstreamFetch";
|
||||
import { getModelPricing, calculateAiCost } from "@server/lib/aiModelPricing";
|
||||
import {
|
||||
applyUsageToBudgetCache,
|
||||
checkBudgets,
|
||||
recordUsage
|
||||
} from "@server/lib/aiBudgetEnforcement";
|
||||
import {
|
||||
extractUsage,
|
||||
estimateUsage,
|
||||
isUsageEmpty,
|
||||
needsStreamUsageInjection,
|
||||
withStreamUsageOption,
|
||||
extractResponseModel,
|
||||
type AiUsage
|
||||
} from "@server/lib/aiUsageExtraction";
|
||||
import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse";
|
||||
import { logAiSession } from "@server/routers/aiGateway/logAiSession";
|
||||
|
||||
const EXIT_NODE_RANGES_CACHE_KEY = "aiGateway:exitNodeRanges";
|
||||
const EXIT_NODE_RANGES_TTL_SEC = 6000;
|
||||
const CLIENT_BY_IP_TTL_SEC = 30;
|
||||
const REQUEST_USER_TTL_SEC = 30;
|
||||
|
||||
type CachedClient = { clientId: number; userId: string | null } | null;
|
||||
|
||||
async function getExitNodeRanges(): Promise<string[]> {
|
||||
const cached = localCache.get<string[]>(EXIT_NODE_RANGES_CACHE_KEY);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ address: exitNodes.address })
|
||||
.from(exitNodes);
|
||||
const ranges = rows.map((r) => r.address);
|
||||
|
||||
localCache.set(
|
||||
EXIT_NODE_RANGES_CACHE_KEY,
|
||||
ranges,
|
||||
EXIT_NODE_RANGES_TTL_SEC
|
||||
);
|
||||
return ranges;
|
||||
}
|
||||
|
||||
async function findClientByIp(ip: string): Promise<CachedClient> {
|
||||
const cacheKey = `aiGateway:clientByIp:${ip}`;
|
||||
const cached = localCache.get<CachedClient>(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const [client] = await db
|
||||
.select({ clientId: clients.clientId, userId: clients.userId })
|
||||
.from(clients)
|
||||
.where(eq(clients.exitNodeSubnet, `${ip}/32`))
|
||||
.limit(1);
|
||||
|
||||
const result: CachedClient = client || null;
|
||||
localCache.set(cacheKey, result, CLIENT_BY_IP_TTL_SEC);
|
||||
return result;
|
||||
}
|
||||
|
||||
type ProviderAttachment = {
|
||||
provider: AiProvider;
|
||||
accessMode: AccessMode;
|
||||
};
|
||||
|
||||
type ResourceModelPattern = {
|
||||
providerId: number;
|
||||
modelKey: string;
|
||||
listType: ModelListType;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type ProviderPatternLists = {
|
||||
allows: string[];
|
||||
blocks: string[];
|
||||
};
|
||||
|
||||
type ResolvedTarget = {
|
||||
resourceId: number | null;
|
||||
siteResourceId: number | null;
|
||||
orgId: string | null;
|
||||
attachments: ProviderAttachment[];
|
||||
resourceListsByProvider: Map<number, ProviderPatternLists>;
|
||||
};
|
||||
|
||||
type ProviderSelection =
|
||||
| { ok: true; provider: AiProvider }
|
||||
| { ok: false; status: number; message: string };
|
||||
|
||||
export type RequestUser = {
|
||||
userId: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
role: string | null;
|
||||
roleIds: number[];
|
||||
};
|
||||
|
||||
// Identity headers forwarded to the upstream inference endpoint when the
|
||||
// requesting user is known. Omitted entirely (not sent empty) when we
|
||||
// couldn't resolve a user for the request.
|
||||
export function applyRequestUserHeaders(
|
||||
headers: Record<string, string>,
|
||||
requestUser: RequestUser | null
|
||||
): void {
|
||||
if (!requestUser) {
|
||||
return;
|
||||
}
|
||||
headers["Remote-User"] = requestUser.username;
|
||||
if (requestUser.email) {
|
||||
headers["Remote-Email"] = requestUser.email;
|
||||
}
|
||||
if (requestUser.name) {
|
||||
headers["Remote-Name"] = requestUser.name;
|
||||
}
|
||||
if (requestUser.role) {
|
||||
headers["Remote-Role"] = requestUser.role;
|
||||
}
|
||||
}
|
||||
|
||||
async function buildRequestUser(
|
||||
userId: string,
|
||||
orgId: string | null
|
||||
): Promise<RequestUser | null> {
|
||||
const cacheKey = `aiGateway:requestUser:${userId}:${orgId || ""}`;
|
||||
const cached = localCache.get<RequestUser | null>(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
localCache.set(cacheKey, null, REQUEST_USER_TTL_SEC);
|
||||
return null;
|
||||
}
|
||||
|
||||
const orgRoles = orgId ? await getUserOrgRoles(user.userId, orgId) : [];
|
||||
|
||||
const requestUser: RequestUser = {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: orgRoles.map((r) => r.roleName).join(", ") || null,
|
||||
roleIds: orgRoles.map((r) => r.roleId)
|
||||
};
|
||||
|
||||
localCache.set(cacheKey, requestUser, REQUEST_USER_TTL_SEC);
|
||||
return requestUser;
|
||||
}
|
||||
|
||||
async function resolveRequestUser(
|
||||
req: Request,
|
||||
_resourceId: number | null,
|
||||
orgId: string | null
|
||||
): Promise<RequestUser | null> {
|
||||
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME];
|
||||
if (sessionToken) {
|
||||
const { session, user } = await validateSessionToken(sessionToken);
|
||||
if (session && user) {
|
||||
return buildRequestUser(user.userId, orgId);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: MAKE SURE THIS CAN NOT BE SPOOFED AND CAN BE TRUSTED AS AN INTERNAL ADDRESS FROM A NODE
|
||||
|
||||
const ip = req.ip;
|
||||
if (!ip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const exitNodeRanges = await getExitNodeRanges();
|
||||
const inExitNodeRange = exitNodeRanges.some((range) =>
|
||||
isIpInCidr(ip, range)
|
||||
);
|
||||
if (!inExitNodeRange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const client = await findClientByIp(ip);
|
||||
if (!client || !client.userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildRequestUser(client.userId, orgId);
|
||||
}
|
||||
|
||||
async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
||||
const [[resourceRow], [siteResourceRow]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
orgId: resources.orgId
|
||||
})
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.fullDomain, host),
|
||||
eq(resources.mode, "inference"),
|
||||
eq(resources.enabled, true)
|
||||
)
|
||||
)
|
||||
.limit(1),
|
||||
db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
orgId: siteResources.orgId
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.fullDomain, host),
|
||||
eq(siteResources.mode, "inference"),
|
||||
eq(siteResources.enabled, true)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
]);
|
||||
|
||||
// Prefer public inference resources when both match the same host.
|
||||
if (resourceRow) {
|
||||
const [attachmentRows, resourcePatterns] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
provider: aiProviders,
|
||||
accessMode: resourceAiProviders.accessMode
|
||||
})
|
||||
.from(resourceAiProviders)
|
||||
.innerJoin(
|
||||
aiProviders,
|
||||
eq(resourceAiProviders.providerId, aiProviders.providerId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
resourceAiProviders.resourceId,
|
||||
resourceRow.resourceId
|
||||
),
|
||||
eq(aiProviders.enabled, true),
|
||||
eq(resourceAiProviders.enabled, true)
|
||||
)
|
||||
),
|
||||
db
|
||||
.select({
|
||||
providerId: aiModels.providerId,
|
||||
modelKey: aiModels.modelKey,
|
||||
listType: resourceAiModels.listType,
|
||||
enabled: aiModels.enabled
|
||||
})
|
||||
.from(resourceAiModels)
|
||||
.innerJoin(
|
||||
aiModels,
|
||||
eq(resourceAiModels.modelId, aiModels.modelId)
|
||||
)
|
||||
.where(eq(resourceAiModels.resourceId, resourceRow.resourceId))
|
||||
]);
|
||||
|
||||
if (attachmentRows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
resourceId: resourceRow.resourceId,
|
||||
siteResourceId: null,
|
||||
orgId: resourceRow.orgId,
|
||||
attachments: attachmentRows.map((a) => ({
|
||||
provider: a.provider,
|
||||
accessMode: a.accessMode
|
||||
})),
|
||||
resourceListsByProvider: groupPatternsByProvider(resourcePatterns)
|
||||
};
|
||||
}
|
||||
|
||||
if (siteResourceRow) {
|
||||
const [attachmentRows, resourcePatterns] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
provider: aiProviders,
|
||||
accessMode: siteResourceAiProviders.accessMode
|
||||
})
|
||||
.from(siteResourceAiProviders)
|
||||
.innerJoin(
|
||||
aiProviders,
|
||||
eq(
|
||||
siteResourceAiProviders.providerId,
|
||||
aiProviders.providerId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
siteResourceAiProviders.siteResourceId,
|
||||
siteResourceRow.siteResourceId
|
||||
),
|
||||
eq(aiProviders.enabled, true),
|
||||
eq(siteResourceAiProviders.enabled, true)
|
||||
)
|
||||
),
|
||||
db
|
||||
.select({
|
||||
providerId: aiModels.providerId,
|
||||
modelKey: aiModels.modelKey,
|
||||
listType: siteResourceAiModels.listType,
|
||||
enabled: aiModels.enabled
|
||||
})
|
||||
.from(siteResourceAiModels)
|
||||
.innerJoin(
|
||||
aiModels,
|
||||
eq(siteResourceAiModels.modelId, aiModels.modelId)
|
||||
)
|
||||
.where(
|
||||
eq(
|
||||
siteResourceAiModels.siteResourceId,
|
||||
siteResourceRow.siteResourceId
|
||||
)
|
||||
)
|
||||
]);
|
||||
|
||||
if (attachmentRows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
resourceId: null,
|
||||
siteResourceId: siteResourceRow.siteResourceId,
|
||||
orgId: siteResourceRow.orgId,
|
||||
attachments: attachmentRows.map((a) => ({
|
||||
provider: a.provider,
|
||||
accessMode: a.accessMode
|
||||
})),
|
||||
resourceListsByProvider: groupPatternsByProvider(resourcePatterns)
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function groupPatternsByProvider(
|
||||
patterns: ResourceModelPattern[]
|
||||
): Map<number, ProviderPatternLists> {
|
||||
const byProvider = new Map<number, ProviderPatternLists>();
|
||||
for (const pattern of patterns) {
|
||||
if (!pattern.enabled) {
|
||||
continue;
|
||||
}
|
||||
let lists = byProvider.get(pattern.providerId);
|
||||
if (!lists) {
|
||||
lists = { allows: [], blocks: [] };
|
||||
byProvider.set(pattern.providerId, lists);
|
||||
}
|
||||
if (pattern.listType === "allow") {
|
||||
lists.allows.push(pattern.modelKey);
|
||||
} else {
|
||||
lists.blocks.push(pattern.modelKey);
|
||||
}
|
||||
}
|
||||
return byProvider;
|
||||
}
|
||||
|
||||
async function selectProvider(
|
||||
attachments: ProviderAttachment[],
|
||||
resourceListsByProvider: Map<number, ProviderPatternLists>,
|
||||
requestedModel: string | undefined
|
||||
): Promise<ProviderSelection> {
|
||||
if (!requestedModel) {
|
||||
return {
|
||||
ok: false,
|
||||
status: HttpCode.FORBIDDEN,
|
||||
message: "A model must be specified for this resource"
|
||||
};
|
||||
}
|
||||
|
||||
const attachmentByProviderId = new Map(
|
||||
attachments.map((a) => [a.provider.providerId, a])
|
||||
);
|
||||
const providerIds = [...attachmentByProviderId.keys()];
|
||||
if (providerIds.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: HttpCode.FORBIDDEN,
|
||||
message: `Model "${requestedModel}" is not permitted on this resource`
|
||||
};
|
||||
}
|
||||
|
||||
const providerModels = await db
|
||||
.select({
|
||||
providerId: aiModels.providerId,
|
||||
modelKey: aiModels.modelKey,
|
||||
listType: aiModels.listType,
|
||||
enabled: aiModels.enabled
|
||||
})
|
||||
.from(aiModels)
|
||||
.where(inArray(aiModels.providerId, providerIds));
|
||||
|
||||
const allowsByProvider = new Map<number, string[]>();
|
||||
const blocksByProvider = new Map<number, string[]>();
|
||||
for (const model of providerModels) {
|
||||
if (!model.enabled) {
|
||||
continue;
|
||||
}
|
||||
const targetMap =
|
||||
model.listType === "allow" ? allowsByProvider : blocksByProvider;
|
||||
const existing = targetMap.get(model.providerId) ?? [];
|
||||
existing.push(model.modelKey);
|
||||
targetMap.set(model.providerId, existing);
|
||||
}
|
||||
|
||||
type ModelCandidate = {
|
||||
provider: AiProvider;
|
||||
modelKey: string;
|
||||
};
|
||||
|
||||
const candidates: ModelCandidate[] = [];
|
||||
for (const [providerId, attachment] of attachmentByProviderId) {
|
||||
const resourceLists = resourceListsByProvider.get(providerId);
|
||||
const { allows, blocks } = resolveEffectiveLists({
|
||||
accessMode: attachment.accessMode,
|
||||
providerAllows: allowsByProvider.get(providerId) ?? [],
|
||||
providerBlocks: blocksByProvider.get(providerId) ?? [],
|
||||
resourceAllows: resourceLists?.allows ?? [],
|
||||
resourceBlocks: resourceLists?.blocks ?? []
|
||||
});
|
||||
|
||||
if (!isAllowedByLists(requestedModel, allows, blocks)) {
|
||||
continue;
|
||||
}
|
||||
const matchingAllow = mostSpecificMatchingAllow(requestedModel, allows);
|
||||
if (!matchingAllow) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
provider: attachment.provider,
|
||||
modelKey: matchingAllow
|
||||
});
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: HttpCode.FORBIDDEN,
|
||||
message: `Model "${requestedModel}" is not permitted on this resource`
|
||||
};
|
||||
}
|
||||
|
||||
// 1) Prefer the most specific allow pattern that matched the request.
|
||||
candidates.sort((a, b) =>
|
||||
compareModelKeySpecificity(a.modelKey, b.modelKey)
|
||||
);
|
||||
const bestSpecificity = candidates[0].modelKey;
|
||||
let remaining = candidates.filter(
|
||||
(c) => compareModelKeySpecificity(c.modelKey, bestSpecificity) === 0
|
||||
);
|
||||
|
||||
// 2) Prefer providers whose catalog owns this model id. Aggregators only
|
||||
// score when the model is known somewhere in the catalog.
|
||||
remaining = keepBestScored(remaining, (c) =>
|
||||
catalogOwnershipScore(c.provider.type as AiProviderType, requestedModel)
|
||||
);
|
||||
|
||||
// 3) Prefer native typed providers over aggregators over custom.
|
||||
remaining = keepBestScored(remaining, (c) =>
|
||||
providerClassRank(c.provider.type as AiProviderType)
|
||||
);
|
||||
|
||||
const uniqueProviders = new Map<number, AiProvider>();
|
||||
for (const candidate of remaining) {
|
||||
uniqueProviders.set(candidate.provider.providerId, candidate.provider);
|
||||
}
|
||||
|
||||
if (uniqueProviders.size === 1) {
|
||||
return { ok: true, provider: [...uniqueProviders.values()][0] };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: HttpCode.FORBIDDEN,
|
||||
message: `Model "${requestedModel}" is ambiguous across multiple AI providers on this resource. Ask your administrator to configure a more specific allow pattern for this model.`
|
||||
};
|
||||
}
|
||||
|
||||
// Extracts usage/cost from a completed AI gateway request, records it for
|
||||
// budget enforcement, and logs the aggregated prompt/response for session
|
||||
// replay. Shared by both the direct-upstream path (below) and the
|
||||
// "custom"/target routing-mode path (targetRouting.ts) so both get identical
|
||||
// usage/cost tracking and session logging instead of only the direct-upstream
|
||||
// path having it.
|
||||
export function recordAiGatewayCompletion(args: {
|
||||
capability: AiCapability;
|
||||
provider: AiProvider;
|
||||
requestedModel: string | undefined;
|
||||
requestBody: unknown;
|
||||
responseText: string;
|
||||
isStream: boolean;
|
||||
statusCode: number;
|
||||
headers: Headers;
|
||||
orgId: string | null;
|
||||
resourceId: number | null;
|
||||
siteResourceId: number | null;
|
||||
requestUserId: string | null;
|
||||
budgets: AiBudget[];
|
||||
}): void {
|
||||
const {
|
||||
capability,
|
||||
provider,
|
||||
requestedModel,
|
||||
requestBody,
|
||||
responseText,
|
||||
isStream,
|
||||
statusCode,
|
||||
headers,
|
||||
orgId,
|
||||
resourceId,
|
||||
siteResourceId,
|
||||
requestUserId,
|
||||
budgets
|
||||
} = args;
|
||||
|
||||
let usage: AiUsage | null = extractUsage(
|
||||
capability,
|
||||
responseText,
|
||||
isStream,
|
||||
headers
|
||||
);
|
||||
if (!usage || isUsageEmpty(usage)) {
|
||||
usage = estimateUsage(JSON.stringify(requestBody ?? ""), responseText);
|
||||
}
|
||||
|
||||
const model = extractResponseModel(responseText) ?? requestedModel;
|
||||
const pricing = getModelPricing(provider.type as AiProviderType, model);
|
||||
const cost = calculateAiCost(pricing, usage);
|
||||
|
||||
// Shared by the usage record and the session log so the two can be
|
||||
// joined later to show token/cost usage alongside the transcript -
|
||||
// generated up front since neither buffered insert's row id is known
|
||||
// until its next batch flush.
|
||||
const sessionId = randomUUID();
|
||||
|
||||
logger.info("AI gateway request usage", {
|
||||
capability,
|
||||
providerId: provider.providerId,
|
||||
providerType: provider.type,
|
||||
model,
|
||||
estimated: usage.estimated,
|
||||
promptTokens: usage.promptTokens,
|
||||
cacheReadTokens: usage.cacheReadTokens,
|
||||
cacheWriteTokens: usage.cacheWriteTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
reasoningTokens: usage.reasoningTokens,
|
||||
pricingApproximate: pricing?.approximate ?? null,
|
||||
totalCostUsd: cost?.totalCost ?? null
|
||||
});
|
||||
|
||||
if (orgId) {
|
||||
void recordUsage({
|
||||
orgId,
|
||||
providerId: provider.providerId,
|
||||
resourceId,
|
||||
siteResourceId,
|
||||
userId: requestUserId,
|
||||
requestedModel: model ?? "unknown",
|
||||
usage,
|
||||
costUsd: cost?.totalCost ?? null,
|
||||
sessionId
|
||||
});
|
||||
|
||||
if (budgets.length > 0) {
|
||||
void applyUsageToBudgetCache(budgets, {
|
||||
usd: cost?.totalCost ?? 0,
|
||||
tokens:
|
||||
usage.promptTokens +
|
||||
usage.cacheReadTokens +
|
||||
usage.cacheWriteTokens +
|
||||
usage.completionTokens +
|
||||
usage.reasoningTokens
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logAiSession({
|
||||
sessionId,
|
||||
capability,
|
||||
provider,
|
||||
requestedModel,
|
||||
requestBody,
|
||||
responseText,
|
||||
isStream,
|
||||
statusCode,
|
||||
orgId,
|
||||
resourceId,
|
||||
siteResourceId,
|
||||
requestUserId
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAiGatewayProxy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
capability: AiCapability
|
||||
): Promise<any> {
|
||||
try {
|
||||
const def = AI_CAPABILITY_DEFS[capability];
|
||||
|
||||
const host = (
|
||||
(req.headers["p-host"] as string | undefined) || // p-host is only used sometimes when overriding the host header for some middleware proxy
|
||||
req.headers.host ||
|
||||
""
|
||||
).split(":")[0];
|
||||
if (!host) {
|
||||
return res
|
||||
.status(HttpCode.BAD_REQUEST)
|
||||
.json({ error: { message: "Missing Host header" } });
|
||||
}
|
||||
|
||||
logger.info(`AI gateway ${capability} request for host: ${host}`);
|
||||
|
||||
const target = await resolveTarget(host);
|
||||
if (!target) {
|
||||
return res.status(HttpCode.NOT_FOUND).json({
|
||||
error: {
|
||||
message: "No inference resource found for this host"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
attachments,
|
||||
resourceListsByProvider,
|
||||
resourceId,
|
||||
siteResourceId,
|
||||
orgId
|
||||
} = target;
|
||||
|
||||
const capableAttachments = attachments.filter((a) =>
|
||||
providerHasCapability(a.provider.capabilities, capability)
|
||||
);
|
||||
|
||||
if (capableAttachments.length === 0) {
|
||||
return res.status(HttpCode.FORBIDDEN).json({
|
||||
error: {
|
||||
message: `No AI provider on this resource supports ${capability}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const requestedModel = def.extractModel(req);
|
||||
|
||||
const [requestUser, selection] = await Promise.all([
|
||||
resolveRequestUser(req, resourceId, orgId),
|
||||
selectProvider(
|
||||
capableAttachments,
|
||||
resourceListsByProvider,
|
||||
requestedModel
|
||||
)
|
||||
]);
|
||||
|
||||
if (requestUser) {
|
||||
logger.debug(
|
||||
`AI gateway request from user ${requestUser.userId} (${requestUser.username})`
|
||||
);
|
||||
}
|
||||
|
||||
if (!selection.ok) {
|
||||
return res.status(selection.status).json({
|
||||
error: { message: selection.message }
|
||||
});
|
||||
}
|
||||
|
||||
const { provider } = selection;
|
||||
|
||||
let appliedBudgets: AiBudget[] = [];
|
||||
if (orgId) {
|
||||
const budgetCheck = await checkBudgets({
|
||||
orgId,
|
||||
providerId: provider.providerId,
|
||||
requestedModel: requestedModel!,
|
||||
resourceId,
|
||||
siteResourceId,
|
||||
roleIds: requestUser?.roleIds ?? [],
|
||||
requestUserId: requestUser?.userId ?? null
|
||||
});
|
||||
appliedBudgets = budgetCheck.budgets;
|
||||
|
||||
if (budgetCheck.blocked) {
|
||||
logger.warn("AI gateway request blocked by budget", {
|
||||
budgetId: budgetCheck.blockingBudget?.budgetId,
|
||||
orgId,
|
||||
providerId: provider.providerId,
|
||||
requestedModel,
|
||||
resourceId,
|
||||
siteResourceId,
|
||||
userId: requestUser?.userId ?? null
|
||||
});
|
||||
return res.status(HttpCode.TOO_MANY_REQUESTS).json({
|
||||
error: {
|
||||
message: "AI usage budget exceeded for this request"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.type === "custom" && provider.routingMode === "target") {
|
||||
return await proxyAiGatewayToSiteTarget(
|
||||
req,
|
||||
res,
|
||||
provider,
|
||||
requestUser,
|
||||
capability,
|
||||
{
|
||||
orgId,
|
||||
resourceId,
|
||||
siteResourceId,
|
||||
requestedModel,
|
||||
budgets: appliedBudgets
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const upstreamUrl = provider.upstreamUrl;
|
||||
const authType = provider.authType as AiProviderAuthType;
|
||||
|
||||
if (!upstreamUrl) {
|
||||
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||
error: {
|
||||
message: "AI provider has no upstream URL configured"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let apiKey: string | null = null;
|
||||
if (authTypeRequiresApiKey(authType)) {
|
||||
if (!provider.apiKey) {
|
||||
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||
error: {
|
||||
message: "AI provider has no API key configured"
|
||||
}
|
||||
});
|
||||
}
|
||||
const secret = config.getRawConfig().server.secret!;
|
||||
apiKey = decrypt(provider.apiKey, secret);
|
||||
}
|
||||
|
||||
const targetUrl = def.resolveUpstreamUrl(
|
||||
upstreamUrl,
|
||||
req,
|
||||
requestedModel!
|
||||
);
|
||||
|
||||
const skipHeaders = new Set([
|
||||
"p-host",
|
||||
"host",
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"content-length",
|
||||
"accept-encoding"
|
||||
]);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (skipHeaders.has(key.toLowerCase()) || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
||||
}
|
||||
applyAiProviderCustomHeaders(
|
||||
headers,
|
||||
provider.headers,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
applyAiProviderAuthHeaders(headers, authType, apiKey);
|
||||
applyRequestUserHeaders(headers, requestUser);
|
||||
|
||||
// OpenAI's Chat Completions API only reports usage in a streaming
|
||||
// response when asked to via stream_options.include_usage - inject
|
||||
// it ourselves when the caller didn't, so we can still track cost,
|
||||
// and strip the extra frame it adds back out of what we forward.
|
||||
const injectedUsageOurselves = needsStreamUsageInjection(
|
||||
capability,
|
||||
req.body
|
||||
);
|
||||
const outboundBody = injectedUsageOurselves
|
||||
? withStreamUsageOption(req.body)
|
||||
: req.body;
|
||||
const body = JSON.stringify(outboundBody);
|
||||
|
||||
logger.debug("AI gateway upstream request", {
|
||||
capability,
|
||||
url: targetUrl,
|
||||
method: "POST",
|
||||
headers,
|
||||
body: outboundBody,
|
||||
skipTlsVerification: provider.skipTlsVerification
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
const onClientClose = () => {
|
||||
if (!res.writableEnded) {
|
||||
abortController.abort();
|
||||
}
|
||||
};
|
||||
res.on("close", onClientClose);
|
||||
|
||||
let upstreamRes: globalThis.Response;
|
||||
try {
|
||||
upstreamRes = await aiGatewayUpstreamFetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
skipTlsVerification: provider.skipTlsVerification,
|
||||
signal: abortController.signal
|
||||
});
|
||||
} catch (fetchError) {
|
||||
res.off("close", onClientClose);
|
||||
if (abortController.signal.aborted) {
|
||||
// Client already disconnected; nothing left to respond to.
|
||||
return;
|
||||
}
|
||||
logger.error({
|
||||
message: "AI gateway upstream fetch failed",
|
||||
url: targetUrl,
|
||||
error: fetchError,
|
||||
cause:
|
||||
fetchError instanceof Error
|
||||
? (fetchError as Error & { cause?: unknown }).cause
|
||||
: undefined
|
||||
});
|
||||
throw fetchError;
|
||||
}
|
||||
|
||||
const isStream = def.isStreaming(
|
||||
req,
|
||||
upstreamRes.headers.get("content-type") || ""
|
||||
);
|
||||
|
||||
const { fullText, aborted } = await streamAiGatewayResponse({
|
||||
res,
|
||||
upstreamRes,
|
||||
isStream,
|
||||
injectedUsageOurselves,
|
||||
abortController,
|
||||
onClientClose
|
||||
});
|
||||
|
||||
if (!aborted) {
|
||||
recordAiGatewayCompletion({
|
||||
capability,
|
||||
provider,
|
||||
requestedModel,
|
||||
requestBody: outboundBody,
|
||||
responseText: fullText,
|
||||
isStream,
|
||||
statusCode: upstreamRes.status,
|
||||
headers: upstreamRes.headers,
|
||||
orgId,
|
||||
resourceId,
|
||||
siteResourceId,
|
||||
requestUserId: requestUser?.userId ?? null,
|
||||
budgets: appliedBudgets
|
||||
});
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||
error: { message: "Failed to proxy inference request" }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Response } from "express";
|
||||
import { stripInjectedUsageFrame } from "@server/lib/aiUsageExtraction";
|
||||
|
||||
/**
|
||||
* Reads an upstream AI provider response, writes it through to the client
|
||||
* (streaming or buffered), and returns the full response text once done, so
|
||||
* the caller can extract usage/cost and log the completed session. Shared by
|
||||
* both the direct-upstream path (pipeline.ts) and the "custom"/target
|
||||
* routing-mode path (targetRouting.ts) so usage/cost tracking and session
|
||||
* logging apply identically to both instead of each maintaining its own copy
|
||||
* of this loop.
|
||||
*
|
||||
* Callers own fetching the upstream response and the AbortController/
|
||||
* `res.on("close", onClientClose)` wiring, since those differ meaningfully
|
||||
* between the two transports (direct upstream fetch with TLS-skip support vs
|
||||
* a plain fetch to gerbil) - only the "read the stream, write to the client,
|
||||
* accumulate the full text" part is actually identical logic between them.
|
||||
*/
|
||||
export async function streamAiGatewayResponse(args: {
|
||||
res: Response;
|
||||
upstreamRes: globalThis.Response;
|
||||
isStream: boolean;
|
||||
// True when we injected stream_options.include_usage ourselves (the
|
||||
// caller didn't ask for it) and need to strip the extra usage-only frame
|
||||
// back out of what's forwarded to the client.
|
||||
injectedUsageOurselves: boolean;
|
||||
abortController: AbortController;
|
||||
onClientClose: () => void;
|
||||
}): Promise<{ fullText: string; aborted: boolean }> {
|
||||
const {
|
||||
res,
|
||||
upstreamRes,
|
||||
isStream,
|
||||
injectedUsageOurselves,
|
||||
abortController,
|
||||
onClientClose
|
||||
} = args;
|
||||
|
||||
const contentType = upstreamRes.headers.get("content-type") || "";
|
||||
res.status(upstreamRes.status);
|
||||
res.setHeader("Content-Type", contentType || "application/json");
|
||||
|
||||
if (isStream && upstreamRes.body) {
|
||||
res.flushHeaders();
|
||||
const reader = upstreamRes.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullText = "";
|
||||
// Frame-boundary buffer, only used when we need to filter the
|
||||
// usage-only frame we injected out of what reaches the client.
|
||||
let sseCarry = "";
|
||||
try {
|
||||
while (!abortController.signal.aborted) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunkText = decoder.decode(value, { stream: true });
|
||||
fullText += chunkText;
|
||||
if (injectedUsageOurselves) {
|
||||
sseCarry += chunkText;
|
||||
const lastBoundary = sseCarry.lastIndexOf("\n\n");
|
||||
if (lastBoundary !== -1) {
|
||||
const toEmit = sseCarry.slice(0, lastBoundary + 2);
|
||||
sseCarry = sseCarry.slice(lastBoundary + 2);
|
||||
res.write(stripInjectedUsageFrame(toEmit));
|
||||
}
|
||||
} else {
|
||||
res.write(value);
|
||||
}
|
||||
}
|
||||
if (injectedUsageOurselves && sseCarry) {
|
||||
res.write(stripInjectedUsageFrame(sseCarry));
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
res.off("close", onClientClose);
|
||||
}
|
||||
if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
return { fullText, aborted: abortController.signal.aborted };
|
||||
}
|
||||
|
||||
res.off("close", onClientClose);
|
||||
const text = await upstreamRes.text();
|
||||
res.send(text);
|
||||
return { fullText: text, aborted: abortController.signal.aborted };
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
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 {
|
||||
needsStreamUsageInjection,
|
||||
withStreamUsageOption
|
||||
} from "@server/lib/aiUsageExtraction";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
applyRequestUserHeaders,
|
||||
recordAiGatewayCompletion,
|
||||
type RequestUser
|
||||
} from "@server/routers/aiGateway/pipeline";
|
||||
import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse";
|
||||
|
||||
// Short TTL: long enough to spare the DB on a burst of requests, short
|
||||
// enough that target/site changes (added, removed, exit node moved) show up
|
||||
// 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"
|
||||
]);
|
||||
|
||||
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[];
|
||||
}
|
||||
): Promise<void> {
|
||||
const providerTargets = await getProviderTargets(provider.providerId);
|
||||
if (providerTargets.length === 0) {
|
||||
res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||
error: {
|
||||
message: "AI provider has no reachable site targets configured"
|
||||
}
|
||||
});
|
||||
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({
|
||||
error: {
|
||||
message: "AI provider has no API key configured"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const secret = config.getRawConfig().server.secret!;
|
||||
apiKey = decrypt(provider.apiKey, secret);
|
||||
}
|
||||
applyAiProviderCustomHeaders(
|
||||
headers,
|
||||
provider.headers,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
applyAiProviderAuthHeaders(headers, authType, apiKey);
|
||||
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({
|
||||
error: { message: "Failed to reach AI provider target" }
|
||||
});
|
||||
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,
|
||||
budgets: ctx.budgets
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiModels, aiProviders, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
|
||||
import { modelListTypeSchema } from "@server/lib/aiInferenceResource";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
modelKey: z.string().nonempty(),
|
||||
name: z.string().nonempty(),
|
||||
enabled: z.boolean().optional(),
|
||||
listType: modelListTypeSchema.optional().default("allow")
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/ai-provider/{providerId}/model",
|
||||
description: "Create an AI model under a provider.",
|
||||
tags: [OpenAPITags.AiModel],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function createAiModel(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { providerId } = parsedParams.data;
|
||||
const { modelKey, name, enabled, listType } = parsedBody.data;
|
||||
|
||||
const [provider] =
|
||||
req.aiProvider && req.aiProvider.providerId === providerId
|
||||
? [req.aiProvider]
|
||||
: await db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select({ modelId: aiModels.modelId })
|
||||
.from(aiModels)
|
||||
.where(
|
||||
and(
|
||||
eq(aiModels.providerId, providerId),
|
||||
eq(aiModels.modelKey, modelKey)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
`Model with key ${modelKey} already exists for this provider`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const [model] = await db
|
||||
.insert(aiModels)
|
||||
.values({
|
||||
providerId,
|
||||
modelKey,
|
||||
name,
|
||||
listType,
|
||||
enabled: enabled ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return response<CreateOrEditAiModelResponse>(res, {
|
||||
data: { model },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI model created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from "./createAiProvider";
|
||||
export * from "./listAiProviders";
|
||||
export * from "./getAiProvider";
|
||||
export * from "./updateAiProvider";
|
||||
export * from "./deleteAiProvider";
|
||||
export * from "./createAiModel";
|
||||
export * from "./listAiModels";
|
||||
export * from "./listCatalogModels";
|
||||
export * from "./getAiModel";
|
||||
export * from "./updateAiModel";
|
||||
export * from "./deleteAiModel";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiModels, aiProviders, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, asc, eq, like, sql } from "drizzle-orm";
|
||||
import type { ListAiModelsResponse } from "@server/routers/aiProvider/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const listSchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/ai-provider/{providerId}/models",
|
||||
description: "List AI models for a provider.",
|
||||
tags: [OpenAPITags.AiModel],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
query: listSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function listAiModels(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { providerId } = parsedParams.data;
|
||||
|
||||
const [provider] =
|
||||
req.aiProvider && req.aiProvider.providerId === providerId
|
||||
? [req.aiProvider]
|
||||
: await db
|
||||
.select({ providerId: aiProviders.providerId })
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { pageSize, page, query } = parsedQuery.data;
|
||||
const conditions = [eq(aiModels.providerId, providerId)];
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
like(
|
||||
sql`LOWER(${aiModels.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = db
|
||||
.select()
|
||||
.from(aiModels)
|
||||
.where(and(...conditions));
|
||||
|
||||
const countQuery = db.$count(
|
||||
db
|
||||
.select()
|
||||
.from(aiModels)
|
||||
.where(and(...conditions))
|
||||
.as("filtered_ai_models")
|
||||
);
|
||||
|
||||
const [totalCount, rows] = await Promise.all([
|
||||
countQuery,
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(asc(aiModels.name))
|
||||
]);
|
||||
|
||||
return response<ListAiModelsResponse>(res, {
|
||||
data: {
|
||||
models: rows,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI models retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiProviders, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, asc, eq, like, sql } from "drizzle-orm";
|
||||
import type { ListAiProvidersResponse } from "@server/routers/aiProvider/types";
|
||||
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const listSchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/ai-providers",
|
||||
description: "List AI providers for an organization.",
|
||||
tags: [OpenAPITags.AiProvider],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
query: listSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function listAiProviders(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { pageSize, page, query } = parsedQuery.data;
|
||||
const conditions = [eq(aiProviders.orgId, orgId)];
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
like(
|
||||
sql`LOWER(${aiProviders.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(and(...conditions));
|
||||
|
||||
const countQuery = db.$count(
|
||||
db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(and(...conditions))
|
||||
.as("filtered_ai_providers")
|
||||
);
|
||||
|
||||
const [totalCount, rows] = await Promise.all([
|
||||
countQuery,
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(asc(aiProviders.name))
|
||||
]);
|
||||
|
||||
return response<ListAiProvidersResponse>(res, {
|
||||
data: {
|
||||
providers: rows.map((row) => toPublicAiProvider(row)),
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI providers retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiProviders, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
aiModelCatalog,
|
||||
getCatalogProviderForType
|
||||
} from "@server/lib/aiModelCatalog";
|
||||
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
|
||||
import type { ListCatalogModelsResponse } from "@server/routers/aiProvider/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const listSchema = z.object({
|
||||
query: z.string().optional()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/ai-provider/{providerId}/catalog-models",
|
||||
description:
|
||||
"List known catalog models for an AI provider's type. Used for model key suggestions.",
|
||||
tags: [OpenAPITags.AiModel],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
query: listSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function listCatalogModels(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { providerId } = parsedParams.data;
|
||||
|
||||
const [provider] =
|
||||
req.aiProvider && req.aiProvider.providerId === providerId
|
||||
? [req.aiProvider]
|
||||
: await db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const catalogProvider = getCatalogProviderForType(
|
||||
provider.type as AiProviderType
|
||||
);
|
||||
|
||||
let models = catalogProvider
|
||||
? aiModelCatalog.list(catalogProvider).map((entry) => ({
|
||||
model: entry.model
|
||||
}))
|
||||
: [];
|
||||
|
||||
const { query } = parsedQuery.data;
|
||||
if (query) {
|
||||
const q = query.toLowerCase();
|
||||
models = models.filter((m) => m.model.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
// Deduplicate model keys (catalog may have duplicates after provider
|
||||
// normalization, e.g. bedrock + bedrock_converse).
|
||||
const seen = new Set<string>();
|
||||
models = models.filter((m) => {
|
||||
if (seen.has(m.model)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(m.model);
|
||||
return true;
|
||||
});
|
||||
|
||||
models.sort((a, b) => a.model.localeCompare(b.model));
|
||||
|
||||
return response<ListCatalogModelsResponse>(res, {
|
||||
data: { models },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Catalog models retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user