mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-20 03:02:58 +02:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0caa17ef9 | |||
| 71348f45b2 | |||
| 21eb4d2876 | |||
| 18270381c1 | |||
| 8e938a2723 | |||
| 197f8f7ba5 | |||
| 10b528642d | |||
| 60d6fff085 | |||
| c664b3da91 | |||
| 492282e758 | |||
| 47f4aefc25 | |||
| 7c0ff9ede7 | |||
| 44e81ea979 | |||
| 56dc10330a | |||
| eb8ad6a181 | |||
| 4edd2e4d32 | |||
| 813c3abe54 | |||
| 048e4fc73c | |||
| 923371e5b4 | |||
| 81430ba3d3 | |||
| e4aaadc9f9 | |||
| d04740fede | |||
| 4677a0d501 | |||
| b4463f0e1a | |||
| b7c0669c38 | |||
| 152d2fb1d6 | |||
| d374b4f66e | |||
| 192542629f |
@@ -77,7 +77,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -407,7 +407,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Login to GitHub Container Registry (for cosign)
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
days-before-stale: 14
|
||||
days-before-close: 14
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:24.18.1-alpine
|
||||
FROM node:26.7.0-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+1
-3
@@ -1785,6 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Anthropic's agentic coding tool for the terminal.",
|
||||
"aiClientConfigDescriptionCodex": "OpenAI's agentic coding tool for the terminal.",
|
||||
"aiClientConfigDescriptionOpencode": "Open source terminal coding agent.",
|
||||
"aiClientConfigDescriptionCursor": "AI code editor built on VS Code.",
|
||||
"aiClientConfigSetup": "Setup",
|
||||
"aiClientConfigTabCli": "Automatic (CLI)",
|
||||
"aiClientConfigTabManual": "Manual Configuration",
|
||||
@@ -3547,9 +3548,6 @@
|
||||
"sidebarLogsAction": "Admin Action Logs",
|
||||
"logRetention": "Log Retention",
|
||||
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them",
|
||||
"logRetentionDisabledWarningTitle": "Log Retention Disabled",
|
||||
"logRetentionDisabledWarningDescription": "{logType} are not being retained for this organization, so new activity will not appear here. Enable retention in security settings to start collecting these logs.",
|
||||
"logRetentionDisabledWarningButton": "Go to Security Settings",
|
||||
"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",
|
||||
|
||||
Generated
+324
-281
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -94,12 +94,12 @@
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.11.0",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "4.3.0",
|
||||
"js-yaml": "4.3.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "1.17.0",
|
||||
"maxmind": "5.0.6",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.2.11",
|
||||
"next": "16.3.1",
|
||||
"next-intl": "4.13.0",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
@@ -139,7 +139,7 @@
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.69.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/ui": "^6.5.0",
|
||||
"@react-email/ui": "^6.9.2",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@tanstack/react-query-devtools": "5.100.14",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
@@ -170,7 +170,7 @@
|
||||
"esbuild-node-externals": "1.22.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"postcss": "8.5.15",
|
||||
"postcss": "8.5.23",
|
||||
"prettier": "3.8.3",
|
||||
"react-email": "6.5.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
@@ -182,6 +182,6 @@
|
||||
"overrides": {
|
||||
"esbuild": "0.28.0",
|
||||
"dompurify": "3.4.0",
|
||||
"postcss": "8.5.15"
|
||||
"postcss": "8.5.23"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09">
|
||||
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #26251e;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 793 B |
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09">
|
||||
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #edecec;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 793 B |
@@ -21,9 +21,7 @@ export function createAiGatewayServer() {
|
||||
|
||||
aiGatewayServer.use(helmet());
|
||||
aiGatewayServer.use(cors());
|
||||
// AI requests can carry large payloads (long conversation history, tool
|
||||
// results, embedded documents), well beyond express.json()'s 100kb default.
|
||||
aiGatewayServer.use(express.json({ limit: "50mb" }));
|
||||
aiGatewayServer.use(express.json());
|
||||
|
||||
aiGatewayServer.use(createAiGatewayRouter());
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export const orgs = pgTable("orgs", {
|
||||
"settingsLogRetentionDaysAISessions"
|
||||
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0),
|
||||
.default(7),
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||
isBillingOrg: boolean("isBillingOrg"),
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
aiModels,
|
||||
aiUsageRecords,
|
||||
db,
|
||||
logsDb,
|
||||
userOrgRoles
|
||||
} from "@server/db";
|
||||
import { modelKeyMatches } from "@server/lib/aiModelKeyMatch";
|
||||
@@ -169,7 +168,7 @@ async function sumUsageAmount(
|
||||
): Promise<number> {
|
||||
const column =
|
||||
unit === "usd" ? aiUsageRecords.costUsd : aiUsageRecords.totalTokens;
|
||||
const [row] = await logsDb
|
||||
const [row] = await db
|
||||
.select({ total: sql<number>`coalesce(sum(${column}), 0)` })
|
||||
.from(aiUsageRecords)
|
||||
.where(where);
|
||||
@@ -201,7 +200,7 @@ export async function sumUsageForBudget(
|
||||
if (!model) {
|
||||
return 0;
|
||||
}
|
||||
const rows = await logsDb
|
||||
const rows = await db
|
||||
.select({
|
||||
requestedModel: aiUsageRecords.requestedModel,
|
||||
costUsd: aiUsageRecords.costUsd,
|
||||
@@ -497,7 +496,7 @@ async function flushUsageRecords() {
|
||||
|
||||
try {
|
||||
// Use a transaction to ensure all inserts succeed or fail together
|
||||
await logsDb.transaction(async (tx) => {
|
||||
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) {
|
||||
|
||||
@@ -17,7 +17,7 @@ export type AiUsage = {
|
||||
estimated: boolean;
|
||||
};
|
||||
|
||||
export function emptyUsage(): AiUsage {
|
||||
function emptyUsage(): AiUsage {
|
||||
return {
|
||||
promptTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
|
||||
@@ -9,7 +9,6 @@ export enum TierFeature {
|
||||
AccessLogs = "accessLogs", // set the retention period to none on downgrade
|
||||
ActionLogs = "actionLogs", // set the retention period to none on downgrade
|
||||
ConnectionLogs = "connectionLogs",
|
||||
AISessionLogs = "aiSessionLogs",
|
||||
RotateCredentials = "rotateCredentials",
|
||||
MaintenancePage = "maintenancePage", // handle downgrade
|
||||
DevicePosture = "devicePosture",
|
||||
@@ -38,7 +37,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.AccessLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.AISessionLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.MaintenancePage]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
|
||||
|
||||
@@ -443,11 +443,7 @@ export const configSchema = z
|
||||
disable_config_managed_domains: z.boolean().optional(),
|
||||
disable_product_help_banners: z.boolean().optional(),
|
||||
disable_enterprise_features: z.boolean().optional(),
|
||||
enable_acme_cert_sync: z.boolean().optional().default(true),
|
||||
disable_private_http_placeholder: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
enable_acme_cert_sync: z.boolean().optional().default(true)
|
||||
})
|
||||
.optional(),
|
||||
acme: z
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db, userOrgRoles, users } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import type {
|
||||
|
||||
@@ -48,7 +48,7 @@ export class PrivateConfig {
|
||||
|
||||
this.rawPrivateConfig = parsedPrivateConfig;
|
||||
|
||||
this.migrateDeprecatedConfig(privateEnvironment);
|
||||
this.migrateDeprecatedAcmeConfig(privateEnvironment);
|
||||
|
||||
process.env.BRANDING_HIDE_AUTH_LAYOUT_FOOTER =
|
||||
this.rawPrivateConfig.branding?.hide_auth_layout_footer === true
|
||||
@@ -152,12 +152,12 @@ export class PrivateConfig {
|
||||
return this.rawPrivateConfig;
|
||||
}
|
||||
|
||||
// `flags.enable_acme_cert_sync`, `flags.disable_private_http_placeholder`,
|
||||
// and `acme` used to live in the private config file. They now live in
|
||||
// the public config file. If an operator still has them set in the
|
||||
// private config and hasn't moved them over to the public config, pull
|
||||
// them forward so behavior doesn't silently change out from under them.
|
||||
private migrateDeprecatedConfig(privateEnvironment: any) {
|
||||
// `flags.enable_acme_cert_sync` and `acme` used to live in the private
|
||||
// config file. They now live in the public config file. If an operator
|
||||
// still has them set in the private config and hasn't moved them over to
|
||||
// the public config, pull them forward so behavior doesn't silently
|
||||
// change out from under them.
|
||||
private migrateDeprecatedAcmeConfig(privateEnvironment: any) {
|
||||
const publicEnvironment: any = readPublicConfigFile();
|
||||
const rawConfig: any = config.getRawConfig();
|
||||
|
||||
@@ -182,20 +182,6 @@ export class PrivateConfig {
|
||||
);
|
||||
rawConfig.acme = this.rawPrivateConfig.acme;
|
||||
}
|
||||
|
||||
if (
|
||||
privateEnvironment?.flags?.disable_private_http_placeholder !==
|
||||
undefined &&
|
||||
publicEnvironment?.flags?.disable_private_http_placeholder ===
|
||||
undefined
|
||||
) {
|
||||
logger.warn(
|
||||
"`flags.disable_private_http_placeholder` is deprecated in the private config file and has moved to the public config file. Using the value from the private config file for now, but please move it to the public config."
|
||||
);
|
||||
rawConfig.flags = rawConfig.flags ?? {};
|
||||
rawConfig.flags.disable_private_http_placeholder =
|
||||
this.rawPrivateConfig.flags.disable_private_http_placeholder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,13 +115,10 @@ export const privateConfigSchema = z
|
||||
// any value set here is migrated into the public config at
|
||||
// startup by PrivateConfig (server/private/lib/config.ts).
|
||||
enable_acme_cert_sync: z.boolean().optional(),
|
||||
// @deprecated Moved to the public config file as
|
||||
// `flags.disable_private_http_placeholder`
|
||||
// (server/lib/readConfigFile.ts). Kept here only so existing
|
||||
// private config files keep parsing; any value set here is
|
||||
// migrated into the public config at startup by PrivateConfig
|
||||
// (server/private/lib/config.ts).
|
||||
disable_private_http_placeholder: z.boolean().optional()
|
||||
disable_private_http_placeholder: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
|
||||
@@ -329,7 +329,8 @@ export async function getTraefikConfig(
|
||||
}[] = [];
|
||||
if (
|
||||
build == "enterprise" &&
|
||||
!config.getRawConfig().flags?.disable_private_http_placeholder
|
||||
!privateConfig.getRawPrivateConfig().flags
|
||||
.disable_private_http_placeholder
|
||||
) {
|
||||
// we dont want to do this on the cloud
|
||||
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { logsDb, db, orgs, aiSessionLog, type AiProvider } from "@server/db";
|
||||
import type { InferInsertModel } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq, lt } from "drizzle-orm";
|
||||
import cache from "#dynamic/lib/cache";
|
||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||
import { sanitizeString } from "@server/lib/sanitize";
|
||||
import type { AiCapability } from "@server/lib/aiCapabilities";
|
||||
import {
|
||||
normalizeAiRequest,
|
||||
normalizeAiResponse
|
||||
} from "@server/lib/aiMessageNormalization";
|
||||
|
||||
// Caps how much of the request/response body we keep per row, so a single
|
||||
// huge multimodal payload can't blow up buffer memory or storage.
|
||||
const AI_SESSION_LOG_MAX_BODY_CHARS = 200_000;
|
||||
|
||||
type AiSessionLogInsert = InferInsertModel<typeof aiSessionLog>;
|
||||
|
||||
// In-memory buffer for batching AI session log inserts, mirroring the
|
||||
// approach in server/routers/badger/logRequestAudit.ts.
|
||||
const sessionLogBuffer: AiSessionLogInsert[] = [];
|
||||
|
||||
const BATCH_SIZE = 100; // Write to DB every 100 logs
|
||||
const BATCH_INTERVAL_MS = 5000; // Or every 5 seconds, whichever comes first
|
||||
const MAX_BUFFER_SIZE = 10000; // Prevent unbounded memory growth
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
let isFlushInProgress = false;
|
||||
|
||||
/**
|
||||
* Flush buffered logs to database
|
||||
*/
|
||||
async function flushSessionLogs() {
|
||||
if (sessionLogBuffer.length === 0 || isFlushInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
isFlushInProgress = true;
|
||||
|
||||
// Take all current logs and clear buffer
|
||||
const logsToWrite = sessionLogBuffer.splice(0, sessionLogBuffer.length);
|
||||
|
||||
try {
|
||||
// Use a transaction to ensure all inserts succeed or fail together
|
||||
await logsDb.transaction(async (tx) => {
|
||||
// Batch insert logs in groups of 25 to avoid overwhelming the database
|
||||
const BATCH_DB_SIZE = 25;
|
||||
for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) {
|
||||
const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE);
|
||||
await tx.insert(aiSessionLog).values(batch);
|
||||
}
|
||||
});
|
||||
logger.debug(
|
||||
`Flushed ${logsToWrite.length} AI session logs to database`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error flushing AI session logs:", error);
|
||||
// On transaction error, put logs back at the front of the buffer to retry
|
||||
// but only if buffer isn't too large
|
||||
if (sessionLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) {
|
||||
sessionLogBuffer.unshift(...logsToWrite);
|
||||
logger.info(
|
||||
`Re-queued ${logsToWrite.length} AI session logs for retry`
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
`Buffer full, dropped ${logsToWrite.length} AI session logs`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isFlushInProgress = false;
|
||||
// If buffer filled up while we were flushing, flush again
|
||||
if (sessionLogBuffer.length >= BATCH_SIZE) {
|
||||
flushSessionLogs().catch((err) =>
|
||||
logger.error("Error in follow-up AI session log flush:", err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a flush if not already scheduled
|
||||
*/
|
||||
function scheduleFlush() {
|
||||
if (flushTimer === null) {
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushSessionLogs().catch((err) =>
|
||||
logger.error("Error in scheduled AI session log flush:", err)
|
||||
);
|
||||
}, BATCH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully flush all pending logs (call this on shutdown)
|
||||
*/
|
||||
export async function shutdownAiSessionLogger() {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
// Force flush even if one is in progress by waiting and retrying
|
||||
while (isFlushInProgress) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
await flushSessionLogs();
|
||||
}
|
||||
|
||||
async function getRetentionDays(orgId: string): Promise<number> {
|
||||
// check cache first
|
||||
const cached = await cache.get<number>(`org_${orgId}_aiSessionsDays`);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select({
|
||||
settingsLogRetentionDaysAISessions:
|
||||
orgs.settingsLogRetentionDaysAISessions
|
||||
})
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// store the result in cache
|
||||
await cache.set(
|
||||
`org_${orgId}_aiSessionsDays`,
|
||||
org.settingsLogRetentionDaysAISessions,
|
||||
300
|
||||
);
|
||||
|
||||
return org.settingsLogRetentionDaysAISessions;
|
||||
}
|
||||
|
||||
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
|
||||
// calculateCutoffTimestamp returns a seconds-epoch cutoff (built for
|
||||
// requestAuditLog.timestamp), but aiSessionLog.createdAt is ms-epoch to
|
||||
// match aiUsageRecords - convert before comparing.
|
||||
const cutoffTimestampMs = calculateCutoffTimestamp(retentionDays) * 1000;
|
||||
|
||||
try {
|
||||
await logsDb
|
||||
.delete(aiSessionLog)
|
||||
.where(
|
||||
and(
|
||||
lt(aiSessionLog.createdAt, cutoffTimestampMs),
|
||||
eq(aiSessionLog.orgId, orgId)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error cleaning up old AI session logs:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function truncateBody(value: string): { value: string; truncated: boolean } {
|
||||
if (value.length <= AI_SESSION_LOG_MAX_BODY_CHARS) {
|
||||
return { value, truncated: false };
|
||||
}
|
||||
return {
|
||||
value: value.slice(0, AI_SESSION_LOG_MAX_BODY_CHARS),
|
||||
truncated: true
|
||||
};
|
||||
}
|
||||
|
||||
export function logAiSession(data: {
|
||||
sessionId: string;
|
||||
capability: AiCapability;
|
||||
provider: AiProvider;
|
||||
requestedModel: string | undefined;
|
||||
requestBody: unknown;
|
||||
responseText: string;
|
||||
isStream: boolean;
|
||||
statusCode: number;
|
||||
orgId: string | null;
|
||||
resourceId: number | null;
|
||||
siteResourceId: number | null;
|
||||
requestUserId: string | null;
|
||||
virtualApiKeyId: string | null;
|
||||
}): void {
|
||||
(async () => {
|
||||
try {
|
||||
// Check retention before buffering any logs
|
||||
if (data.orgId) {
|
||||
const retentionDays = await getRetentionDays(data.orgId);
|
||||
if (retentionDays === 0) {
|
||||
// do not log
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No org resolved for this request - nothing to govern
|
||||
// retention with, so don't log it.
|
||||
return;
|
||||
}
|
||||
|
||||
const requestBodyText = truncateBody(
|
||||
JSON.stringify(data.requestBody ?? "")
|
||||
);
|
||||
const responseBodyText = truncateBody(data.responseText ?? "");
|
||||
|
||||
// Uniform, capability-agnostic transcript for search/display -
|
||||
// computed from the untruncated originals so normalization sees
|
||||
// the full content; the normalized result gets its own
|
||||
// (typically much smaller) truncation pass below.
|
||||
const normalizedRequestMessages = normalizeAiRequest(
|
||||
data.capability,
|
||||
data.requestBody
|
||||
);
|
||||
const normalizedResponseMessages = normalizeAiResponse(
|
||||
data.capability,
|
||||
data.responseText ?? "",
|
||||
data.isStream
|
||||
);
|
||||
const normalizedRequestText = normalizedRequestMessages
|
||||
? truncateBody(JSON.stringify(normalizedRequestMessages))
|
||||
: null;
|
||||
const normalizedResponseText = normalizedResponseMessages
|
||||
? truncateBody(JSON.stringify(normalizedResponseMessages))
|
||||
: null;
|
||||
|
||||
// Prevent unbounded buffer growth - drop oldest entries if buffer is too large
|
||||
if (sessionLogBuffer.length >= MAX_BUFFER_SIZE) {
|
||||
const dropped = sessionLogBuffer.splice(0, BATCH_SIZE);
|
||||
logger.warn(
|
||||
`AI session log buffer exceeded max size (${MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries`
|
||||
);
|
||||
}
|
||||
|
||||
sessionLogBuffer.push({
|
||||
sessionId: data.sessionId,
|
||||
orgId: sanitizeString(data.orgId),
|
||||
providerId: data.provider.providerId,
|
||||
capability: data.capability,
|
||||
resourceId: data.resourceId ?? undefined,
|
||||
siteResourceId: data.siteResourceId ?? undefined,
|
||||
userId: sanitizeString(data.requestUserId ?? undefined),
|
||||
virtualApiKeyId: sanitizeString(
|
||||
data.virtualApiKeyId ?? undefined
|
||||
),
|
||||
requestedModel: sanitizeString(data.requestedModel),
|
||||
isStream: data.isStream,
|
||||
requestBody: sanitizeString(requestBodyText.value),
|
||||
responseBody: sanitizeString(responseBodyText.value),
|
||||
normalizedRequest: normalizedRequestText
|
||||
? sanitizeString(normalizedRequestText.value)
|
||||
: undefined,
|
||||
normalizedResponse: normalizedResponseText
|
||||
? sanitizeString(normalizedResponseText.value)
|
||||
: undefined,
|
||||
truncated:
|
||||
requestBodyText.truncated ||
|
||||
responseBodyText.truncated ||
|
||||
(normalizedRequestText?.truncated ?? false) ||
|
||||
(normalizedResponseText?.truncated ?? false),
|
||||
statusCode: data.statusCode,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
// Flush immediately if buffer is full, otherwise schedule a flush
|
||||
if (sessionLogBuffer.length >= BATCH_SIZE) {
|
||||
flushSessionLogs().catch((err) =>
|
||||
logger.error("Error flushing AI session logs:", err)
|
||||
);
|
||||
} else {
|
||||
scheduleFlush();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to log AI session", { error });
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -291,10 +291,6 @@ async function disableFeature(
|
||||
await disableConnectionLogs(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.AISessionLogs:
|
||||
await disableAISessionLogs(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.RotateCredentials:
|
||||
await disableRotateCredentials(orgId);
|
||||
break;
|
||||
@@ -497,15 +493,6 @@ async function disableConnectionLogs(orgId: string): Promise<void> {
|
||||
logger.info(`Disabled connection logs for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableAISessionLogs(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set({ settingsLogRetentionDaysAISessions: 0 })
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled AI session logs for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableRotateCredentials(orgId: string): Promise<void> {}
|
||||
|
||||
async function disablemaintenancePage(orgId: string): Promise<void> {
|
||||
|
||||
@@ -21,10 +21,6 @@ import * as auth from "#private/routers/auth";
|
||||
import * as license from "#private/routers/license";
|
||||
import * as generateLicense from "#private/routers/generatedLicense";
|
||||
import * as logs from "#private/routers/auditLogs";
|
||||
import {
|
||||
queryAiSessionLogs,
|
||||
exportAiSessionLogs
|
||||
} from "@server/routers/auditLogs";
|
||||
import * as misc from "#private/routers/misc";
|
||||
import * as reKey from "#private/routers/re-key";
|
||||
import * as approval from "#private/routers/approvals";
|
||||
@@ -595,25 +591,6 @@ authenticated.get(
|
||||
logs.exportConnectionAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.aiSessionLogs),
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.viewLogs),
|
||||
queryAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/export",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.aiSessionLogs),
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
exportAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/re-key/:clientId/regenerate-client-secret",
|
||||
verifyClientAccess, // this is first to set the org id
|
||||
|
||||
@@ -43,10 +43,6 @@ import {
|
||||
unauthenticated as ua,
|
||||
authenticated as a
|
||||
} from "@server/routers/integration";
|
||||
import {
|
||||
queryAiSessionLogs,
|
||||
exportAiSessionLogs
|
||||
} from "@server/routers/auditLogs";
|
||||
import { logActionAudit } from "#private/middlewares";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { build } from "@server/build";
|
||||
@@ -157,25 +153,6 @@ authenticated.get(
|
||||
logs.exportConnectionAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.aiSessionLogs),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.viewLogs),
|
||||
queryAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/export",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.aiSessionLogs),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
exportAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/idp/oidc",
|
||||
verifyValidLicense,
|
||||
|
||||
@@ -1,12 +1,171 @@
|
||||
import { AiCapability } from "@app/lib/aiCapabilities";
|
||||
import { AiProvider } from "@server/db";
|
||||
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() {}
|
||||
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();
|
||||
}
|
||||
|
||||
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {}
|
||||
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;
|
||||
@@ -22,4 +181,95 @@ export function logAiSession(data: {
|
||||
siteResourceId: number | null;
|
||||
requestUserId: string | null;
|
||||
virtualApiKeyId: string | null;
|
||||
}): void {}
|
||||
}): void {
|
||||
(async () => {
|
||||
try {
|
||||
// Check retention before buffering any logs
|
||||
if (data.orgId) {
|
||||
const retentionDays = await getRetentionDays(data.orgId);
|
||||
if (retentionDays === 0) {
|
||||
// do not log
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No org resolved for this request - nothing to govern
|
||||
// retention with, so don't log it.
|
||||
return;
|
||||
}
|
||||
|
||||
const requestBodyText = truncateBody(
|
||||
JSON.stringify(data.requestBody ?? "")
|
||||
);
|
||||
const responseBodyText = truncateBody(data.responseText ?? "");
|
||||
|
||||
// Uniform, capability-agnostic transcript for search/display -
|
||||
// computed from the untruncated originals so normalization sees
|
||||
// the full content; the normalized result gets its own
|
||||
// (typically much smaller) truncation pass below.
|
||||
const normalizedRequestMessages = normalizeAiRequest(
|
||||
data.capability,
|
||||
data.requestBody
|
||||
);
|
||||
const normalizedResponseMessages = normalizeAiResponse(
|
||||
data.capability,
|
||||
data.responseText ?? "",
|
||||
data.isStream
|
||||
);
|
||||
const normalizedRequestText = normalizedRequestMessages
|
||||
? truncateBody(JSON.stringify(normalizedRequestMessages))
|
||||
: null;
|
||||
const normalizedResponseText = normalizedResponseMessages
|
||||
? truncateBody(JSON.stringify(normalizedResponseMessages))
|
||||
: null;
|
||||
|
||||
// Prevent unbounded buffer growth - drop oldest entries if buffer is too large
|
||||
if (sessionLogBuffer.length >= MAX_BUFFER_SIZE) {
|
||||
const dropped = sessionLogBuffer.splice(0, BATCH_SIZE);
|
||||
logger.warn(
|
||||
`AI session log buffer exceeded max size (${MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries`
|
||||
);
|
||||
}
|
||||
|
||||
sessionLogBuffer.push({
|
||||
sessionId: data.sessionId,
|
||||
orgId: sanitizeString(data.orgId),
|
||||
providerId: data.provider.providerId,
|
||||
capability: data.capability,
|
||||
resourceId: data.resourceId ?? undefined,
|
||||
siteResourceId: data.siteResourceId ?? undefined,
|
||||
userId: sanitizeString(data.requestUserId ?? undefined),
|
||||
virtualApiKeyId: sanitizeString(
|
||||
data.virtualApiKeyId ?? undefined
|
||||
),
|
||||
requestedModel: sanitizeString(data.requestedModel),
|
||||
isStream: data.isStream,
|
||||
requestBody: sanitizeString(requestBodyText.value),
|
||||
responseBody: sanitizeString(responseBodyText.value),
|
||||
normalizedRequest: normalizedRequestText
|
||||
? sanitizeString(normalizedRequestText.value)
|
||||
: undefined,
|
||||
normalizedResponse: normalizedResponseText
|
||||
? sanitizeString(normalizedResponseText.value)
|
||||
: undefined,
|
||||
truncated:
|
||||
requestBodyText.truncated ||
|
||||
responseBodyText.truncated ||
|
||||
(normalizedRequestText?.truncated ?? false) ||
|
||||
(normalizedResponseText?.truncated ?? false),
|
||||
statusCode: data.statusCode,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
// Flush immediately if buffer is full, otherwise schedule a flush
|
||||
if (sessionLogBuffer.length >= BATCH_SIZE) {
|
||||
flushSessionLogs().catch((err) =>
|
||||
logger.error("Error flushing AI session logs:", err)
|
||||
);
|
||||
} else {
|
||||
scheduleFlush();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to log AI session", { error });
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -82,11 +82,10 @@ import {
|
||||
needsStreamUsageInjection,
|
||||
withStreamUsageOption,
|
||||
extractResponseModel,
|
||||
emptyUsage,
|
||||
type AiUsage
|
||||
} from "@server/lib/aiUsageExtraction";
|
||||
import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse";
|
||||
import { logAiSession } from "#dynamic/routers/aiGateway/logAiSession";
|
||||
import { logAiSession } from "@server/routers/aiGateway/logAiSession";
|
||||
|
||||
const EXIT_NODE_RANGES_CACHE_KEY = "aiGateway:exitNodeRanges";
|
||||
const EXIT_NODE_RANGES_TTL_SEC = 6000;
|
||||
@@ -714,38 +713,20 @@ export function recordAiGatewayCompletion(args: {
|
||||
budgets
|
||||
} = args;
|
||||
|
||||
// A non-2xx status means the upstream provider rejected the request
|
||||
// (bad auth, invalid request, rate limit, 5xx, etc.) before ever running
|
||||
// the model - no tokens were actually billed, so don't estimate usage
|
||||
// off the error body text or price/charge it. We still record a
|
||||
// zeroed-out row below (rather than skipping it) so request-count
|
||||
// dashboards built on aiUsageRecords keep counting every attempt.
|
||||
const upstreamSucceeded = statusCode >= 200 && statusCode < 300;
|
||||
|
||||
let usage: AiUsage;
|
||||
let model: string | undefined;
|
||||
let pricing: ReturnType<typeof getModelPricing> = null;
|
||||
let cost: ReturnType<typeof calculateAiCost> = null;
|
||||
|
||||
if (upstreamSucceeded) {
|
||||
usage =
|
||||
extractUsage(capability, responseText, isStream, headers) ??
|
||||
emptyUsage();
|
||||
if (isUsageEmpty(usage)) {
|
||||
usage = estimateUsage(
|
||||
JSON.stringify(requestBody ?? ""),
|
||||
responseText
|
||||
);
|
||||
}
|
||||
|
||||
model = extractResponseModel(responseText) ?? requestedModel;
|
||||
pricing = getModelPricing(provider.type as AiProviderType, model);
|
||||
cost = calculateAiCost(pricing, usage);
|
||||
} else {
|
||||
usage = emptyUsage();
|
||||
model = requestedModel;
|
||||
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
|
||||
@@ -757,7 +738,6 @@ export function recordAiGatewayCompletion(args: {
|
||||
providerId: provider.providerId,
|
||||
providerType: provider.type,
|
||||
model,
|
||||
statusCode,
|
||||
estimated: usage.estimated,
|
||||
promptTokens: usage.promptTokens,
|
||||
cacheReadTokens: usage.cacheReadTokens,
|
||||
|
||||
@@ -312,7 +312,7 @@ async function enrichWithDetails(
|
||||
>();
|
||||
const sessionIds = logs.map((log) => log.sessionId);
|
||||
if (sessionIds.length > 0) {
|
||||
const usageDetails = await logsDb
|
||||
const usageDetails = await primaryDb
|
||||
.select({
|
||||
sessionId: aiUsageRecords.sessionId,
|
||||
promptTokens: aiUsageRecords.promptTokens,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
db,
|
||||
logsDb,
|
||||
aiUsageRecords,
|
||||
aiProviders,
|
||||
resources,
|
||||
@@ -79,22 +78,22 @@ async function query(data: Q) {
|
||||
uniqueUsers,
|
||||
uniqueVirtualApiKeys
|
||||
] = await Promise.all([
|
||||
logsDb
|
||||
db
|
||||
.selectDistinct({ id: aiUsageRecords.providerId })
|
||||
.from(aiUsageRecords)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
db
|
||||
.selectDistinct({ model: aiUsageRecords.requestedModel })
|
||||
.from(aiUsageRecords)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
db
|
||||
.selectDistinct({ id: aiUsageRecords.resourceId })
|
||||
.from(aiUsageRecords)
|
||||
.where(and(baseConditions, not(isNull(aiUsageRecords.resourceId))))
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
db
|
||||
.selectDistinct({ id: aiUsageRecords.siteResourceId })
|
||||
.from(aiUsageRecords)
|
||||
.where(
|
||||
@@ -105,12 +104,12 @@ async function query(data: Q) {
|
||||
)
|
||||
)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
db
|
||||
.selectDistinct({ userId: aiUsageRecords.userId })
|
||||
.from(aiUsageRecords)
|
||||
.where(and(baseConditions, not(isNull(aiUsageRecords.userId))))
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
db
|
||||
.selectDistinct({ id: aiUsageRecords.virtualApiKeyId })
|
||||
.from(aiUsageRecords)
|
||||
.where(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { logsDb, aiUsageRecords } from "@server/db";
|
||||
import { db, aiUsageRecords } from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
@@ -29,7 +29,7 @@ async function query(data: Q) {
|
||||
const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId);
|
||||
const baseConditions = buildAiUsageWhere(data, roleUserIds);
|
||||
|
||||
const [totalsRow] = await logsDb
|
||||
const [totalsRow] = await db
|
||||
.select({
|
||||
requests: count(),
|
||||
promptTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.promptTokens}), 0)`,
|
||||
@@ -46,7 +46,7 @@ async function query(data: Q) {
|
||||
|
||||
const dayExpr = dayBucketExpr();
|
||||
|
||||
const requestsPerDay = await logsDb
|
||||
const requestsPerDay = await db
|
||||
.select({
|
||||
day: dayExpr.as("day"),
|
||||
requests: count()
|
||||
@@ -56,7 +56,7 @@ async function query(data: Q) {
|
||||
.groupBy(dayExpr)
|
||||
.orderBy(dayExpr);
|
||||
|
||||
const tokensPerDay = await logsDb
|
||||
const tokensPerDay = await db
|
||||
.select({
|
||||
day: dayExpr.as("day"),
|
||||
promptTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.promptTokens}), 0)`,
|
||||
@@ -70,7 +70,7 @@ async function query(data: Q) {
|
||||
.groupBy(dayExpr)
|
||||
.orderBy(dayExpr);
|
||||
|
||||
const costPerDay = await logsDb
|
||||
const costPerDay = await db
|
||||
.select({
|
||||
day: dayExpr.as("day"),
|
||||
cost: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`
|
||||
@@ -80,7 +80,7 @@ async function query(data: Q) {
|
||||
.groupBy(dayExpr)
|
||||
.orderBy(dayExpr);
|
||||
|
||||
const modelByDay = await logsDb
|
||||
const modelByDay = await db
|
||||
.select({
|
||||
day: dayExpr.as("day"),
|
||||
model: aiUsageRecords.requestedModel,
|
||||
@@ -117,7 +117,7 @@ async function query(data: Q) {
|
||||
topModelsByTokens
|
||||
);
|
||||
|
||||
const topModelsRaw = await logsDb
|
||||
const topModelsRaw = await db
|
||||
.select({
|
||||
model: aiUsageRecords.requestedModel,
|
||||
requests: count(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db, logsDb, aiUsageRecords, aiProviders } from "@server/db";
|
||||
import { db, aiUsageRecords, aiProviders } from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
@@ -29,7 +29,7 @@ async function query(data: Q) {
|
||||
const baseConditions = buildAiUsageWhere(data, roleUserIds);
|
||||
const dayExpr = dayBucketExpr();
|
||||
|
||||
const providerByDay = await logsDb
|
||||
const providerByDay = await db
|
||||
.select({
|
||||
day: dayExpr.as("day"),
|
||||
providerId: aiUsageRecords.providerId,
|
||||
@@ -69,7 +69,7 @@ async function query(data: Q) {
|
||||
topByTokens
|
||||
);
|
||||
|
||||
const topProvidersRaw = await logsDb
|
||||
const topProvidersRaw = await db
|
||||
.select({
|
||||
providerId: aiUsageRecords.providerId,
|
||||
requests: count(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db, logsDb, aiUsageRecords, resources, siteResources } from "@server/db";
|
||||
import { db, aiUsageRecords, resources, siteResources } from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
@@ -39,7 +39,7 @@ async function query(data: Q) {
|
||||
const baseConditions = buildAiUsageWhere(data, roleUserIds);
|
||||
const dayExpr = dayBucketExpr();
|
||||
|
||||
const resourceByDay = await logsDb
|
||||
const resourceByDay = await db
|
||||
.select({
|
||||
day: dayExpr.as("day"),
|
||||
resourceId: aiUsageRecords.resourceId,
|
||||
@@ -80,7 +80,7 @@ async function query(data: Q) {
|
||||
topByTokens
|
||||
);
|
||||
|
||||
const topResourcesRaw = await logsDb
|
||||
const topResourcesRaw = await db
|
||||
.select({
|
||||
resourceId: aiUsageRecords.resourceId,
|
||||
siteResourceId: aiUsageRecords.siteResourceId,
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
db,
|
||||
logsDb,
|
||||
aiUsageRecords,
|
||||
users,
|
||||
roles,
|
||||
userOrgRoles
|
||||
} from "@server/db";
|
||||
import { db, aiUsageRecords, users, roles, userOrgRoles } from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
@@ -43,7 +36,7 @@ async function query(data: Q) {
|
||||
// userId, so role totals are derived by expanding each user's usage into
|
||||
// every role they hold in the org (per-role double counting for
|
||||
// multi-role users is expected/accepted).
|
||||
const userByDay = await logsDb
|
||||
const userByDay = await db
|
||||
.select({
|
||||
day: dayExpr.as("day"),
|
||||
userId: aiUsageRecords.userId,
|
||||
@@ -55,7 +48,7 @@ async function query(data: Q) {
|
||||
.groupBy(dayExpr, aiUsageRecords.userId)
|
||||
.orderBy(dayExpr);
|
||||
|
||||
const userTotalsRaw = await logsDb
|
||||
const userTotalsRaw = await db
|
||||
.select({
|
||||
userId: aiUsageRecords.userId,
|
||||
requests: count(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db, logsDb, aiUsageRecords, virtualApiKeys } from "@server/db";
|
||||
import { db, aiUsageRecords, virtualApiKeys } from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
@@ -31,7 +31,7 @@ async function query(data: Q) {
|
||||
const baseConditions = buildAiUsageWhere(data, roleUserIds);
|
||||
const dayExpr = dayBucketExpr();
|
||||
|
||||
const virtualApiKeyByDay = await logsDb
|
||||
const virtualApiKeyByDay = await db
|
||||
.select({
|
||||
day: dayExpr.as("day"),
|
||||
virtualApiKeyId: aiUsageRecords.virtualApiKeyId,
|
||||
@@ -43,7 +43,7 @@ async function query(data: Q) {
|
||||
.groupBy(dayExpr, aiUsageRecords.virtualApiKeyId)
|
||||
.orderBy(dayExpr);
|
||||
|
||||
const virtualApiKeyTotalsRaw = await logsDb
|
||||
const virtualApiKeyTotalsRaw = await db
|
||||
.select({
|
||||
virtualApiKeyId: aiUsageRecords.virtualApiKeyId,
|
||||
requests: count(),
|
||||
|
||||
@@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||
import { response } from "@server/lib/response";
|
||||
import { db } from "@server/db";
|
||||
import { passwordResetTokens, users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
|
||||
import { createDate } from "oslo";
|
||||
import logger from "@server/logger";
|
||||
@@ -49,7 +49,12 @@ export async function requestPasswordReset(
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email));
|
||||
.where(
|
||||
and(
|
||||
eq(users.email, email),
|
||||
eq(users.type, UserType.Internal)
|
||||
)
|
||||
);
|
||||
|
||||
if (!existingUser || !existingUser.length) {
|
||||
await randomDelay(2000);
|
||||
|
||||
@@ -1490,6 +1490,21 @@ authenticated.get(
|
||||
logs.exportRequestAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.viewLogs),
|
||||
logs.queryAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/export",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
logs.exportAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/usage/filters",
|
||||
verifyOrgAccess,
|
||||
|
||||
@@ -1532,6 +1532,21 @@ authenticated.get(
|
||||
logs.exportRequestAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.viewLogs),
|
||||
logs.queryAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/export",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
logs.exportAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/usage/filters",
|
||||
verifyApiKeyOrgAccess,
|
||||
|
||||
@@ -147,42 +147,6 @@ export async function updateOrg(
|
||||
parsedBody.data.settingsEnableGlobalNewtAutoUpdate = false; // force it off
|
||||
}
|
||||
|
||||
// Check access logs feature
|
||||
const hasAccessLogsFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.AccessLogs]
|
||||
);
|
||||
if (!hasAccessLogsFeature) {
|
||||
parsedBody.data.settingsLogRetentionDaysAccess = undefined;
|
||||
}
|
||||
|
||||
// Check action logs feature
|
||||
const hasActionLogsFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.ActionLogs]
|
||||
);
|
||||
if (!hasActionLogsFeature) {
|
||||
parsedBody.data.settingsLogRetentionDaysAction = undefined;
|
||||
}
|
||||
|
||||
// Check connection logs feature
|
||||
const hasConnectionLogsFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.ConnectionLogs]
|
||||
);
|
||||
if (!hasConnectionLogsFeature) {
|
||||
parsedBody.data.settingsLogRetentionDaysConnection = undefined;
|
||||
}
|
||||
|
||||
// Check AI session logs feature
|
||||
const hasAISessionLogsFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.AISessionLogs]
|
||||
);
|
||||
if (!hasAISessionLogsFeature) {
|
||||
parsedBody.data.settingsLogRetentionDaysAISessions = undefined;
|
||||
}
|
||||
|
||||
if (build == "saas") {
|
||||
const { tier } = await getOrgTierData(orgId);
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ export default async function migration() {
|
||||
try {
|
||||
await db.execute(sql`BEGIN`);
|
||||
|
||||
await db.execute(sql`
|
||||
`);
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "aiBudgetBreachEvents" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
@@ -451,14 +454,14 @@ export default async function migration() {
|
||||
throw new Error(fromZodError(parsedConfig.error).toString());
|
||||
}
|
||||
|
||||
traefikConfig.experimental.plugins.badger.version = "v1.6.1";
|
||||
traefikConfig.experimental.plugins.badger.version = "v1.6.0";
|
||||
|
||||
const updatedTraefikYaml = yaml.dump(traefikConfig);
|
||||
|
||||
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
|
||||
|
||||
console.log(
|
||||
"Updated the version of Badger in your Traefik configuration to v1.6.1"
|
||||
"Updated the version of Badger in your Traefik configuration to v1.6.0"
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
|
||||
@@ -456,14 +456,14 @@ export default async function migration() {
|
||||
throw new Error(fromZodError(parsedConfig.error).toString());
|
||||
}
|
||||
|
||||
traefikConfig.experimental.plugins.badger.version = "v1.6.1";
|
||||
traefikConfig.experimental.plugins.badger.version = "v1.6.0";
|
||||
|
||||
const updatedTraefikYaml = yaml.dump(traefikConfig);
|
||||
|
||||
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
|
||||
|
||||
console.log(
|
||||
"Updated the version of Badger in your Traefik configuration to v1.6.1"
|
||||
"Updated the version of Badger in your Traefik configuration to v1.6.0"
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
|
||||
@@ -298,6 +298,101 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysAISessions"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("logRetentionAISessionsLabel")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(
|
||||
parseInt(value, 10)
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectLogRetention"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_RETENTION_OPTIONS.filter(
|
||||
(option) => {
|
||||
if (
|
||||
build != "saas"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let maxDays: number;
|
||||
|
||||
if (
|
||||
!subscriptionTier
|
||||
) {
|
||||
// No tier
|
||||
maxDays = 3;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"enterprise"
|
||||
) {
|
||||
// Enterprise - no limit
|
||||
return true;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier3"
|
||||
) {
|
||||
maxDays = 90;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier2"
|
||||
) {
|
||||
maxDays = 30;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier1"
|
||||
) {
|
||||
maxDays = 7;
|
||||
} else {
|
||||
// Default to most restrictive
|
||||
maxDays = 3;
|
||||
}
|
||||
|
||||
// Filter out options that exceed the max
|
||||
// Special values: -1 (forever) and 9001 (end of year) should be filtered
|
||||
if (
|
||||
option.value <
|
||||
0 ||
|
||||
option.value >
|
||||
maxDays
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
).map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{t(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<PaidFeaturesAlert
|
||||
@@ -679,131 +774,6 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysAISessions"
|
||||
render={({ field }) => {
|
||||
const isDisabled = !isPaidUser(
|
||||
tierMatrix.aiSessionLogs
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"logRetentionAISessionsLabel"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value.toString()}
|
||||
onValueChange={(
|
||||
value
|
||||
) => {
|
||||
if (
|
||||
!isDisabled
|
||||
) {
|
||||
field.onChange(
|
||||
parseInt(
|
||||
value,
|
||||
10
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isDisabled
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectLogRetention"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_RETENTION_OPTIONS.filter(
|
||||
(
|
||||
option
|
||||
) => {
|
||||
if (
|
||||
build !=
|
||||
"saas"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let maxDays: number;
|
||||
|
||||
if (
|
||||
!subscriptionTier
|
||||
) {
|
||||
// No tier
|
||||
maxDays = 3;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"enterprise"
|
||||
) {
|
||||
// Enterprise - no limit
|
||||
return true;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier3"
|
||||
) {
|
||||
maxDays = 90;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier2"
|
||||
) {
|
||||
maxDays = 30;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier1"
|
||||
) {
|
||||
maxDays = 7;
|
||||
} else {
|
||||
// Default to most restrictive
|
||||
maxDays = 3;
|
||||
}
|
||||
|
||||
// Filter out options that exceed the max
|
||||
// Special values: -1 (forever) and 9001 (end of year) should be filtered
|
||||
if (
|
||||
option.value <
|
||||
0 ||
|
||||
option.value >
|
||||
maxDays
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
).map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.value
|
||||
}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{t(
|
||||
option.label
|
||||
)}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -19,8 +19,6 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr
|
||||
import axios from "axios";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import LogRetentionWarning from "@app/components/LogRetentionWarning";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
@@ -34,7 +32,6 @@ export default function GeneralPage() {
|
||||
const t = useTranslations();
|
||||
const { orgId } = useParams();
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
@@ -532,13 +529,6 @@ export default function GeneralPage() {
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.accessLogs} />
|
||||
|
||||
{org.org.settingsLogRetentionDaysAccess === 0 && (
|
||||
<LogRetentionWarning
|
||||
orgId={orgId as string}
|
||||
logTypeLabel={t("accessLogs")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
|
||||
@@ -3,10 +3,8 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { LogDataTable } from "@app/components/LogDataTable";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import LogRetentionWarning from "@app/components/LogRetentionWarning";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
@@ -31,7 +29,6 @@ export default function GeneralPage() {
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
@@ -362,13 +359,6 @@ export default function GeneralPage() {
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.actionLogs} />
|
||||
|
||||
{org.org.settingsLogRetentionDaysAction === 0 && (
|
||||
<LogRetentionWarning
|
||||
orgId={orgId as string}
|
||||
logTypeLabel={t("actionLogs")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
|
||||
@@ -3,13 +3,9 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { LogDataTable } from "@app/components/LogDataTable";
|
||||
import { AiSessionChatView } from "@app/components/AiSessionChatView";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import LogRetentionWarning from "@app/components/LogRetentionWarning";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -17,8 +13,6 @@ import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
@@ -47,9 +41,6 @@ export default function AiSessionLogsPage() {
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
@@ -138,8 +129,7 @@ export default function AiSessionLogsPage() {
|
||||
...logQueries.aiSessions({
|
||||
orgId: orgId as string,
|
||||
filters: queryFilters
|
||||
}),
|
||||
enabled: isPaidUser(tierMatrix.aiSessionLogs) && build !== "oss"
|
||||
})
|
||||
});
|
||||
|
||||
const rows = isLoading ? generateSampleAiSessionLogs() : (data?.log ?? []);
|
||||
@@ -651,15 +641,6 @@ export default function AiSessionLogsPage() {
|
||||
description={t("aiSessionLogsDescription")}
|
||||
/>
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.aiSessionLogs} />
|
||||
|
||||
{org.org.settingsLogRetentionDaysAISessions === 0 && (
|
||||
<LogRetentionWarning
|
||||
orgId={orgId as string}
|
||||
logTypeLabel={t("aiSessionLogs")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
@@ -687,9 +668,6 @@ export default function AiSessionLogsPage() {
|
||||
pageSize={pageSize}
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={
|
||||
!isPaidUser(tierMatrix.aiSessionLogs) || build === "oss"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,10 +4,8 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { LogDataTable } from "@app/components/LogDataTable";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import LogRetentionWarning from "@app/components/LogRetentionWarning";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
@@ -49,7 +47,6 @@ export default function ConnectionLogsPage() {
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
@@ -585,13 +582,6 @@ export default function ConnectionLogsPage() {
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.connectionLogs} />
|
||||
|
||||
{org.org.settingsLogRetentionDaysConnection === 0 && (
|
||||
<LogRetentionWarning
|
||||
orgId={orgId as string}
|
||||
logTypeLabel={t("connectionLogs")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
|
||||
type GeneralSettingsProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -13,8 +11,6 @@ export default async function GeneralSettingsPage({
|
||||
children,
|
||||
params
|
||||
}: GeneralSettingsProps) {
|
||||
const { orgId } = await params;
|
||||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
||||
@@ -22,13 +18,5 @@ export default async function GeneralSettingsPage({
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const res = await getCachedOrg(orgId);
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
return <OrgProvider org={org}>{children}</OrgProvider>;
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
import { ColumnFilter } from "@app/components/ColumnFilter";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { LogDataTable } from "@app/components/LogDataTable";
|
||||
import LogRetentionWarning from "@app/components/LogRetentionWarning";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -31,8 +29,6 @@ export default function GeneralPage() {
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
@@ -718,13 +714,6 @@ export default function GeneralPage() {
|
||||
description={t("requestLogsDescription")}
|
||||
/>
|
||||
|
||||
{org.org.settingsLogRetentionDaysRequest === 0 && (
|
||||
<LogRetentionWarning
|
||||
orgId={orgId as string}
|
||||
logTypeLabel={t("requestLogs")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
|
||||
@@ -201,11 +201,7 @@ export default async function ResourceAuthPage(props: {
|
||||
// Inference resources never establish a resource session on the inference
|
||||
// host. Authenticated users retrieve their virtual API key on the dashboard.
|
||||
if (isInference && user) {
|
||||
if (host !== expectedHost) {
|
||||
redirect(`/auth/org?redirect=${encodeURIComponent(keysPath)}`);
|
||||
} else {
|
||||
redirect(keysPath);
|
||||
}
|
||||
redirect(keysPath);
|
||||
}
|
||||
|
||||
// After password/pincode/SSO, do not send the browser back to the
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import ActionBanner from "@app/components/ActionBanner";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ShieldAlert } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
type LogRetentionWarningProps = {
|
||||
orgId: string;
|
||||
logTypeLabel: string;
|
||||
};
|
||||
|
||||
export function LogRetentionWarning({
|
||||
orgId,
|
||||
logTypeLabel
|
||||
}: LogRetentionWarningProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<ActionBanner
|
||||
variant="warning"
|
||||
title={t("logRetentionDisabledWarningTitle")}
|
||||
titleIcon={<ShieldAlert className="w-5 h-5" />}
|
||||
description={t("logRetentionDisabledWarningDescription", {
|
||||
logType: logTypeLabel
|
||||
})}
|
||||
actions={
|
||||
<Link href={`/${orgId}/settings/general/security`}>
|
||||
<Button variant="outline" className="gap-2">
|
||||
{t("logRetentionDisabledWarningButton")}
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogRetentionWarning;
|
||||
@@ -49,6 +49,10 @@ const CLIENT_LOGOS = {
|
||||
opencode: {
|
||||
light: "/third-party/opencode-dark.svg",
|
||||
dark: "/third-party/opencode-light.svg"
|
||||
},
|
||||
cursor: {
|
||||
light: "/third-party/cursor-dark.svg",
|
||||
dark: "/third-party/cursor-light.svg"
|
||||
}
|
||||
} as const;
|
||||
|
||||
@@ -65,7 +69,8 @@ export function AiClientConfigSection({
|
||||
const descriptions: Record<string, string> = {
|
||||
claude: t("aiClientConfigDescriptionClaude"),
|
||||
codex: t("aiClientConfigDescriptionCodex"),
|
||||
opencode: t("aiClientConfigDescriptionOpencode")
|
||||
opencode: t("aiClientConfigDescriptionOpencode"),
|
||||
cursor: t("aiClientConfigDescriptionCursor")
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+41
-22
@@ -1,10 +1,11 @@
|
||||
export const AI_CLIENT_IDS = ["claude", "codex", "opencode"] as const;
|
||||
export const AI_CLIENT_IDS = ["claude", "codex", "opencode", "cursor"] as const;
|
||||
export type AiClientId = (typeof AI_CLIENT_IDS)[number];
|
||||
|
||||
export const AI_CLIENT_NAMES: Record<AiClientId, string> = {
|
||||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
opencode: "OpenCode"
|
||||
opencode: "OpenCode",
|
||||
cursor: "Cursor"
|
||||
};
|
||||
|
||||
/** Auth as supplied by callers: the real key isn't fetched yet. */
|
||||
@@ -292,7 +293,7 @@ function buildOpencodeGuide(
|
||||
' "options": {',
|
||||
` "baseURL": "${endpoint}/v1"`,
|
||||
" }",
|
||||
" },",
|
||||
" }",
|
||||
' "openai": {',
|
||||
' "options": {',
|
||||
` "baseURL": "${endpoint}/v1"`,
|
||||
@@ -313,26 +314,12 @@ function buildOpencodeGuide(
|
||||
' "anthropic": {',
|
||||
' "type": "api",',
|
||||
` "key": "${key}"`,
|
||||
" },",
|
||||
' "openai": {',
|
||||
' "type": "api",',
|
||||
` "key": "${key}"`,
|
||||
" }",
|
||||
"}"
|
||||
].join("\n"),
|
||||
auth
|
||||
);
|
||||
|
||||
const moreProviders = block(
|
||||
"opencode-more-providers",
|
||||
"More providers",
|
||||
() =>
|
||||
"OpenCode configures providers individually, so Anthropic and OpenAI are just the ones set up above. " +
|
||||
'You can point any other OpenCode-supported provider (e.g. "openrouter", "google", "groq") at this gateway the same way: add a matching entry under "provider" in opencode.json, and under auth.json if it needs an API key.',
|
||||
auth,
|
||||
"steps"
|
||||
);
|
||||
|
||||
return {
|
||||
id: "opencode",
|
||||
name: AI_CLIENT_NAMES.opencode,
|
||||
@@ -342,10 +329,41 @@ function buildOpencodeGuide(
|
||||
id: "default",
|
||||
label: "Default",
|
||||
relation: "steps",
|
||||
blocks:
|
||||
auth.mode === "keyed"
|
||||
? [config, authFile, moreProviders]
|
||||
: [config, moreProviders]
|
||||
blocks: [config, authFile]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function buildCursorGuide(endpoint: string, auth: AiClientAuth): AiClientGuide {
|
||||
const steps = block(
|
||||
"cursor-steps",
|
||||
"Cursor Settings",
|
||||
(key) =>
|
||||
[
|
||||
"1. Open Cursor Settings -> Models.",
|
||||
'2. Enable "Override OpenAI Base URL".',
|
||||
`3. Set the base URL to: ${endpoint}/v1`,
|
||||
auth.mode === "keyed"
|
||||
? `4. Paste your API key into the OpenAI API Key field: ${key}`
|
||||
: '4. Leave the OpenAI API Key field set to a placeholder (e.g. "-"). Pangolin authenticates the request over your Newt/Olm connection automatically.',
|
||||
"5. Add a custom model matching the model your Pangolin AI Gateway serves (e.g. claude-sonnet-4-6)."
|
||||
].join("\n"),
|
||||
auth,
|
||||
"steps",
|
||||
true
|
||||
);
|
||||
|
||||
return {
|
||||
id: "cursor",
|
||||
name: AI_CLIENT_NAMES.cursor,
|
||||
cli: null,
|
||||
presets: [
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
relation: "steps",
|
||||
blocks: [steps]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -361,7 +379,8 @@ const GUIDE_BUILDERS: Record<
|
||||
> = {
|
||||
claude: buildClaudeGuide,
|
||||
codex: buildCodexGuide,
|
||||
opencode: buildOpencodeGuide
|
||||
opencode: buildOpencodeGuide,
|
||||
cursor: buildCursorGuide
|
||||
};
|
||||
|
||||
export function buildAiClientGuide(
|
||||
|
||||
Reference in New Issue
Block a user