mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-20 19:22:37 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df7e26a444 | |||
| c1051db4a5 | |||
| c1caa30cb9 |
@@ -1785,6 +1785,7 @@
|
|||||||
"aiClientConfigDescriptionClaude": "Anthropic's agentic coding tool for the terminal.",
|
"aiClientConfigDescriptionClaude": "Anthropic's agentic coding tool for the terminal.",
|
||||||
"aiClientConfigDescriptionCodex": "OpenAI's agentic coding tool for the terminal.",
|
"aiClientConfigDescriptionCodex": "OpenAI's agentic coding tool for the terminal.",
|
||||||
"aiClientConfigDescriptionOpencode": "Open source terminal coding agent.",
|
"aiClientConfigDescriptionOpencode": "Open source terminal coding agent.",
|
||||||
|
"aiClientConfigDescriptionGemini": "Google's agentic coding tool for the terminal.",
|
||||||
"aiClientConfigSetup": "Setup",
|
"aiClientConfigSetup": "Setup",
|
||||||
"aiClientConfigTabCli": "Automatic (CLI)",
|
"aiClientConfigTabCli": "Automatic (CLI)",
|
||||||
"aiClientConfigTabManual": "Manual Configuration",
|
"aiClientConfigTabManual": "Manual Configuration",
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 413 B |
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 413 B |
@@ -9,6 +9,7 @@ export enum TierFeature {
|
|||||||
AccessLogs = "accessLogs", // set the retention period to none on downgrade
|
AccessLogs = "accessLogs", // set the retention period to none on downgrade
|
||||||
ActionLogs = "actionLogs", // set the retention period to none on downgrade
|
ActionLogs = "actionLogs", // set the retention period to none on downgrade
|
||||||
ConnectionLogs = "connectionLogs",
|
ConnectionLogs = "connectionLogs",
|
||||||
|
AISessionLogs = "aiSessionLogs",
|
||||||
RotateCredentials = "rotateCredentials",
|
RotateCredentials = "rotateCredentials",
|
||||||
MaintenancePage = "maintenancePage", // handle downgrade
|
MaintenancePage = "maintenancePage", // handle downgrade
|
||||||
DevicePosture = "devicePosture",
|
DevicePosture = "devicePosture",
|
||||||
@@ -37,6 +38,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
[TierFeature.AccessLogs]: ["tier2", "tier3", "enterprise"],
|
[TierFeature.AccessLogs]: ["tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
|
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"],
|
[TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"],
|
||||||
|
[TierFeature.AISessionLogs]: ["tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.MaintenancePage]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.MaintenancePage]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
|
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* 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 { db, userOrgRoles, users } from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
/*
|
||||||
|
* 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,6 +291,10 @@ async function disableFeature(
|
|||||||
await disableConnectionLogs(orgId);
|
await disableConnectionLogs(orgId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case TierFeature.AISessionLogs:
|
||||||
|
await disableAISessionLogs(orgId);
|
||||||
|
break;
|
||||||
|
|
||||||
case TierFeature.RotateCredentials:
|
case TierFeature.RotateCredentials:
|
||||||
await disableRotateCredentials(orgId);
|
await disableRotateCredentials(orgId);
|
||||||
break;
|
break;
|
||||||
@@ -493,6 +497,15 @@ async function disableConnectionLogs(orgId: string): Promise<void> {
|
|||||||
logger.info(`Disabled connection logs for org ${orgId}`);
|
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 disableRotateCredentials(orgId: string): Promise<void> {}
|
||||||
|
|
||||||
async function disablemaintenancePage(orgId: string): Promise<void> {
|
async function disablemaintenancePage(orgId: string): Promise<void> {
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import * as auth from "#private/routers/auth";
|
|||||||
import * as license from "#private/routers/license";
|
import * as license from "#private/routers/license";
|
||||||
import * as generateLicense from "#private/routers/generatedLicense";
|
import * as generateLicense from "#private/routers/generatedLicense";
|
||||||
import * as logs from "#private/routers/auditLogs";
|
import * as logs from "#private/routers/auditLogs";
|
||||||
|
import {
|
||||||
|
queryAiSessionLogs,
|
||||||
|
exportAiSessionLogs
|
||||||
|
} from "@server/routers/auditLogs";
|
||||||
import * as misc from "#private/routers/misc";
|
import * as misc from "#private/routers/misc";
|
||||||
import * as reKey from "#private/routers/re-key";
|
import * as reKey from "#private/routers/re-key";
|
||||||
import * as approval from "#private/routers/approvals";
|
import * as approval from "#private/routers/approvals";
|
||||||
@@ -591,6 +595,25 @@ authenticated.get(
|
|||||||
logs.exportConnectionAuditLogs
|
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(
|
authenticated.post(
|
||||||
"/re-key/:clientId/regenerate-client-secret",
|
"/re-key/:clientId/regenerate-client-secret",
|
||||||
verifyClientAccess, // this is first to set the org id
|
verifyClientAccess, // this is first to set the org id
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ import {
|
|||||||
unauthenticated as ua,
|
unauthenticated as ua,
|
||||||
authenticated as a
|
authenticated as a
|
||||||
} from "@server/routers/integration";
|
} from "@server/routers/integration";
|
||||||
|
import {
|
||||||
|
queryAiSessionLogs,
|
||||||
|
exportAiSessionLogs
|
||||||
|
} from "@server/routers/auditLogs";
|
||||||
import { logActionAudit } from "#private/middlewares";
|
import { logActionAudit } from "#private/middlewares";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
@@ -153,6 +157,25 @@ authenticated.get(
|
|||||||
logs.exportConnectionAuditLogs
|
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(
|
authenticated.put(
|
||||||
"/org/:orgId/idp/oidc",
|
"/org/:orgId/idp/oidc",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
@@ -1,171 +1,12 @@
|
|||||||
import { logsDb, db, orgs, aiSessionLog, type AiProvider } from "@server/db";
|
import { AiCapability } from "@app/lib/aiCapabilities";
|
||||||
import type { InferInsertModel } from "drizzle-orm";
|
import { AiProvider } from "@server/db";
|
||||||
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)
|
* 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRetentionDays(orgId: string): Promise<number> {
|
export async function cleanUpOldLogs(orgId: string, retentionDays: 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: {
|
export function logAiSession(data: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -181,95 +22,4 @@ export function logAiSession(data: {
|
|||||||
siteResourceId: number | null;
|
siteResourceId: number | null;
|
||||||
requestUserId: string | null;
|
requestUserId: string | null;
|
||||||
virtualApiKeyId: 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 });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ import {
|
|||||||
type AiUsage
|
type AiUsage
|
||||||
} from "@server/lib/aiUsageExtraction";
|
} from "@server/lib/aiUsageExtraction";
|
||||||
import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse";
|
import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse";
|
||||||
import { logAiSession } from "@server/routers/aiGateway/logAiSession";
|
import { logAiSession } from "#dynamic/routers/aiGateway/logAiSession";
|
||||||
|
|
||||||
const EXIT_NODE_RANGES_CACHE_KEY = "aiGateway:exitNodeRanges";
|
const EXIT_NODE_RANGES_CACHE_KEY = "aiGateway:exitNodeRanges";
|
||||||
const EXIT_NODE_RANGES_TTL_SEC = 6000;
|
const EXIT_NODE_RANGES_TTL_SEC = 6000;
|
||||||
@@ -728,7 +728,9 @@ export function recordAiGatewayCompletion(args: {
|
|||||||
let cost: ReturnType<typeof calculateAiCost> = null;
|
let cost: ReturnType<typeof calculateAiCost> = null;
|
||||||
|
|
||||||
if (upstreamSucceeded) {
|
if (upstreamSucceeded) {
|
||||||
usage = extractUsage(capability, responseText, isStream, headers) ?? emptyUsage();
|
usage =
|
||||||
|
extractUsage(capability, responseText, isStream, headers) ??
|
||||||
|
emptyUsage();
|
||||||
if (isUsageEmpty(usage)) {
|
if (isUsageEmpty(usage)) {
|
||||||
usage = estimateUsage(
|
usage = estimateUsage(
|
||||||
JSON.stringify(requestBody ?? ""),
|
JSON.stringify(requestBody ?? ""),
|
||||||
|
|||||||
@@ -1490,21 +1490,6 @@ authenticated.get(
|
|||||||
logs.exportRequestAuditLogs
|
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(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/ai/usage/filters",
|
"/org/:orgId/logs/ai/usage/filters",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|||||||
@@ -1532,21 +1532,6 @@ authenticated.get(
|
|||||||
logs.exportRequestAuditLogs
|
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(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/ai/usage/filters",
|
"/org/:orgId/logs/ai/usage/filters",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
|||||||
@@ -147,6 +147,42 @@ export async function updateOrg(
|
|||||||
parsedBody.data.settingsEnableGlobalNewtAutoUpdate = false; // force it off
|
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") {
|
if (build == "saas") {
|
||||||
const { tier } = await getOrgTierData(orgId);
|
const { tier } = await getOrgTierData(orgId);
|
||||||
|
|
||||||
|
|||||||
@@ -298,101 +298,6 @@ 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 && (
|
{!env.flags.disableEnterpriseFeatures && (
|
||||||
<>
|
<>
|
||||||
<PaidFeaturesAlert
|
<PaidFeaturesAlert
|
||||||
@@ -774,6 +679,131 @@ 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>
|
</form>
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
|||||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||||
import { LogDataTable } from "@app/components/LogDataTable";
|
import { LogDataTable } from "@app/components/LogDataTable";
|
||||||
import { AiSessionChatView } from "@app/components/AiSessionChatView";
|
import { AiSessionChatView } from "@app/components/AiSessionChatView";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import LogRetentionWarning from "@app/components/LogRetentionWarning";
|
import LogRetentionWarning from "@app/components/LogRetentionWarning";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -15,6 +17,8 @@ import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
|||||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||||
import { logQueries } from "@app/lib/queries";
|
import { logQueries } from "@app/lib/queries";
|
||||||
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
|
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 { ColumnDef } from "@tanstack/react-table";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -44,6 +48,7 @@ export default function AiSessionLogsPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -133,7 +138,8 @@ export default function AiSessionLogsPage() {
|
|||||||
...logQueries.aiSessions({
|
...logQueries.aiSessions({
|
||||||
orgId: orgId as string,
|
orgId: orgId as string,
|
||||||
filters: queryFilters
|
filters: queryFilters
|
||||||
})
|
}),
|
||||||
|
enabled: isPaidUser(tierMatrix.aiSessionLogs) && build !== "oss"
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = isLoading ? generateSampleAiSessionLogs() : (data?.log ?? []);
|
const rows = isLoading ? generateSampleAiSessionLogs() : (data?.log ?? []);
|
||||||
@@ -645,6 +651,8 @@ export default function AiSessionLogsPage() {
|
|||||||
description={t("aiSessionLogsDescription")}
|
description={t("aiSessionLogsDescription")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PaidFeaturesAlert tiers={tierMatrix.aiSessionLogs} />
|
||||||
|
|
||||||
{org.org.settingsLogRetentionDaysAISessions === 0 && (
|
{org.org.settingsLogRetentionDaysAISessions === 0 && (
|
||||||
<LogRetentionWarning
|
<LogRetentionWarning
|
||||||
orgId={orgId as string}
|
orgId={orgId as string}
|
||||||
@@ -679,6 +687,9 @@ export default function AiSessionLogsPage() {
|
|||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
|
disabled={
|
||||||
|
!isPaidUser(tierMatrix.aiSessionLogs) || build === "oss"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ const CLIENT_LOGOS = {
|
|||||||
opencode: {
|
opencode: {
|
||||||
light: "/third-party/opencode-dark.svg",
|
light: "/third-party/opencode-dark.svg",
|
||||||
dark: "/third-party/opencode-light.svg"
|
dark: "/third-party/opencode-light.svg"
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
light: "/third-party/gemini-dark.svg",
|
||||||
|
dark: "/third-party/gemini-light.svg"
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -65,7 +69,8 @@ export function AiClientConfigSection({
|
|||||||
const descriptions: Record<string, string> = {
|
const descriptions: Record<string, string> = {
|
||||||
claude: t("aiClientConfigDescriptionClaude"),
|
claude: t("aiClientConfigDescriptionClaude"),
|
||||||
codex: t("aiClientConfigDescriptionCodex"),
|
codex: t("aiClientConfigDescriptionCodex"),
|
||||||
opencode: t("aiClientConfigDescriptionOpencode")
|
opencode: t("aiClientConfigDescriptionOpencode"),
|
||||||
|
gemini: t("aiClientConfigDescriptionGemini")
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+68
-11
@@ -1,10 +1,16 @@
|
|||||||
export const AI_CLIENT_IDS = ["claude", "codex", "opencode"] as const;
|
export const AI_CLIENT_IDS = [
|
||||||
|
"claude",
|
||||||
|
"codex",
|
||||||
|
"opencode",
|
||||||
|
"gemini"
|
||||||
|
] as const;
|
||||||
export type AiClientId = (typeof AI_CLIENT_IDS)[number];
|
export type AiClientId = (typeof AI_CLIENT_IDS)[number];
|
||||||
|
|
||||||
export const AI_CLIENT_NAMES: Record<AiClientId, string> = {
|
export const AI_CLIENT_NAMES: Record<AiClientId, string> = {
|
||||||
claude: "Claude Code",
|
claude: "Claude Code",
|
||||||
codex: "Codex",
|
codex: "Codex",
|
||||||
opencode: "OpenCode"
|
opencode: "OpenCode",
|
||||||
|
gemini: "Gemini CLI"
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Auth as supplied by callers: the real key isn't fetched yet. */
|
/** Auth as supplied by callers: the real key isn't fetched yet. */
|
||||||
@@ -43,8 +49,15 @@ export type AiClientGuide = {
|
|||||||
presets: AiConfigPreset[];
|
presets: AiConfigPreset[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder key for keyless (private/site) resources. Those resources need
|
||||||
|
* no credential, but most clients refuse to start without *some* key set, so
|
||||||
|
* they get an obviously-inert one rather than an omitted field.
|
||||||
|
*/
|
||||||
|
const KEYLESS_PLACEHOLDER_KEY = "none";
|
||||||
|
|
||||||
function keyValue(auth: AiClientAuth): string {
|
function keyValue(auth: AiClientAuth): string {
|
||||||
return auth.mode === "keyed" ? auth.key : "-";
|
return auth.mode === "keyed" ? auth.key : KEYLESS_PLACEHOLDER_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
function block(
|
function block(
|
||||||
@@ -74,7 +87,7 @@ export function aiConfigBlockHasPlaceholders(block: AiConfigBlock): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildCli(
|
function buildCli(
|
||||||
clientArg: "claude" | "codex" | "opencode",
|
clientArg: "claude" | "codex" | "opencode" | "gemini",
|
||||||
auth: AiClientAuth,
|
auth: AiClientAuth,
|
||||||
resourceNiceId?: string
|
resourceNiceId?: string
|
||||||
): AiConfigBlock[] {
|
): AiConfigBlock[] {
|
||||||
@@ -126,7 +139,7 @@ function buildClaudeGuide(
|
|||||||
(key) =>
|
(key) =>
|
||||||
[
|
[
|
||||||
`export ANTHROPIC_BASE_URL=${endpoint}`,
|
`export ANTHROPIC_BASE_URL=${endpoint}`,
|
||||||
`export ANTHROPIC_API_KEY=${auth.mode === "keyed" ? key : "none"}`,
|
`export ANTHROPIC_API_KEY=${key}`,
|
||||||
"claude"
|
"claude"
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
auth
|
auth
|
||||||
@@ -328,7 +341,7 @@ function buildOpencodeGuide(
|
|||||||
"More providers",
|
"More providers",
|
||||||
() =>
|
() =>
|
||||||
"OpenCode configures providers individually, so Anthropic and OpenAI are just the ones set up above. " +
|
"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.',
|
'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 a matching key in auth.json.',
|
||||||
auth,
|
auth,
|
||||||
"steps"
|
"steps"
|
||||||
);
|
);
|
||||||
@@ -342,10 +355,53 @@ function buildOpencodeGuide(
|
|||||||
id: "default",
|
id: "default",
|
||||||
label: "Default",
|
label: "Default",
|
||||||
relation: "steps",
|
relation: "steps",
|
||||||
blocks:
|
// auth.json is written even for keyless resources: OpenCode
|
||||||
auth.mode === "keyed"
|
// refuses to start a provider with no key at all ("OpenAI API
|
||||||
? [config, authFile, moreProviders]
|
// key is missing"), so it gets the inert placeholder instead.
|
||||||
: [config, moreProviders]
|
blocks: [config, authFile, moreProviders]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGeminiGuide(
|
||||||
|
endpoint: string,
|
||||||
|
auth: AiClientAuth,
|
||||||
|
resourceNiceId?: string
|
||||||
|
): AiClientGuide {
|
||||||
|
const defaultEnv = block(
|
||||||
|
"gemini-default-env",
|
||||||
|
"~/.gemini/.env",
|
||||||
|
(key) =>
|
||||||
|
[
|
||||||
|
`GOOGLE_GEMINI_BASE_URL=${endpoint}`,
|
||||||
|
`GEMINI_API_KEY=${key}`
|
||||||
|
].join("\n"),
|
||||||
|
auth
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultShell = block(
|
||||||
|
"gemini-default-shell",
|
||||||
|
"Shell",
|
||||||
|
(key) =>
|
||||||
|
[
|
||||||
|
`export GOOGLE_GEMINI_BASE_URL=${endpoint}`,
|
||||||
|
`export GEMINI_API_KEY=${key}`,
|
||||||
|
"gemini"
|
||||||
|
].join("\n"),
|
||||||
|
auth
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: "gemini",
|
||||||
|
name: AI_CLIENT_NAMES.gemini,
|
||||||
|
cli: buildCli("gemini", auth, resourceNiceId),
|
||||||
|
presets: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
relation: "options",
|
||||||
|
blocks: [defaultEnv, defaultShell]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -361,7 +417,8 @@ const GUIDE_BUILDERS: Record<
|
|||||||
> = {
|
> = {
|
||||||
claude: buildClaudeGuide,
|
claude: buildClaudeGuide,
|
||||||
codex: buildCodexGuide,
|
codex: buildCodexGuide,
|
||||||
opencode: buildOpencodeGuide
|
opencode: buildOpencodeGuide,
|
||||||
|
gemini: buildGeminiGuide
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildAiClientGuide(
|
export function buildAiClientGuide(
|
||||||
|
|||||||
Reference in New Issue
Block a user