From c1caa30cb9c94d427dd25427c8dfebb1f8aa73f4 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 19 Aug 2026 17:25:32 -0400 Subject: [PATCH 01/42] Move session logs to private --- server/lib/billing/tierMatrix.ts | 2 + .../private/lib/alerts/processTestAlerts.ts | 13 + .../private/routers/aiGateway/logAiSession.ts | 288 ++++++++++++++++++ .../routers/billing/featureLifecycle.ts | 13 + server/private/routers/external.ts | 23 ++ server/private/routers/integration.ts | 23 ++ server/routers/aiGateway/logAiSession.ts | 260 +--------------- server/routers/aiGateway/pipeline.ts | 6 +- server/routers/external.ts | 15 - server/routers/integration.ts | 15 - server/routers/org/updateOrg.ts | 36 +++ .../settings/general/security/page.tsx | 220 +++++++------ src/app/[orgId]/settings/logs/ai/page.tsx | 13 +- 13 files changed, 544 insertions(+), 383 deletions(-) create mode 100644 server/private/routers/aiGateway/logAiSession.ts diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 7e49121dc..c04a60aed 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -9,6 +9,7 @@ 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", @@ -37,6 +38,7 @@ export const tierMatrix: Record = { [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"], diff --git a/server/private/lib/alerts/processTestAlerts.ts b/server/private/lib/alerts/processTestAlerts.ts index f7fa47b20..5b2b8c878 100644 --- a/server/private/lib/alerts/processTestAlerts.ts +++ b/server/private/lib/alerts/processTestAlerts.ts @@ -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 logger from "@server/logger"; import type { diff --git a/server/private/routers/aiGateway/logAiSession.ts b/server/private/routers/aiGateway/logAiSession.ts new file mode 100644 index 000000000..527dc0b5b --- /dev/null +++ b/server/private/routers/aiGateway/logAiSession.ts @@ -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; + +// 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 { + // check cache first + const cached = await cache.get(`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 }); + } + })(); +} diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index b32d83f7e..b7487fe49 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -291,6 +291,10 @@ async function disableFeature( await disableConnectionLogs(orgId); break; + case TierFeature.AISessionLogs: + await disableAISessionLogs(orgId); + break; + case TierFeature.RotateCredentials: await disableRotateCredentials(orgId); break; @@ -493,6 +497,15 @@ async function disableConnectionLogs(orgId: string): Promise { logger.info(`Disabled connection logs for org ${orgId}`); } +async function disableAISessionLogs(orgId: string): Promise { + 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 {} async function disablemaintenancePage(orgId: string): Promise { diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 5cc2b1545..0110bf51d 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -21,6 +21,10 @@ 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"; @@ -591,6 +595,25 @@ 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 diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 8a1e15c2f..814fa8e4a 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -43,6 +43,10 @@ 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"; @@ -153,6 +157,25 @@ 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, diff --git a/server/routers/aiGateway/logAiSession.ts b/server/routers/aiGateway/logAiSession.ts index 5a2e29bdb..756d53d97 100644 --- a/server/routers/aiGateway/logAiSession.ts +++ b/server/routers/aiGateway/logAiSession.ts @@ -1,171 +1,12 @@ -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; - -// 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); - } -} +import { AiCapability } from "@app/lib/aiCapabilities"; +import { AiProvider } from "@server/db"; /** * 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(); -} +export async function shutdownAiSessionLogger() {} -async function getRetentionDays(orgId: string): Promise { - // check cache first - const cached = await cache.get(`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 async function cleanUpOldLogs(orgId: string, retentionDays: number) {} export function logAiSession(data: { sessionId: string; @@ -181,95 +22,4 @@ export function logAiSession(data: { 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 }); - } - })(); -} +}): void {} diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index e11e14a62..76889cdc8 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -86,7 +86,7 @@ import { type AiUsage } from "@server/lib/aiUsageExtraction"; 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_TTL_SEC = 6000; @@ -728,7 +728,9 @@ export function recordAiGatewayCompletion(args: { let cost: ReturnType = null; if (upstreamSucceeded) { - usage = extractUsage(capability, responseText, isStream, headers) ?? emptyUsage(); + usage = + extractUsage(capability, responseText, isStream, headers) ?? + emptyUsage(); if (isUsageEmpty(usage)) { usage = estimateUsage( JSON.stringify(requestBody ?? ""), diff --git a/server/routers/external.ts b/server/routers/external.ts index 2d91868bc..f4442bd08 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1490,21 +1490,6 @@ 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, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 214c1ff27..bcf5a6a32 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -1532,21 +1532,6 @@ 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, diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 021448375..3c35cecc1 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -147,6 +147,42 @@ 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); diff --git a/src/app/[orgId]/settings/general/security/page.tsx b/src/app/[orgId]/settings/general/security/page.tsx index 7f83711b5..2fd6405a7 100644 --- a/src/app/[orgId]/settings/general/security/page.tsx +++ b/src/app/[orgId]/settings/general/security/page.tsx @@ -298,101 +298,6 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { )} /> - ( - - - {t("logRetentionAISessionsLabel")} - - - - - - - )} - /> - {!env.flags.disableEnterpriseFeatures && ( <> + { + const isDisabled = !isPaidUser( + tierMatrix.aiSessionLogs + ); + + return ( + + + {t( + "logRetentionAISessionsLabel" + )} + + + + + + + ); + }} + /> )} diff --git a/src/app/[orgId]/settings/logs/ai/page.tsx b/src/app/[orgId]/settings/logs/ai/page.tsx index 4916d21be..136d186c3 100644 --- a/src/app/[orgId]/settings/logs/ai/page.tsx +++ b/src/app/[orgId]/settings/logs/ai/page.tsx @@ -3,11 +3,13 @@ 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"; @@ -15,6 +17,8 @@ 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"; @@ -44,6 +48,7 @@ export default function AiSessionLogsPage() { const searchParams = useSearchParams(); const { org } = useOrgContext(); + const { isPaidUser } = usePaidStatus(); const [isExporting, startTransition] = useTransition(); @@ -133,7 +138,8 @@ export default function AiSessionLogsPage() { ...logQueries.aiSessions({ orgId: orgId as string, filters: queryFilters - }) + }), + enabled: isPaidUser(tierMatrix.aiSessionLogs) && build !== "oss" }); const rows = isLoading ? generateSampleAiSessionLogs() : (data?.log ?? []); @@ -645,6 +651,8 @@ export default function AiSessionLogsPage() { description={t("aiSessionLogsDescription")} /> + + {org.org.settingsLogRetentionDaysAISessions === 0 && ( ); From c1051db4a56321c5b2afacf1500390b61ccacd17 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 20 Aug 2026 10:28:25 -0400 Subject: [PATCH 02/42] Add gemini as a client option --- messages/en-US.json | 1 + public/third-party/gemini-dark.svg | 3 + public/third-party/gemini-light.svg | 3 + .../AiClientConfigSection.tsx | 7 ++- src/lib/aiClientConfig.ts | 58 +++++++++++++++++-- 5 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 public/third-party/gemini-dark.svg create mode 100644 public/third-party/gemini-light.svg diff --git a/messages/en-US.json b/messages/en-US.json index 02c022e35..46f255c39 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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.", + "aiClientConfigDescriptionGemini": "Google's agentic coding tool for the terminal.", "aiClientConfigSetup": "Setup", "aiClientConfigTabCli": "Automatic (CLI)", "aiClientConfigTabManual": "Manual Configuration", diff --git a/public/third-party/gemini-dark.svg b/public/third-party/gemini-dark.svg new file mode 100644 index 000000000..03c310a83 --- /dev/null +++ b/public/third-party/gemini-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/third-party/gemini-light.svg b/public/third-party/gemini-light.svg new file mode 100644 index 000000000..46647c7ff --- /dev/null +++ b/public/third-party/gemini-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/ai-client-config/AiClientConfigSection.tsx b/src/components/ai-client-config/AiClientConfigSection.tsx index 955dfb4a9..d4f97e5f9 100644 --- a/src/components/ai-client-config/AiClientConfigSection.tsx +++ b/src/components/ai-client-config/AiClientConfigSection.tsx @@ -49,6 +49,10 @@ const CLIENT_LOGOS = { opencode: { light: "/third-party/opencode-dark.svg", dark: "/third-party/opencode-light.svg" + }, + gemini: { + light: "/third-party/gemini-dark.svg", + dark: "/third-party/gemini-light.svg" } } as const; @@ -65,7 +69,8 @@ export function AiClientConfigSection({ const descriptions: Record = { claude: t("aiClientConfigDescriptionClaude"), codex: t("aiClientConfigDescriptionCodex"), - opencode: t("aiClientConfigDescriptionOpencode") + opencode: t("aiClientConfigDescriptionOpencode"), + gemini: t("aiClientConfigDescriptionGemini") }; return ( diff --git a/src/lib/aiClientConfig.ts b/src/lib/aiClientConfig.ts index 37fe7cd16..1950c4e6c 100644 --- a/src/lib/aiClientConfig.ts +++ b/src/lib/aiClientConfig.ts @@ -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 const AI_CLIENT_NAMES: Record = { claude: "Claude Code", codex: "Codex", - opencode: "OpenCode" + opencode: "OpenCode", + gemini: "Gemini CLI" }; /** Auth as supplied by callers: the real key isn't fetched yet. */ @@ -74,7 +80,7 @@ export function aiConfigBlockHasPlaceholders(block: AiConfigBlock): boolean { } function buildCli( - clientArg: "claude" | "codex" | "opencode", + clientArg: "claude" | "codex" | "opencode" | "gemini", auth: AiClientAuth, resourceNiceId?: string ): AiConfigBlock[] { @@ -351,6 +357,49 @@ function buildOpencodeGuide( }; } +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=${auth.mode === "keyed" ? key : "none"}` + ].join("\n"), + auth + ); + + const defaultShell = block( + "gemini-default-shell", + "Shell", + (key) => + [ + `export GOOGLE_GEMINI_BASE_URL=${endpoint}`, + `export GEMINI_API_KEY=${auth.mode === "keyed" ? key : "none"}`, + "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] + } + ] + }; +} + const GUIDE_BUILDERS: Record< AiClientId, ( @@ -361,7 +410,8 @@ const GUIDE_BUILDERS: Record< > = { claude: buildClaudeGuide, codex: buildCodexGuide, - opencode: buildOpencodeGuide + opencode: buildOpencodeGuide, + gemini: buildGeminiGuide }; export function buildAiClientGuide( From df7e26a44407504aba75b14d6b4ce21b20e27ad0 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 20 Aug 2026 11:38:47 -0400 Subject: [PATCH 03/42] Show required key when nessicary for private resources --- src/lib/aiClientConfig.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/lib/aiClientConfig.ts b/src/lib/aiClientConfig.ts index 1950c4e6c..500e38a37 100644 --- a/src/lib/aiClientConfig.ts +++ b/src/lib/aiClientConfig.ts @@ -49,8 +49,15 @@ export type AiClientGuide = { 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 { - return auth.mode === "keyed" ? auth.key : "-"; + return auth.mode === "keyed" ? auth.key : KEYLESS_PLACEHOLDER_KEY; } function block( @@ -132,7 +139,7 @@ function buildClaudeGuide( (key) => [ `export ANTHROPIC_BASE_URL=${endpoint}`, - `export ANTHROPIC_API_KEY=${auth.mode === "keyed" ? key : "none"}`, + `export ANTHROPIC_API_KEY=${key}`, "claude" ].join("\n"), auth @@ -334,7 +341,7 @@ function buildOpencodeGuide( "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.', + '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, "steps" ); @@ -348,10 +355,10 @@ function buildOpencodeGuide( id: "default", label: "Default", relation: "steps", - blocks: - auth.mode === "keyed" - ? [config, authFile, moreProviders] - : [config, moreProviders] + // auth.json is written even for keyless resources: OpenCode + // refuses to start a provider with no key at all ("OpenAI API + // key is missing"), so it gets the inert placeholder instead. + blocks: [config, authFile, moreProviders] } ] }; @@ -368,7 +375,7 @@ function buildGeminiGuide( (key) => [ `GOOGLE_GEMINI_BASE_URL=${endpoint}`, - `GEMINI_API_KEY=${auth.mode === "keyed" ? key : "none"}` + `GEMINI_API_KEY=${key}` ].join("\n"), auth ); @@ -379,7 +386,7 @@ function buildGeminiGuide( (key) => [ `export GOOGLE_GEMINI_BASE_URL=${endpoint}`, - `export GEMINI_API_KEY=${auth.mode === "keyed" ? key : "none"}`, + `export GEMINI_API_KEY=${key}`, "gemini" ].join("\n"), auth From bafbf6e096d218cfa7ea72b33be489ab26c9a364 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 20 Aug 2026 14:28:19 -0400 Subject: [PATCH 04/42] Add anthropic_models capability --- docs/ai-gateway-provider-selection.md | 46 ++- messages/en-US.json | 2 + server/lib/aiCapabilities.ts | 17 +- server/lib/aiMessageNormalization.ts | 3 + server/lib/aiModelDiscovery.ts | 175 +++++++++++ server/lib/aiUsageExtraction.ts | 2 + server/routers/aiGateway/anthropicModels.ts | 293 ++++++++++++++++++ .../aiGateway/createAiGatewayRouter.ts | 26 +- server/routers/aiGateway/index.ts | 1 + server/routers/aiGateway/pipeline.ts | 25 +- src/app/[orgId]/settings/logs/ai/page.tsx | 1 + .../AiProviderCapabilitiesSelect.tsx | 1 + src/lib/aiCapabilities.ts | 1 + src/lib/aiProviderDefaults.ts | 9 +- 14 files changed, 584 insertions(+), 18 deletions(-) create mode 100644 server/lib/aiModelDiscovery.ts create mode 100644 server/routers/aiGateway/anthropicModels.ts diff --git a/docs/ai-gateway-provider-selection.md b/docs/ai-gateway-provider-selection.md index f955e25aa..d35e8afe7 100644 --- a/docs/ai-gateway-provider-selection.md +++ b/docs/ai-gateway-provider-selection.md @@ -7,6 +7,8 @@ inference resource has more than one AI provider. - Route → capability binding: `server/routers/aiGateway/createAiGatewayRouter.ts` - Request pipeline: `server/routers/aiGateway/pipeline.ts` (`selectProvider`) +- Model discovery: `server/routers/aiGateway/anthropicModels.ts` and + `server/lib/aiModelDiscovery.ts` - Tie-break scoring: `server/lib/aiProviderSelection.ts` - Allow/block matching: `server/lib/aiModelKeyMatch.ts` - Model catalog: `server/lib/aiModelCatalog.ts` @@ -39,6 +41,7 @@ The incoming path selects a capability before any provider logic runs. | `POST /v1/chat/completions` | `openai_chat` | | `POST /v1/responses` | `openai_responses` | | `POST /v1/messages` | `anthropic_messages` | +| `GET /v1/models`, `GET /v1/models/{id}` | `anthropic_models` | | Gemini / Vertex / Bedrock routes | their respective capability ids | Only attached providers that advertise that capability stay in the candidate @@ -47,10 +50,10 @@ set. Default capabilities do not overlap for native OpenAI vs Anthropic: | Provider type | Default capabilities | |---------------|----------------------| | `openai` | `openai_chat`, `openai_responses` | -| `anthropic` | `anthropic_messages` | +| `anthropic` | `anthropic_messages`, `anthropic_models` | | `openRouter` | `openai_chat` | | `vercelAiGateway` | `openai_chat`, `openai_responses` | -| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages` | +| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages`, `anthropic_models` | | `custom` | whatever was configured | ### 2. Allow / Block Lists @@ -128,6 +131,45 @@ Model "" is ambiguous across multiple AI providers on this resource Typical remaining ties: two OpenAI-type providers both with `*`, or two customs advertising the same capability for an unknown model. +## Model Discovery Is Not Selection + +`GET /v1/models` and `GET /v1/models/{id}` (`anthropic_models`) skip steps 3-6 +entirely. There is no requested model to disambiguate on, so the gateway does +not pick one provider - it returns the **union** of what every attached +provider advertising `anthropic_models` would accept, deduplicated by model id +(lowest `providerId` wins a collision). + +Discovery is answered from the gateway's own view of the allow/block lists, +never proxied upstream. Providers that expose no `/v1/models` endpoint of their +own still get a working listing, and a model an allow/block list forbids is +never advertised. + +Each provider's candidate ids come from two places: + +| Source | Contributes | +|--------|-------------| +| Exact (non-wildcard) allow entries | the model key itself | +| The model catalog for the provider's type | every catalog id matching an allow pattern | + +Both sources are then filtered through the same +`isAllowedByLists(id, allows, blocks)` check step 2 applies, so a block pattern +hides a model from discovery exactly as it would reject it at request time. + +The catalog source is what makes a wildcard allow such as `claude-*` +enumerable. Provider types with no catalog mapping (`openRouter`, +`vercelAiGateway`, `custom`) have nothing to expand against, so a wildcard +allow on those types lists nothing - **add exact allow entries to make their +models discoverable.** + +Fields the API declares nullable and an allow/block list cannot supply +(`max_input_tokens`, `max_tokens`, `capabilities`) are returned as `null`. +`display_name` and `created_at` come from the configured model row when the id +matches one; otherwise the id doubles as the display name and `created_at` is +the epoch, which the Models API permits when the release date is unknown. +Results are ordered newest-first with the id as tie-break, and paginated with +Anthropic's `limit` / `after_id` / `before_id` semantics (default 20, max +1000). + ## Examples Assume each provider below is attached and enabled on the same inference diff --git a/messages/en-US.json b/messages/en-US.json index 46f255c39..8952bbcbc 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1923,6 +1923,8 @@ "aiCapabilityOpenaiResponsesDescription": "Supports /v1/responses", "aiCapabilityAnthropicMessages": "Anthropic Messages", "aiCapabilityAnthropicMessagesDescription": "Supports /v1/messages", + "aiCapabilityAnthropicModels": "Anthropic Models", + "aiCapabilityAnthropicModelsDescription": "Supports /v1/models model discovery", "aiCapabilityGeminiGenerateContent": "Gemini Generate Content", "aiCapabilityGeminiGenerateContentDescription": "Supports the direct Gemini API", "aiCapabilityBedrockModelInvoke": "Bedrock Model Invoke", diff --git a/server/lib/aiCapabilities.ts b/server/lib/aiCapabilities.ts index b43cf3c1f..15e2595e2 100644 --- a/server/lib/aiCapabilities.ts +++ b/server/lib/aiCapabilities.ts @@ -4,7 +4,7 @@ import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities"; export { AI_CAPABILITIES, type AiCapability }; export type AiCapabilityRoute = { - method: "POST"; + method: "GET" | "POST"; path: string; }; @@ -135,6 +135,21 @@ export const AI_CAPABILITY_DEFS: Record = joinUpstreamUrl(base, pathFromRequest(req)), isStreaming: isBodyOrSseStreaming }, + anthropic_models: { + id: "anthropic_models", + protocolFamily: "anthropic", + routes: [ + { method: "GET", path: "/v1/models" }, + { method: "GET", path: "/v1/models/:model" } + ], + extractModel: paramModel, + resolveUpstreamUrl: (base, req) => + joinUpstreamUrl(base, pathFromRequest(req)), + // Model listings are answered from the gateway's own view of the + // provider allow/block lists rather than proxied upstream, so + // there is never a stream to detect. + isStreaming: () => false + }, gemini_generate_content: { id: "gemini_generate_content", protocolFamily: "google", diff --git a/server/lib/aiMessageNormalization.ts b/server/lib/aiMessageNormalization.ts index 1153142e1..4dd8cef8c 100644 --- a/server/lib/aiMessageNormalization.ts +++ b/server/lib/aiMessageNormalization.ts @@ -471,6 +471,8 @@ const REQUEST_NORMALIZERS: Record< openai_chat: normalizeOpenAiChatRequest, openai_responses: normalizeOpenAiResponsesRequest, anthropic_messages: normalizeAnthropicRequest, + // Model discovery carries no transcript to normalize. + anthropic_models: () => null, gemini_generate_content: normalizeGeminiRequest, google_generate_content: normalizeGeminiRequest, google_raw_predict: normalizeBestEffortRequest, @@ -485,6 +487,7 @@ const RESPONSE_NORMALIZERS: Record< openai_chat: normalizeOpenAiChatResponse, openai_responses: normalizeOpenAiResponsesResponse, anthropic_messages: normalizeAnthropicResponse, + anthropic_models: () => null, gemini_generate_content: normalizeGeminiResponse, google_generate_content: normalizeGeminiResponse, google_raw_predict: normalizeGoogleRawPredictResponse, diff --git a/server/lib/aiModelDiscovery.ts b/server/lib/aiModelDiscovery.ts new file mode 100644 index 000000000..873abdb98 --- /dev/null +++ b/server/lib/aiModelDiscovery.ts @@ -0,0 +1,175 @@ +import { + isAllowedByLists, + isModelKeyPattern +} from "@server/lib/aiModelKeyMatch"; + +// Anthropic's Models API pagination: 20 per page by default, 1..1000. +export const MODEL_PAGE_DEFAULT_LIMIT = 20; +export const MODEL_PAGE_MAX_LIMIT = 1000; + +// Release dates aren't something we can know for a wildcard allow pattern or a +// catalog entry. The Models API explicitly permits an epoch value when the +// release date is unknown. +const UNKNOWN_CREATED_AT = new Date(0).toISOString(); + +/** + * One entry of Anthropic's `GET /v1/models` response. Only the identity fields + * can be filled in from a provider's model lists - token limits and + * per-model capability flags aren't derivable from an allow/block list, and the + * API schema declares all three nullable. + */ +export type AnthropicModelInfo = { + type: "model"; + id: string; + display_name: string; + created_at: string; + max_input_tokens: number | null; + max_tokens: number | null; + capabilities: null; +}; + +/** A model row an administrator configured explicitly on a provider. */ +export type ConfiguredModel = { name: string; createdAt: number }; + +/** + * One attached provider's contribution to a resource's model listing, with the + * allow/block lists already resolved for the attachment's access mode. + */ +export type ModelDiscoveryProvider = { + providerId: number; + allows: string[]; + blocks: string[]; + /** + * Concrete model ids the provider's type is known to serve. This is what + * lets a wildcard allow such as `claude-*` enumerate into real ids; + * provider types with no catalog (aggregators, custom) pass an empty list + * and surface only their exact allow entries. + */ + catalogModelIds: string[]; + /** Keyed by model key, for display names and creation times. */ + configured: Map; +}; + +export type ModelPage = { + data: AnthropicModelInfo[]; + has_more: boolean; +}; + +/** + * Expands one provider's effective allow/block lists into concrete model ids. + * Two sources feed the candidate set: exact (non-wildcard) allow entries, which + * are already concrete ids, and the catalog for the provider's type, which is + * what makes wildcard allows enumerable. Every candidate is then run back + * through the same allow/block check the inference pipeline applies, so a block + * pattern hides a model here exactly as it would reject it at request time. + */ +export function expandProviderModels( + provider: ModelDiscoveryProvider +): AnthropicModelInfo[] { + const candidates = new Set(); + + for (const allow of provider.allows) { + if (!isModelKeyPattern(allow)) { + candidates.add(allow); + } + } + for (const modelId of provider.catalogModelIds) { + candidates.add(modelId); + } + + const models: AnthropicModelInfo[] = []; + for (const modelKey of candidates) { + if (!isAllowedByLists(modelKey, provider.allows, provider.blocks)) { + continue; + } + const configured = provider.configured.get(modelKey); + models.push({ + type: "model", + id: modelKey, + display_name: configured?.name || modelKey, + created_at: configured + ? new Date(configured.createdAt).toISOString() + : UNKNOWN_CREATED_AT, + max_input_tokens: null, + max_tokens: null, + capabilities: null + }); + } + + return models; +} + +/** + * Aggregates the permitted models across every provider attached to a + * resource. Unlike an inference request there is no requested model to + * disambiguate on, so no provider selection happens - the listing is the union + * of what each provider would accept, deduplicated by model id. + */ +export function listPermittedModels( + providers: ModelDiscoveryProvider[] +): AnthropicModelInfo[] { + const byModelId = new Map(); + + // Sorted so a model offered by two providers always resolves to the same + // entry, which keeps the cursor ordering stable across requests. + const ordered = [...providers].sort((a, b) => a.providerId - b.providerId); + + for (const provider of ordered) { + for (const model of expandProviderModels(provider)) { + if (!byModelId.has(model.id)) { + byModelId.set(model.id, model); + } + } + } + + // "More recently released models are listed first" per the Models API, + // with the id as a tie-break so the ordering is total - cursor pagination + // needs it to be stable between calls. + return [...byModelId.values()].sort((a, b) => { + const byCreated = b.created_at.localeCompare(a.created_at); + return byCreated !== 0 ? byCreated : a.id.localeCompare(b.id); + }); +} + +/** + * Applies Anthropic's cursor pagination to an ordered model list. `after_id` + * returns the page immediately after that model, `before_id` the page + * immediately before it. Returns an error message for a caller mistake + * (both cursors, or a cursor naming a model that isn't in the list). + */ +export function paginateModels( + models: AnthropicModelInfo[], + limit: number, + cursor: { afterId?: string; beforeId?: string } +): ModelPage | { error: string } { + if (cursor.afterId && cursor.beforeId) { + return { error: "Only one of after_id and before_id may be provided" }; + } + + const cursorId = cursor.afterId ?? cursor.beforeId; + if (!cursorId) { + return { + data: models.slice(0, limit), + has_more: models.length > limit + }; + } + + const index = models.findIndex((model) => model.id === cursorId); + if (index === -1) { + return { error: `Unknown cursor id "${cursorId}"` }; + } + + if (cursor.afterId) { + const start = index + 1; + return { + data: models.slice(start, start + limit), + has_more: models.length > start + limit + }; + } + + const start = Math.max(0, index - limit); + return { + data: models.slice(start, index), + has_more: start > 0 + }; +} diff --git a/server/lib/aiUsageExtraction.ts b/server/lib/aiUsageExtraction.ts index fcec807e1..55c562e3b 100644 --- a/server/lib/aiUsageExtraction.ts +++ b/server/lib/aiUsageExtraction.ts @@ -335,6 +335,8 @@ const EXTRACTORS: Record< openai_chat: extractOpenAiChat, openai_responses: extractOpenAiResponses, anthropic_messages: extractAnthropicMessages, + // Model discovery never runs a model, so there are no tokens to bill. + anthropic_models: () => null, gemini_generate_content: extractGoogleGenerateContent, google_generate_content: extractGoogleGenerateContent, // rawPredict is a passthrough to whatever the underlying publisher diff --git a/server/routers/aiGateway/anthropicModels.ts b/server/routers/aiGateway/anthropicModels.ts new file mode 100644 index 000000000..b89868510 --- /dev/null +++ b/server/routers/aiGateway/anthropicModels.ts @@ -0,0 +1,293 @@ +import { Request, Response } from "express"; +import { inArray } from "drizzle-orm"; +import { z } from "zod"; +import { aiModels, db } from "@server/db"; +import { + providerHasCapability, + type AiCapability +} from "@server/lib/aiCapabilities"; +import { + buildAiCapabilityErrorBody, + type AiCapabilityErrorKind +} from "@server/lib/aiGatewayAuthError"; +import { + getAiGatewayResourceType, + isAiGatewayTrustHeaderValid +} from "@server/lib/aiGatewayTrust"; +import { resolveEffectiveLists } from "@server/lib/aiInferenceResource"; +import { listCatalogModelsForType } from "@server/lib/aiModelCatalog"; +import { + listPermittedModels, + paginateModels, + MODEL_PAGE_DEFAULT_LIMIT, + MODEL_PAGE_MAX_LIMIT, + type ConfiguredModel, + type ModelDiscoveryProvider +} from "@server/lib/aiModelDiscovery"; +import type { AiProviderType } from "@server/lib/aiProviderDefaults"; +import { + resolveGatewayHost, + resolveTarget, + type ProviderAttachment, + type ProviderPatternLists +} from "@server/routers/aiGateway/pipeline"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; + +const CAPABILITY: AiCapability = "anthropic_models"; + +const querySchema = z.object({ + limit: z.coerce.number().int().min(1).max(MODEL_PAGE_MAX_LIMIT).optional(), + after_id: z.string().min(1).optional(), + before_id: z.string().min(1).optional() +}); + +type ProviderModelLists = { + allowsByProvider: Map; + blocksByProvider: Map; + configuredByProvider: Map>; +}; + +function errorResponse( + res: Response, + status: number, + kind: AiCapabilityErrorKind, + message: string +) { + return res + .status(status) + .json(buildAiCapabilityErrorBody(CAPABILITY, kind, message, status)); +} + +// Provider-level allow/block lists, plus the display name and creation time of +// every catalog row, so explicitly configured models are reported with the name +// the administrator gave them rather than a bare model id. +async function loadProviderModelLists( + providerIds: number[] +): Promise { + const lists: ProviderModelLists = { + allowsByProvider: new Map(), + blocksByProvider: new Map(), + configuredByProvider: new Map() + }; + + if (providerIds.length === 0) { + return lists; + } + + const rows = await db + .select({ + providerId: aiModels.providerId, + modelKey: aiModels.modelKey, + name: aiModels.name, + listType: aiModels.listType, + enabled: aiModels.enabled, + createdAt: aiModels.createdAt + }) + .from(aiModels) + .where(inArray(aiModels.providerId, providerIds)); + + for (const row of rows) { + if (!row.enabled) { + continue; + } + const targetMap = + row.listType === "allow" + ? lists.allowsByProvider + : lists.blocksByProvider; + const existing = targetMap.get(row.providerId) ?? []; + existing.push(row.modelKey); + targetMap.set(row.providerId, existing); + + let configured = lists.configuredByProvider.get(row.providerId); + if (!configured) { + configured = new Map(); + lists.configuredByProvider.set(row.providerId, configured); + } + configured.set(row.modelKey, { + name: row.name, + createdAt: row.createdAt + }); + } + + return lists; +} + +function buildDiscoveryProviders( + attachments: ProviderAttachment[], + resourceListsByProvider: Map, + lists: ProviderModelLists +): ModelDiscoveryProvider[] { + return attachments.map((attachment) => { + const providerId = attachment.provider.providerId; + const resourceLists = resourceListsByProvider.get(providerId); + const { allows, blocks } = resolveEffectiveLists({ + accessMode: attachment.accessMode, + providerAllows: lists.allowsByProvider.get(providerId) ?? [], + providerBlocks: lists.blocksByProvider.get(providerId) ?? [], + resourceAllows: resourceLists?.allows ?? [], + resourceBlocks: resourceLists?.blocks ?? [] + }); + + return { + providerId, + allows, + blocks, + catalogModelIds: listCatalogModelsForType( + attachment.provider.type as AiProviderType + ).map((entry) => entry.model), + configured: lists.configuredByProvider.get(providerId) ?? new Map() + }; + }); +} + +/** + * Serves Anthropic's model-discovery endpoints (`GET /v1/models` and + * `GET /v1/models/{id}`) for an inference resource. The gateway answers these + * itself rather than proxying: upstream providers either don't expose a model + * list at all or would expose models the resource's allow/block lists forbid, + * so the response is built from the same effective lists that gate inference. + */ +export async function handleAnthropicModels( + req: Request, + res: Response +): Promise { + try { + const host = resolveGatewayHost(req); + if (!host) { + return errorResponse( + res, + HttpCode.BAD_REQUEST, + "invalid_request", + "Missing Host header" + ); + } + + const resourceType = getAiGatewayResourceType( + req.headers as Record + ); + const target = await resolveTarget(host, resourceType); + if (!target) { + return errorResponse( + res, + HttpCode.NOT_FOUND, + "not_found", + "No inference resource found for this host" + ); + } + + // Same gate as the inference pipeline: public inference must pass + // Badger verify-session first, which is what stamps the trust header. + if ( + target.resourceId != null && + !isAiGatewayTrustHeaderValid(req.headers as Record) + ) { + return errorResponse( + res, + HttpCode.UNAUTHORIZED, + "authentication", + "Request must be authenticated via the inference resource" + ); + } + + if (target.attachments.length === 0) { + return errorResponse( + res, + HttpCode.FORBIDDEN, + "permission", + "No AI providers configured for this resource" + ); + } + + const capableAttachments = target.attachments.filter((a) => + providerHasCapability(a.provider.capabilities, CAPABILITY) + ); + if (capableAttachments.length === 0) { + return errorResponse( + res, + HttpCode.FORBIDDEN, + "permission", + `No AI provider on this resource supports ${CAPABILITY}` + ); + } + + const lists = await loadProviderModelLists( + capableAttachments.map((a) => a.provider.providerId) + ); + const models = listPermittedModels( + buildDiscoveryProviders( + capableAttachments, + target.resourceListsByProvider, + lists + ) + ); + + // `GET /v1/models/{id}` - a single model, 404 when this resource + // doesn't permit it. + const requestedModel = req.params?.model; + if (typeof requestedModel === "string" && requestedModel.length > 0) { + const model = models.find((m) => m.id === requestedModel); + if (!model) { + return errorResponse( + res, + HttpCode.NOT_FOUND, + "not_found", + `Model "${requestedModel}" is not available on this resource` + ); + } + return res.status(HttpCode.OK).json(model); + } + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return errorResponse( + res, + HttpCode.BAD_REQUEST, + "invalid_request", + parsedQuery.error.issues[0]?.message ?? + "Invalid pagination parameters" + ); + } + + const page = paginateModels( + models, + parsedQuery.data.limit ?? MODEL_PAGE_DEFAULT_LIMIT, + { + afterId: parsedQuery.data.after_id, + beforeId: parsedQuery.data.before_id + } + ); + if ("error" in page) { + return errorResponse( + res, + HttpCode.BAD_REQUEST, + "invalid_request", + page.error + ); + } + + logger.debug("AI gateway model discovery", { + host, + resourceId: target.resourceId, + siteResourceId: target.siteResourceId, + providers: capableAttachments.length, + total: models.length, + returned: page.data.length + }); + + return res.status(HttpCode.OK).json({ + data: page.data, + has_more: page.has_more, + first_id: page.data[0]?.id ?? null, + last_id: page.data[page.data.length - 1]?.id ?? null + }); + } catch (error) { + logger.error(error); + return errorResponse( + res, + HttpCode.INTERNAL_SERVER_ERROR, + "internal", + "Failed to list models" + ); + } +} diff --git a/server/routers/aiGateway/createAiGatewayRouter.ts b/server/routers/aiGateway/createAiGatewayRouter.ts index cc62ade28..3e30dd6fb 100644 --- a/server/routers/aiGateway/createAiGatewayRouter.ts +++ b/server/routers/aiGateway/createAiGatewayRouter.ts @@ -1,19 +1,37 @@ -import { Router } from "express"; +import { Router, type Request, type Response } from "express"; import { AI_CAPABILITY_DEFS, type AiCapability } from "@server/lib/aiCapabilities"; import { handleAiGatewayProxy } from "@server/routers/aiGateway/pipeline"; +import { handleAnthropicModels } from "@server/routers/aiGateway/anthropicModels"; + +type CapabilityHandler = ( + req: Request, + res: Response, + capability: AiCapability +) => Promise; + +// Capabilities the gateway answers itself instead of proxying upstream. +// Everything else goes through the inference pipeline. +const LOCAL_HANDLERS: Partial> = { + anthropic_models: handleAnthropicModels +}; export function createAiGatewayRouter() { const router = Router(); for (const def of Object.values(AI_CAPABILITY_DEFS)) { const capability = def.id as AiCapability; + const handler = LOCAL_HANDLERS[capability] ?? handleAiGatewayProxy; for (const route of def.routes) { - router.post(route.path, (req, res) => - handleAiGatewayProxy(req, res, capability) - ); + const bind = (req: Request, res: Response) => + handler(req, res, capability); + if (route.method === "GET") { + router.get(route.path, bind); + } else { + router.post(route.path, bind); + } } } diff --git a/server/routers/aiGateway/index.ts b/server/routers/aiGateway/index.ts index 6eea36d60..a10a7aed4 100644 --- a/server/routers/aiGateway/index.ts +++ b/server/routers/aiGateway/index.ts @@ -1,2 +1,3 @@ export { handleAiGatewayProxy } from "./pipeline"; +export { handleAnthropicModels } from "./anthropicModels"; export { createAiGatewayRouter } from "./createAiGatewayRouter"; diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 76889cdc8..6375aa704 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -137,7 +137,7 @@ async function findClientByIp(ip: string): Promise { return result; } -type ProviderAttachment = { +export type ProviderAttachment = { provider: AiProvider; accessMode: AccessMode; }; @@ -149,12 +149,12 @@ type ResourceModelPattern = { enabled: boolean; }; -type ProviderPatternLists = { +export type ProviderPatternLists = { allows: string[]; blocks: string[]; }; -type ResolvedTarget = { +export type ResolvedTarget = { resourceId: number | null; siteResourceId: number | null; orgId: string | null; @@ -362,7 +362,7 @@ function getRequestHeader(req: Request, name: string): string | undefined { // request came through, per the trust middleware's resource-type header - // falls back to checking both (public preferred on overlap) only when that // header is absent, e.g. a request that reached the gateway outside Traefik. -async function resolveTarget( +export async function resolveTarget( host: string, resourceType: AiGatewayResourceType | null ): Promise { @@ -812,6 +812,17 @@ export function recordAiGatewayCompletion(args: { }); } +// p-host is only used sometimes when overriding the host header for some +// middleware proxy. Shared with the model-discovery endpoint so both resolve +// the inference resource off the same hostname. +export function resolveGatewayHost(req: Request): string { + return ( + (req.headers["p-host"] as string | undefined) || + req.headers.host || + "" + ).split(":")[0]; +} + export async function handleAiGatewayProxy( req: Request, res: Response, @@ -820,11 +831,7 @@ export async function handleAiGatewayProxy( try { const def = AI_CAPABILITY_DEFS[capability]; - const host = ( - (req.headers["p-host"] as string | undefined) || // p-host is only used sometimes when overriding the host header for some middleware proxy - req.headers.host || - "" - ).split(":")[0]; + const host = resolveGatewayHost(req); if (!host) { return res .status(HttpCode.BAD_REQUEST) diff --git a/src/app/[orgId]/settings/logs/ai/page.tsx b/src/app/[orgId]/settings/logs/ai/page.tsx index 136d186c3..be043dd30 100644 --- a/src/app/[orgId]/settings/logs/ai/page.tsx +++ b/src/app/[orgId]/settings/logs/ai/page.tsx @@ -33,6 +33,7 @@ const capabilityLabels: Record = { openai_chat: "OpenAI Chat Completions", openai_responses: "OpenAI Responses", anthropic_messages: "Anthropic Messages", + anthropic_models: "Anthropic Models", gemini_generate_content: "Gemini", google_generate_content: "Vertex AI (Generate Content)", google_raw_predict: "Vertex AI (Raw Predict)", diff --git a/src/components/AiProviderCapabilitiesSelect.tsx b/src/components/AiProviderCapabilitiesSelect.tsx index ecb636472..7383998b4 100644 --- a/src/components/AiProviderCapabilitiesSelect.tsx +++ b/src/components/AiProviderCapabilitiesSelect.tsx @@ -20,6 +20,7 @@ const CAPABILITY_LABEL_KEYS: Record = { openai_chat: "aiCapabilityOpenaiChat", openai_responses: "aiCapabilityOpenaiResponses", anthropic_messages: "aiCapabilityAnthropicMessages", + anthropic_models: "aiCapabilityAnthropicModels", gemini_generate_content: "aiCapabilityGeminiGenerateContent", bedrock_model_invoke: "aiCapabilityBedrockModelInvoke", google_generate_content: "aiCapabilityGoogleGenerateContent", diff --git a/src/lib/aiCapabilities.ts b/src/lib/aiCapabilities.ts index faa1f7af6..885cf4a9f 100644 --- a/src/lib/aiCapabilities.ts +++ b/src/lib/aiCapabilities.ts @@ -2,6 +2,7 @@ export const AI_CAPABILITIES = [ "openai_chat", "openai_responses", "anthropic_messages", + "anthropic_models", "gemini_generate_content", "bedrock_model_invoke", "google_generate_content", diff --git a/src/lib/aiProviderDefaults.ts b/src/lib/aiProviderDefaults.ts index 31499a19d..f30f6dbd1 100644 --- a/src/lib/aiProviderDefaults.ts +++ b/src/lib/aiProviderDefaults.ts @@ -43,7 +43,7 @@ export const AI_PROVIDER_DEFAULTS: Record< anthropic: { upstreamUrl: "https://api.anthropic.com", authType: "x-api-key", - capabilities: ["anthropic_messages"] + capabilities: ["anthropic_messages", "anthropic_models"] }, googleGemini: { upstreamUrl: "https://generativelanguage.googleapis.com", @@ -63,7 +63,12 @@ export const AI_PROVIDER_DEFAULTS: Record< microsoftFoundry: { upstreamUrl: null, authType: "bearer", - capabilities: ["openai_chat", "openai_responses", "anthropic_messages"] + capabilities: [ + "openai_chat", + "openai_responses", + "anthropic_messages", + "anthropic_models" + ] }, openRouter: { upstreamUrl: "https://openrouter.ai/api/v1", From 365a905e697971476ac96511bc6d7bc02ddbadee Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 20 Aug 2026 15:17:48 -0400 Subject: [PATCH 05/42] Add more data to the models catalog list --- docs/ai-gateway-provider-selection.md | 33 +++++- server/lib/aiModelCatalog.ts | 112 ++++++++++++++---- server/lib/aiModelDiscovery.ts | 80 +++++++++++-- .../private/routers/aiGateway/logAiSession.ts | 2 +- server/routers/aiGateway/anthropicModels.ts | 21 +++- 5 files changed, 205 insertions(+), 43 deletions(-) diff --git a/docs/ai-gateway-provider-selection.md b/docs/ai-gateway-provider-selection.md index d35e8afe7..af67fd4f6 100644 --- a/docs/ai-gateway-provider-selection.md +++ b/docs/ai-gateway-provider-selection.md @@ -161,11 +161,34 @@ enumerable. Provider types with no catalog mapping (`openRouter`, allow on those types lists nothing - **add exact allow entries to make their models discoverable.** -Fields the API declares nullable and an allow/block list cannot supply -(`max_input_tokens`, `max_tokens`, `capabilities`) are returned as `null`. -`display_name` and `created_at` come from the configured model row when the id -matches one; otherwise the id doubles as the display name and `created_at` is -the epoch, which the Models API permits when the release date is unknown. +### Where each field comes from + +Token limits and capability flags can't be derived from an allow/block list. +They come from the model catalog (`server/lib/aiModelCatalog.ts`), which the +Fossorial API builds from LiteLLM: + +| Field | Source | +|-------|--------| +| `max_input_tokens` | catalog `limits.input` | +| `max_tokens` | catalog `limits.output` | +| `capabilities` | catalog flags, mapped to the Models API shape by `capabilitiesFromCatalog` | +| `display_name` | the configured model row's name, else the model id | +| `created_at` | the configured model row's timestamp, else the epoch | + +A model the catalog doesn't know (an exact allow entry for a fine-tune, say) +reports `null` for all three metadata fields. The Models API declares them +nullable, so that is a valid answer rather than a broken one. + +The catalog's flags are coarser than the Models API describes: it carries a +single `reasoning` flag with no way to distinguish adaptive from +`budget_tokens`-style thinking, and nothing at all for batch, citations, code +execution, PDF input, or context management. Anything it reports as unknown +(`null`) is surfaced as unsupported rather than invented, so `capabilities` +understates rather than overstates what a model can do. + +The gateway does **not** query the provider's own `/v1/models`. Discovery is +answered entirely from local state. + Results are ordered newest-first with the id as tie-break, and paginated with Anthropic's `limit` / `after_id` / `before_id` semantics (default 20, max 1000). diff --git a/server/lib/aiModelCatalog.ts b/server/lib/aiModelCatalog.ts index 5f2f7fb94..db5c0b692 100644 --- a/server/lib/aiModelCatalog.ts +++ b/server/lib/aiModelCatalog.ts @@ -44,6 +44,20 @@ export function getCatalogProviderForType( return PROVIDER_CATALOG_MAP[type]; } +/** + * Per-model feature flags as reported upstream. `null` means the catalog has + * no data for that model - deliberately distinct from `false`, so consumers + * can tell "unsupported" apart from "unknown". + */ +export type AiModelCapabilityFlags = { + functionCalling: boolean | null; + vision: boolean | null; + promptCaching: boolean | null; + reasoning: boolean | null; + responseSchema: boolean | null; + webSearch: boolean | null; +}; + export type AiModelCatalogEntry = { provider: CatalogProvider; model: string; @@ -53,8 +67,20 @@ export type AiModelCatalogEntry = { cache: number | null; reasoning: number | null; }; + limits: { + /** Context window. */ + input: number | null; + /** Cap on the output/max_tokens request parameter. */ + output: number | null; + }; + capabilities: AiModelCapabilityFlags; }; +const flag = z.boolean().nullable().optional(); + +// limits/capabilities are optional so a catalog published before they were +// added (or an operator's own merge_file) still parses - those entries just +// report unknown metadata rather than failing the whole payload. const catalogEntrySchema = z.object({ model: z.string(), provider: z.string(), @@ -65,6 +91,22 @@ const catalogEntrySchema = z.object({ cache: z.number().nullable().optional(), reasoning: z.number().nullable().optional() }) + .optional(), + limits: z + .object({ + input: z.number().nullable().optional(), + output: z.number().nullable().optional() + }) + .optional(), + capabilities: z + .object({ + functionCalling: flag, + vision: flag, + promptCaching: flag, + reasoning: flag, + responseSchema: flag, + webSearch: flag + }) .optional() }); @@ -108,6 +150,18 @@ function normalizeEntry(raw: RawCatalogEntry): AiModelCatalogEntry | null { out: raw.pricing?.out ?? null, cache: raw.pricing?.cache ?? null, reasoning: raw.pricing?.reasoning ?? null + }, + limits: { + input: raw.limits?.input ?? null, + output: raw.limits?.output ?? null + }, + capabilities: { + functionCalling: raw.capabilities?.functionCalling ?? null, + vision: raw.capabilities?.vision ?? null, + promptCaching: raw.capabilities?.promptCaching ?? null, + reasoning: raw.capabilities?.reasoning ?? null, + responseSchema: raw.capabilities?.responseSchema ?? null, + webSearch: raw.capabilities?.webSearch ?? null } }; } @@ -284,34 +338,44 @@ export class AiModelCatalog { export const aiModelCatalog = new AiModelCatalog(); +/** + * Full catalog entries for a provider type, deduplicated by model id and + * sorted by id. Model discovery uses these to report real token limits and + * capability flags; `listCatalogModelsForType` is the id-only view of the + * same list. + */ +export function listCatalogEntriesForType( + type: AiProviderType, + query?: string +): AiModelCatalogEntry[] { + const catalogProvider = getCatalogProviderForType(type); + + let entries = catalogProvider ? aiModelCatalog.list(catalogProvider) : []; + + if (query) { + const q = query.toLowerCase(); + entries = entries.filter((e) => e.model.toLowerCase().includes(q)); + } + + const seen = new Set(); + entries = entries.filter((e) => { + if (seen.has(e.model)) { + return false; + } + seen.add(e.model); + return true; + }); + + return [...entries].sort((a, b) => a.model.localeCompare(b.model)); +} + export function listCatalogModelsForType( type: AiProviderType, query?: string ): { model: string }[] { - const catalogProvider = getCatalogProviderForType(type); - - let models = catalogProvider - ? aiModelCatalog.list(catalogProvider).map((entry) => ({ - model: entry.model - })) - : []; - - if (query) { - const q = query.toLowerCase(); - models = models.filter((m) => m.model.toLowerCase().includes(q)); - } - - const seen = new Set(); - models = models.filter((m) => { - if (seen.has(m.model)) { - return false; - } - seen.add(m.model); - return true; - }); - - models.sort((a, b) => a.model.localeCompare(b.model)); - return models; + return listCatalogEntriesForType(type, query).map((entry) => ({ + model: entry.model + })); } /** diff --git a/server/lib/aiModelDiscovery.ts b/server/lib/aiModelDiscovery.ts index 873abdb98..93ef28ea9 100644 --- a/server/lib/aiModelDiscovery.ts +++ b/server/lib/aiModelDiscovery.ts @@ -2,6 +2,7 @@ import { isAllowedByLists, isModelKeyPattern } from "@server/lib/aiModelKeyMatch"; +import type { AiModelCapabilityFlags } from "@server/lib/aiModelCatalog"; // Anthropic's Models API pagination: 20 per page by default, 1..1000. export const MODEL_PAGE_DEFAULT_LIMIT = 20; @@ -25,12 +26,66 @@ export type AnthropicModelInfo = { created_at: string; max_input_tokens: number | null; max_tokens: number | null; - capabilities: null; + capabilities: Record | null; }; /** A model row an administrator configured explicitly on a provider. */ export type ConfiguredModel = { name: string; createdAt: number }; +/** What the pricing catalog knows about a model beyond its id. */ +export type CatalogModelMetadata = { + maxInputTokens: number | null; + maxOutputTokens: number | null; + capabilities: AiModelCapabilityFlags; +}; + +/** + * Translates the catalog's flat feature flags into the nested shape + * Anthropic's Models API uses. Best-effort by nature: the catalog carries a + * coarser set of flags than the Models API describes, so anything it reports + * as unknown (`null`) is surfaced as unsupported rather than invented. + */ +export function capabilitiesFromCatalog( + flags: AiModelCapabilityFlags +): Record { + const supported = (value: boolean | null) => ({ + supported: value === true + }); + // The catalog has a single `reasoning` flag and no way to distinguish + // adaptive from budget_tokens-style thinking, so both variants follow it. + const reasoning = flags.reasoning === true; + + return { + batch: supported(null), + citations: supported(null), + code_execution: supported(null), + context_management: { + supported: false, + clear_thinking_20251015: null, + clear_tool_uses_20250919: null, + compact_20260112: null + }, + effort: { + supported: reasoning, + low: supported(flags.reasoning), + medium: supported(flags.reasoning), + high: supported(flags.reasoning), + max: supported(flags.reasoning), + xhigh: null + }, + image_input: supported(flags.vision), + pdf_input: supported(null), + structured_outputs: supported(flags.responseSchema), + thinking: { + supported: reasoning, + types: { + adaptive: { supported: reasoning }, + enabled: { supported: reasoning } + } + } + }; +} + /** * One attached provider's contribution to a resource's model listing, with the * allow/block lists already resolved for the attachment's access mode. @@ -40,12 +95,13 @@ export type ModelDiscoveryProvider = { allows: string[]; blocks: string[]; /** - * Concrete model ids the provider's type is known to serve. This is what - * lets a wildcard allow such as `claude-*` enumerate into real ids; - * provider types with no catalog (aggregators, custom) pass an empty list - * and surface only their exact allow entries. + * Concrete model ids the provider's type is known to serve, with whatever + * the catalog knows about each. This is what lets a wildcard allow such as + * `claude-*` enumerate into real ids; provider types with no catalog + * (aggregators, custom) pass an empty map and surface only their exact + * allow entries. */ - catalogModelIds: string[]; + catalog: Map; /** Keyed by model key, for display names and creation times. */ configured: Map; }; @@ -73,7 +129,7 @@ export function expandProviderModels( candidates.add(allow); } } - for (const modelId of provider.catalogModelIds) { + for (const modelId of provider.catalog.keys()) { candidates.add(modelId); } @@ -83,6 +139,8 @@ export function expandProviderModels( continue; } const configured = provider.configured.get(modelKey); + const catalog = provider.catalog.get(modelKey); + models.push({ type: "model", id: modelKey, @@ -90,9 +148,11 @@ export function expandProviderModels( created_at: configured ? new Date(configured.createdAt).toISOString() : UNKNOWN_CREATED_AT, - max_input_tokens: null, - max_tokens: null, - capabilities: null + max_input_tokens: catalog?.maxInputTokens ?? null, + max_tokens: catalog?.maxOutputTokens ?? null, + capabilities: catalog + ? capabilitiesFromCatalog(catalog.capabilities) + : null }); } diff --git a/server/private/routers/aiGateway/logAiSession.ts b/server/private/routers/aiGateway/logAiSession.ts index 527dc0b5b..47b0f039e 100644 --- a/server/private/routers/aiGateway/logAiSession.ts +++ b/server/private/routers/aiGateway/logAiSession.ts @@ -15,7 +15,7 @@ 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 cache from "#private/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; import { sanitizeString } from "@server/lib/sanitize"; import type { AiCapability } from "@server/lib/aiCapabilities"; diff --git a/server/routers/aiGateway/anthropicModels.ts b/server/routers/aiGateway/anthropicModels.ts index b89868510..9e15a78de 100644 --- a/server/routers/aiGateway/anthropicModels.ts +++ b/server/routers/aiGateway/anthropicModels.ts @@ -15,12 +15,13 @@ import { isAiGatewayTrustHeaderValid } from "@server/lib/aiGatewayTrust"; import { resolveEffectiveLists } from "@server/lib/aiInferenceResource"; -import { listCatalogModelsForType } from "@server/lib/aiModelCatalog"; +import { listCatalogEntriesForType } from "@server/lib/aiModelCatalog"; import { listPermittedModels, paginateModels, MODEL_PAGE_DEFAULT_LIMIT, MODEL_PAGE_MAX_LIMIT, + type CatalogModelMetadata, type ConfiguredModel, type ModelDiscoveryProvider } from "@server/lib/aiModelDiscovery"; @@ -113,6 +114,20 @@ async function loadProviderModelLists( return lists; } +function catalogMetadataForType( + type: AiProviderType +): Map { + const metadata = new Map(); + for (const entry of listCatalogEntriesForType(type)) { + metadata.set(entry.model, { + maxInputTokens: entry.limits.input, + maxOutputTokens: entry.limits.output, + capabilities: entry.capabilities + }); + } + return metadata; +} + function buildDiscoveryProviders( attachments: ProviderAttachment[], resourceListsByProvider: Map, @@ -133,9 +148,9 @@ function buildDiscoveryProviders( providerId, allows, blocks, - catalogModelIds: listCatalogModelsForType( + catalog: catalogMetadataForType( attachment.provider.type as AiProviderType - ).map((entry) => entry.model), + ), configured: lists.configuredByProvider.get(providerId) ?? new Map() }; }); From e65a79cc48e934438f81bff3afb708324cc8e1a3 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 20 Aug 2026 16:01:07 -0400 Subject: [PATCH 06/42] Rename to v1_models and use with openai as well --- docs/ai-gateway-provider-selection.md | 12 ++++++------ messages/en-US.json | 4 ++-- server/lib/aiCapabilities.ts | 4 ++-- server/lib/aiMessageNormalization.ts | 4 ++-- server/lib/aiUsageExtraction.ts | 2 +- server/routers/aiGateway/createAiGatewayRouter.ts | 4 ++-- server/routers/aiGateway/index.ts | 2 +- .../aiGateway/{anthropicModels.ts => v1Models.ts} | 4 ++-- src/app/[orgId]/settings/logs/ai/page.tsx | 2 +- src/components/AiProviderCapabilitiesSelect.tsx | 2 +- src/lib/aiCapabilities.ts | 2 +- src/lib/aiProviderDefaults.ts | 6 +++--- 12 files changed, 24 insertions(+), 24 deletions(-) rename server/routers/aiGateway/{anthropicModels.ts => v1Models.ts} (99%) diff --git a/docs/ai-gateway-provider-selection.md b/docs/ai-gateway-provider-selection.md index af67fd4f6..f80bd8b4f 100644 --- a/docs/ai-gateway-provider-selection.md +++ b/docs/ai-gateway-provider-selection.md @@ -7,7 +7,7 @@ inference resource has more than one AI provider. - Route → capability binding: `server/routers/aiGateway/createAiGatewayRouter.ts` - Request pipeline: `server/routers/aiGateway/pipeline.ts` (`selectProvider`) -- Model discovery: `server/routers/aiGateway/anthropicModels.ts` and +- Model discovery: `server/routers/aiGateway/v1Models.ts` and `server/lib/aiModelDiscovery.ts` - Tie-break scoring: `server/lib/aiProviderSelection.ts` - Allow/block matching: `server/lib/aiModelKeyMatch.ts` @@ -41,7 +41,7 @@ The incoming path selects a capability before any provider logic runs. | `POST /v1/chat/completions` | `openai_chat` | | `POST /v1/responses` | `openai_responses` | | `POST /v1/messages` | `anthropic_messages` | -| `GET /v1/models`, `GET /v1/models/{id}` | `anthropic_models` | +| `GET /v1/models`, `GET /v1/models/{id}` | `v1_models` | | Gemini / Vertex / Bedrock routes | their respective capability ids | Only attached providers that advertise that capability stay in the candidate @@ -50,10 +50,10 @@ set. Default capabilities do not overlap for native OpenAI vs Anthropic: | Provider type | Default capabilities | |---------------|----------------------| | `openai` | `openai_chat`, `openai_responses` | -| `anthropic` | `anthropic_messages`, `anthropic_models` | +| `anthropic` | `anthropic_messages`, `v1_models` | | `openRouter` | `openai_chat` | | `vercelAiGateway` | `openai_chat`, `openai_responses` | -| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages`, `anthropic_models` | +| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages`, `v1_models` | | `custom` | whatever was configured | ### 2. Allow / Block Lists @@ -133,10 +133,10 @@ customs advertising the same capability for an unknown model. ## Model Discovery Is Not Selection -`GET /v1/models` and `GET /v1/models/{id}` (`anthropic_models`) skip steps 3-6 +`GET /v1/models` and `GET /v1/models/{id}` (`v1_models`) skip steps 3-6 entirely. There is no requested model to disambiguate on, so the gateway does not pick one provider - it returns the **union** of what every attached -provider advertising `anthropic_models` would accept, deduplicated by model id +provider advertising `v1_models` would accept, deduplicated by model id (lowest `providerId` wins a collision). Discovery is answered from the gateway's own view of the allow/block lists, diff --git a/messages/en-US.json b/messages/en-US.json index 8952bbcbc..b6e24adcc 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1923,8 +1923,8 @@ "aiCapabilityOpenaiResponsesDescription": "Supports /v1/responses", "aiCapabilityAnthropicMessages": "Anthropic Messages", "aiCapabilityAnthropicMessagesDescription": "Supports /v1/messages", - "aiCapabilityAnthropicModels": "Anthropic Models", - "aiCapabilityAnthropicModelsDescription": "Supports /v1/models model discovery", + "aiCapabilityV1Models": "Models List", + "aiCapabilityV1ModelsDescription": "Supports /v1/models model discovery", "aiCapabilityGeminiGenerateContent": "Gemini Generate Content", "aiCapabilityGeminiGenerateContentDescription": "Supports the direct Gemini API", "aiCapabilityBedrockModelInvoke": "Bedrock Model Invoke", diff --git a/server/lib/aiCapabilities.ts b/server/lib/aiCapabilities.ts index 15e2595e2..152fa684f 100644 --- a/server/lib/aiCapabilities.ts +++ b/server/lib/aiCapabilities.ts @@ -135,8 +135,8 @@ export const AI_CAPABILITY_DEFS: Record = joinUpstreamUrl(base, pathFromRequest(req)), isStreaming: isBodyOrSseStreaming }, - anthropic_models: { - id: "anthropic_models", + v1_models: { + id: "v1_models", protocolFamily: "anthropic", routes: [ { method: "GET", path: "/v1/models" }, diff --git a/server/lib/aiMessageNormalization.ts b/server/lib/aiMessageNormalization.ts index 4dd8cef8c..bc7ac3cfc 100644 --- a/server/lib/aiMessageNormalization.ts +++ b/server/lib/aiMessageNormalization.ts @@ -472,7 +472,7 @@ const REQUEST_NORMALIZERS: Record< openai_responses: normalizeOpenAiResponsesRequest, anthropic_messages: normalizeAnthropicRequest, // Model discovery carries no transcript to normalize. - anthropic_models: () => null, + v1_models: () => null, gemini_generate_content: normalizeGeminiRequest, google_generate_content: normalizeGeminiRequest, google_raw_predict: normalizeBestEffortRequest, @@ -487,7 +487,7 @@ const RESPONSE_NORMALIZERS: Record< openai_chat: normalizeOpenAiChatResponse, openai_responses: normalizeOpenAiResponsesResponse, anthropic_messages: normalizeAnthropicResponse, - anthropic_models: () => null, + v1_models: () => null, gemini_generate_content: normalizeGeminiResponse, google_generate_content: normalizeGeminiResponse, google_raw_predict: normalizeGoogleRawPredictResponse, diff --git a/server/lib/aiUsageExtraction.ts b/server/lib/aiUsageExtraction.ts index 55c562e3b..0dae1b1ab 100644 --- a/server/lib/aiUsageExtraction.ts +++ b/server/lib/aiUsageExtraction.ts @@ -336,7 +336,7 @@ const EXTRACTORS: Record< openai_responses: extractOpenAiResponses, anthropic_messages: extractAnthropicMessages, // Model discovery never runs a model, so there are no tokens to bill. - anthropic_models: () => null, + v1_models: () => null, gemini_generate_content: extractGoogleGenerateContent, google_generate_content: extractGoogleGenerateContent, // rawPredict is a passthrough to whatever the underlying publisher diff --git a/server/routers/aiGateway/createAiGatewayRouter.ts b/server/routers/aiGateway/createAiGatewayRouter.ts index 3e30dd6fb..0a697faf2 100644 --- a/server/routers/aiGateway/createAiGatewayRouter.ts +++ b/server/routers/aiGateway/createAiGatewayRouter.ts @@ -4,7 +4,7 @@ import { type AiCapability } from "@server/lib/aiCapabilities"; import { handleAiGatewayProxy } from "@server/routers/aiGateway/pipeline"; -import { handleAnthropicModels } from "@server/routers/aiGateway/anthropicModels"; +import { handleV1Models } from "@server/routers/aiGateway"; type CapabilityHandler = ( req: Request, @@ -15,7 +15,7 @@ type CapabilityHandler = ( // Capabilities the gateway answers itself instead of proxying upstream. // Everything else goes through the inference pipeline. const LOCAL_HANDLERS: Partial> = { - anthropic_models: handleAnthropicModels + v1_models: handleV1Models }; export function createAiGatewayRouter() { diff --git a/server/routers/aiGateway/index.ts b/server/routers/aiGateway/index.ts index a10a7aed4..fc219faf6 100644 --- a/server/routers/aiGateway/index.ts +++ b/server/routers/aiGateway/index.ts @@ -1,3 +1,3 @@ export { handleAiGatewayProxy } from "./pipeline"; -export { handleAnthropicModels } from "./anthropicModels"; +export { handleV1Models } from "./v1Models"; export { createAiGatewayRouter } from "./createAiGatewayRouter"; diff --git a/server/routers/aiGateway/anthropicModels.ts b/server/routers/aiGateway/v1Models.ts similarity index 99% rename from server/routers/aiGateway/anthropicModels.ts rename to server/routers/aiGateway/v1Models.ts index 9e15a78de..0d79bc3db 100644 --- a/server/routers/aiGateway/anthropicModels.ts +++ b/server/routers/aiGateway/v1Models.ts @@ -35,7 +35,7 @@ import { import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; -const CAPABILITY: AiCapability = "anthropic_models"; +const CAPABILITY: AiCapability = "v1_models"; const querySchema = z.object({ limit: z.coerce.number().int().min(1).max(MODEL_PAGE_MAX_LIMIT).optional(), @@ -163,7 +163,7 @@ function buildDiscoveryProviders( * list at all or would expose models the resource's allow/block lists forbid, * so the response is built from the same effective lists that gate inference. */ -export async function handleAnthropicModels( +export async function handleV1Models( req: Request, res: Response ): Promise { diff --git a/src/app/[orgId]/settings/logs/ai/page.tsx b/src/app/[orgId]/settings/logs/ai/page.tsx index be043dd30..2499e8a20 100644 --- a/src/app/[orgId]/settings/logs/ai/page.tsx +++ b/src/app/[orgId]/settings/logs/ai/page.tsx @@ -33,7 +33,7 @@ const capabilityLabels: Record = { openai_chat: "OpenAI Chat Completions", openai_responses: "OpenAI Responses", anthropic_messages: "Anthropic Messages", - anthropic_models: "Anthropic Models", + v1_models: "Models List", gemini_generate_content: "Gemini", google_generate_content: "Vertex AI (Generate Content)", google_raw_predict: "Vertex AI (Raw Predict)", diff --git a/src/components/AiProviderCapabilitiesSelect.tsx b/src/components/AiProviderCapabilitiesSelect.tsx index 7383998b4..43c1b5c89 100644 --- a/src/components/AiProviderCapabilitiesSelect.tsx +++ b/src/components/AiProviderCapabilitiesSelect.tsx @@ -20,7 +20,7 @@ const CAPABILITY_LABEL_KEYS: Record = { openai_chat: "aiCapabilityOpenaiChat", openai_responses: "aiCapabilityOpenaiResponses", anthropic_messages: "aiCapabilityAnthropicMessages", - anthropic_models: "aiCapabilityAnthropicModels", + v1_models: "aiCapabilityV1Models", gemini_generate_content: "aiCapabilityGeminiGenerateContent", bedrock_model_invoke: "aiCapabilityBedrockModelInvoke", google_generate_content: "aiCapabilityGoogleGenerateContent", diff --git a/src/lib/aiCapabilities.ts b/src/lib/aiCapabilities.ts index 885cf4a9f..39c9b462a 100644 --- a/src/lib/aiCapabilities.ts +++ b/src/lib/aiCapabilities.ts @@ -2,7 +2,7 @@ export const AI_CAPABILITIES = [ "openai_chat", "openai_responses", "anthropic_messages", - "anthropic_models", + "v1_models", "gemini_generate_content", "bedrock_model_invoke", "google_generate_content", diff --git a/src/lib/aiProviderDefaults.ts b/src/lib/aiProviderDefaults.ts index f30f6dbd1..fd15d1868 100644 --- a/src/lib/aiProviderDefaults.ts +++ b/src/lib/aiProviderDefaults.ts @@ -38,12 +38,12 @@ export const AI_PROVIDER_DEFAULTS: Record< openai: { upstreamUrl: "https://api.openai.com/v1", authType: "bearer", - capabilities: ["openai_chat", "openai_responses"] + capabilities: ["openai_chat", "openai_responses", "v1_models"] }, anthropic: { upstreamUrl: "https://api.anthropic.com", authType: "x-api-key", - capabilities: ["anthropic_messages", "anthropic_models"] + capabilities: ["anthropic_messages", "v1_models"] }, googleGemini: { upstreamUrl: "https://generativelanguage.googleapis.com", @@ -67,7 +67,7 @@ export const AI_PROVIDER_DEFAULTS: Record< "openai_chat", "openai_responses", "anthropic_messages", - "anthropic_models" + "v1_models" ] }, openRouter: { From 2e87927b83769f58f9cfc6dc6d72c233a1d6a855 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 21 Aug 2026 10:21:21 -0400 Subject: [PATCH 07/42] Remove unused use_subdomain --- server/lib/readConfigFile.ts | 1 - server/private/routers/gerbil/createExitNode.ts | 6 +----- server/routers/gerbil/createExitNode.ts | 8 +------- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 68dc7cea7..718076349 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -348,7 +348,6 @@ export const configSchema = z .optional() .pipe(z.string()) .transform((url) => url.toLowerCase()), - use_subdomain: z.boolean().optional().default(false), subnet_group: z.string().optional().default("100.89.137.0/20"), block_size: z.number().positive().gt(0).optional().default(24), site_block_size: z diff --git a/server/private/routers/gerbil/createExitNode.ts b/server/private/routers/gerbil/createExitNode.ts index cfa7c42eb..0e0f6c8d7 100644 --- a/server/private/routers/gerbil/createExitNode.ts +++ b/server/private/routers/gerbil/createExitNode.ts @@ -34,10 +34,6 @@ export async function createExitNode( // TODO: eventually we will want to get the next available port so that we can multiple exit nodes // const listenPort = await getNextAvailablePort(); const listenPort = config.getRawConfig().gerbil.start_port; - let subEndpoint = ""; - if (config.getRawConfig().gerbil.use_subdomain) { - subEndpoint = await getUniqueExitNodeEndpointName(); - } const exitNodeName = config.getRawConfig().gerbil.exit_node_name || @@ -48,7 +44,7 @@ export async function createExitNode( .insert(exitNodes) .values({ publicKey, - endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`, + endpoint: config.getRawConfig().gerbil.base_endpoint, address, listenPort, online: true, diff --git a/server/routers/gerbil/createExitNode.ts b/server/routers/gerbil/createExitNode.ts index 9e93cf575..4fc4e989f 100644 --- a/server/routers/gerbil/createExitNode.ts +++ b/server/routers/gerbil/createExitNode.ts @@ -15,13 +15,7 @@ export async function createExitNode( if (!exitNodeQuery) { const { value: address, release } = await getNextAvailableSubnet(); try { - // TODO: eventually we will want to get the next available port so that we can multiple exit nodes - // const listenPort = await getNextAvailablePort(); const listenPort = config.getRawConfig().gerbil.start_port; - let subEndpoint = ""; - if (config.getRawConfig().gerbil.use_subdomain) { - subEndpoint = await getUniqueExitNodeEndpointName(); - } const exitNodeName = config.getRawConfig().gerbil.exit_node_name || @@ -32,7 +26,7 @@ export async function createExitNode( .insert(exitNodes) .values({ publicKey, - endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`, + endpoint: config.getRawConfig().gerbil.base_endpoint, address, online: true, listenPort, From e3e1508e8a6b0896c3259fe54ca605e3b35aace7 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 21 Aug 2026 12:09:22 -0400 Subject: [PATCH 08/42] Handle warning and no routing to remote exit nodes for ai providers --- messages/en-US.json | 1 + server/routers/aiGateway/targetRouting.ts | 7 +++++++ .../ai-providers/[niceId]/network/page.tsx | 1 + .../settings/ai-providers/create/page.tsx | 1 + .../public/ProxyResourceTargetsForm.tsx | 19 ++++++++++++++++++- src/lib/queries.ts | 12 ++++++++++++ 6 files changed, 40 insertions(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index b6e24adcc..0c59990a7 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "Route through targets on your sites", "aiProviderRoutingModeTargetNote": "After creating this provider, configure site targets on the Network Settings tab.", "aiProviderTargetNoOne": "This provider doesn't have any targets. Add a target to route requests through your sites.", + "aiProviderRemoteNodeTargetsWarning": "Sites connected to remote nodes are inaccessable to be routed to on AI Gateway providers.", "aiProviderSkipTlsVerification": "Skip TLS Verification", "aiProviderSkipTlsVerificationDescription": "Disable TLS certificate verification for the upstream connection", "aiProviderBudget": "Budget", diff --git a/server/routers/aiGateway/targetRouting.ts b/server/routers/aiGateway/targetRouting.ts index 963eac2d5..2dd8c721d 100644 --- a/server/routers/aiGateway/targetRouting.ts +++ b/server/routers/aiGateway/targetRouting.ts @@ -99,6 +99,7 @@ async function fetchProviderTargets( method: targets.method, exitNodeSubnet: sites.exitNodeSubnet, reachableAt: exitNodes.reachableAt, + exitNodeType: exitNodes.type, hcHealth: targetHealthCheck.hcHealth }) .from(targets) @@ -119,6 +120,12 @@ async function fetchProviderTargets( if (!row.exitNodeSubnet || !row.reachableAt) { continue; } + // Sites connected to a remote exit node aren't reachable via a + // gerbil sidecar's /router/* proxy - only "gerbil" type exit nodes + // run that endpoint. + if (row.exitNodeType !== "gerbil") { + continue; + } // A target with an active health check that's currently failing is // taken out of rotation. No health check (null) or "unknown" (check // hasn't run yet / hcEnabled is off) still routes normally, matching diff --git a/src/app/[orgId]/settings/ai-providers/[niceId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/network/page.tsx index fafbdf49f..bcf75b7f7 100644 --- a/src/app/[orgId]/settings/ai-providers/[niceId]/network/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[niceId]/network/page.tsx @@ -345,6 +345,7 @@ export default function AiProviderNetworkPage() { ref={targetsFormRef} orgId={orgId} isHttp + isAiProvider providerId={provider.providerId} initialTargets={ isTargetModeSaved ? remoteTargets : [] diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 5872594ef..f4609b1cc 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -682,6 +682,7 @@ export default function CreateAiProviderPage() { { targetsRef.current = nextTargets; }} diff --git a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx index 2cc33acaf..ba331ed4e 100644 --- a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx +++ b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx @@ -113,6 +113,8 @@ type ProxyResourceTargetsFormProps = { hideSaveButton?: boolean; /** Hide the advanced mode toggle and always use non-advanced mode (e.g. AI providers) */ disableAdvancedMode?: boolean; + /** Targets picker is for an AI provider (changes which routing warnings are shown) */ + isAiProvider?: boolean; }; export const ProxyResourceTargetsForm = forwardRef< @@ -131,7 +133,8 @@ export const ProxyResourceTargetsForm = forwardRef< emptyMessage, embedded = false, hideSaveButton = false, - disableAdvancedMode = false + disableAdvancedMode = false, + isAiProvider = false }, ref ) { @@ -259,6 +262,14 @@ export const ProxyResourceTargetsForm = forwardRef< }) ); + const { data: remoteExitNodes = [] } = useQuery({ + ...orgQueries.remoteExitNodes({ orgId }), + enabled: build === "saas" && isAiProvider + }); + const hasRemoteExitNodes = remoteExitNodes.some( + (node) => node.exitNodeId !== null + ); + const updateTarget = useCallback( (targetId: number, data: Partial) => { setTargets((prevTargets) => { @@ -972,6 +983,7 @@ export const ProxyResourceTargetsForm = forwardRef< )} {build === "saas" && + !isAiProvider && targets.length > 1 && new Set(targets.map((t) => t.siteId)).size > 1 && (

@@ -988,6 +1000,11 @@ export const ProxyResourceTargetsForm = forwardRef< .

)} + {build === "saas" && isAiProvider && hasRemoteExitNodes && ( +

+ {t("aiProviderRemoteNodeTargetsWarning")} +

+ )} ); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index ce3fe960c..8d3831dbd 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -59,6 +59,7 @@ import type { import type { GetResourceResponse } from "@server/routers/resource/getResource"; import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; +import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; import type { ListRolesResponse } from "@server/routers/role"; import type { ListSitesResponse } from "@server/routers/site"; import type { @@ -330,6 +331,17 @@ export const orgQueries = { } }), + remoteExitNodes: ({ orgId }: { orgId: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "REMOTE_EXIT_NODES"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/remote-exit-nodes`, { signal }); + return res.data.data.remoteExitNodes; + } + }), + labels: ({ orgId, query, From 442cefda842403ff7e3c0051a52245e970d31f3a Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 21 Aug 2026 12:25:14 -0400 Subject: [PATCH 09/42] Dont allow clients to connect to remote nodes quite yet --- server/lib/exitNodes/exitNodes.ts | 5 ++++- server/private/lib/exitNodes/exitNodes.ts | 7 +++++-- server/routers/olm/handleOlmExitNodesRequestMessage.ts | 3 ++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/server/lib/exitNodes/exitNodes.ts b/server/lib/exitNodes/exitNodes.ts index 823fdde97..12947c1d5 100644 --- a/server/lib/exitNodes/exitNodes.ts +++ b/server/lib/exitNodes/exitNodes.ts @@ -22,7 +22,10 @@ export async function listExitNodes( // Accepted for parity with the enterprise implementation (used there for // site-label filtering of remote exit nodes). The OSS build has no remote // exit nodes, so it is unused here. - siteId?: number + siteId?: number, + // Same as above: accepted for parity, unused since the OSS build has no + // remote exit nodes to exclude. + noRemote = false ) { // TODO: pick which nodes to send and ping better than just all of them that are not remote const allExitNodes = await db diff --git a/server/private/lib/exitNodes/exitNodes.ts b/server/private/lib/exitNodes/exitNodes.ts index 976f52cff..7298b32d4 100644 --- a/server/private/lib/exitNodes/exitNodes.ts +++ b/server/private/lib/exitNodes/exitNodes.ts @@ -153,7 +153,8 @@ export async function listExitNodes( orgId: string, filterOnline = false, noCloud = false, - siteId?: number + siteId?: number, + noRemote = false ) { const allExitNodes = await db .select({ @@ -242,7 +243,9 @@ export async function listExitNodes( let remoteExitNodesList = allExitNodes.filter( (node) => - node.type === "remoteExitNode" && (!filterOnline || node.online) + node.type === "remoteExitNode" && + !noRemote && + (!filterOnline || node.online) ); const gerbilExitNodes = allExitNodes.filter( (node) => diff --git a/server/routers/olm/handleOlmExitNodesRequestMessage.ts b/server/routers/olm/handleOlmExitNodesRequestMessage.ts index bb8c7a48f..efaaae470 100644 --- a/server/routers/olm/handleOlmExitNodesRequestMessage.ts +++ b/server/routers/olm/handleOlmExitNodesRequestMessage.ts @@ -42,7 +42,8 @@ export const handleOlmExitNodesRequestMessage: MessageHandler = async ( client.orgId, true, noCloud || false, - olm.clientId + olm.clientId, + true // don't select remote exit nodes for clients ); // filter for only the online ones let lastExitNodeId = null; From 59f286299ed8f460cd4d8235c0d6a9bf9f00cd71 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 21 Aug 2026 13:41:45 -0400 Subject: [PATCH 10/42] Move the request log to be public --- .../routers/newt/handleRequestLogMessage.ts | 238 ------------------ server/private/routers/newt/index.ts | 1 - server/private/routers/ws/messageHandlers.ts | 4 +- .../routers/newt/handleRequestLogMessage.ts | 233 ++++++++++++++++- server/routers/ws/messageHandlers.ts | 4 +- 5 files changed, 235 insertions(+), 245 deletions(-) delete mode 100644 server/private/routers/newt/handleRequestLogMessage.ts diff --git a/server/private/routers/newt/handleRequestLogMessage.ts b/server/private/routers/newt/handleRequestLogMessage.ts deleted file mode 100644 index f06c59bc6..000000000 --- a/server/private/routers/newt/handleRequestLogMessage.ts +++ /dev/null @@ -1,238 +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 { db } from "@server/db"; -import { MessageHandler } from "@server/routers/ws"; -import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db"; -import { and, eq, inArray } from "drizzle-orm"; -import logger from "@server/logger"; -import { inflate } from "zlib"; -import { promisify } from "util"; -import { logRequestAudit } from "@server/routers/badger/logRequestAudit"; -import { getCountryCodeForIp } from "@server/lib/geoip"; - -export async function flushRequestLogToDb(): Promise { - return; -} - -const zlibInflate = promisify(inflate); - -interface HTTPRequestLogData { - requestId: string; - resourceId: number; // siteResourceId - timestamp: string; // ISO 8601 - method: string; - scheme: string; // "http" or "https" - host: string; - path: string; - rawQuery?: string; - userAgent?: string; - sourceAddr: string; // ip:port - tls: boolean; -} - -/** - * Decompress a base64-encoded zlib-compressed string into parsed JSON. - */ -async function decompressRequestLog( - compressed: string -): Promise { - const compressedBuffer = Buffer.from(compressed, "base64"); - const decompressed = await zlibInflate(compressedBuffer); - const jsonString = decompressed.toString("utf-8"); - const parsed = JSON.parse(jsonString); - - if (!Array.isArray(parsed)) { - throw new Error("Decompressed request log data is not an array"); - } - - return parsed; -} - -export const handleRequestLogMessage: MessageHandler = async (context) => { - const { message, client } = context; - const newt = client as Newt; - - if (!newt) { - logger.warn("Request log received but no newt client in context"); - return; - } - - if (!newt.siteId) { - logger.warn("Request log received but newt has no siteId"); - return; - } - - if (!message.data?.compressed) { - logger.warn("Request log message missing compressed data"); - return; - } - - // Look up the org for this site and check retention settings - const [site] = await db - .select({ - orgId: sites.orgId, - orgSubnet: orgs.subnet, - settingsLogRetentionDaysRequest: - orgs.settingsLogRetentionDaysRequest - }) - .from(sites) - .innerJoin(orgs, eq(sites.orgId, orgs.orgId)) - .where(eq(sites.siteId, newt.siteId)); - - if (!site) { - logger.warn( - `Request log received but site ${newt.siteId} not found in database` - ); - return; - } - - const orgId = site.orgId; - - if (site.settingsLogRetentionDaysRequest === 0) { - logger.debug( - `Request log retention is disabled for org ${orgId}, skipping` - ); - return; - } - - let entries: HTTPRequestLogData[]; - try { - entries = await decompressRequestLog(message.data.compressed); - } catch (error) { - logger.error("Failed to decompress request log data:", error); - return; - } - - if (entries.length === 0) { - return; - } - - logger.debug(`Request log entries: ${JSON.stringify(entries)}`); - - // Build a map from sourceIp → external endpoint string by joining clients - // with clientSitesAssociationsCache. The endpoint is the real-world IP:port - // of the client device and is used for GeoIP lookup. - const ipToEndpoint = new Map(); - - const cidrSuffix = site.orgSubnet?.includes("/") - ? site.orgSubnet.substring(site.orgSubnet.indexOf("/")) - : null; - - if (cidrSuffix) { - const uniqueSourceAddrs = new Set(); - for (const entry of entries) { - if (entry.sourceAddr) { - uniqueSourceAddrs.add(entry.sourceAddr); - } - } - - if (uniqueSourceAddrs.size > 0) { - const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => { - const ip = addr.includes(":") ? addr.split(":")[0] : addr; - return `${ip}${cidrSuffix}`; - }); - - const matchedClients = await db - .select({ - subnet: clients.subnet, - endpoint: clientSitesAssociationsCache.endpoint - }) - .from(clients) - .innerJoin( - clientSitesAssociationsCache, - and( - eq( - clientSitesAssociationsCache.clientId, - clients.clientId - ), - eq(clientSitesAssociationsCache.siteId, newt.siteId) - ) - ) - .where( - and( - eq(clients.orgId, orgId), - inArray(clients.subnet, subnetQueries) - ) - ); - - for (const c of matchedClients) { - if (c.endpoint) { - const ip = c.subnet.split("/")[0]; - ipToEndpoint.set(ip, c.endpoint); - } - } - } - } - - for (const entry of entries) { - if ( - !entry.requestId || - !entry.resourceId || - !entry.method || - !entry.scheme || - !entry.host || - !entry.path || - !entry.sourceAddr - ) { - logger.debug( - `Skipping request log entry with missing required fields: ${JSON.stringify(entry)}` - ); - continue; - } - - const originalRequestURL = - entry.scheme + - "://" + - entry.host + - entry.path + - (entry.rawQuery ? "?" + entry.rawQuery : ""); - - // Resolve the client's external endpoint for GeoIP lookup. - // sourceAddr is the WireGuard IP (possibly ip:port), so strip the port. - const sourceIp = entry.sourceAddr.includes(":") - ? entry.sourceAddr.split(":")[0] - : entry.sourceAddr; - const endpoint = ipToEndpoint.get(sourceIp); - let location: string | undefined; - if (endpoint) { - const endpointIp = endpoint.includes(":") - ? endpoint.split(":")[0] - : endpoint; - location = await getCountryCodeForIp(endpointIp); - } - - await logRequestAudit( - { - action: true, - reason: 108, - siteResourceId: entry.resourceId, - orgId, - location - }, - { - path: entry.path, - originalRequestURL, - scheme: entry.scheme, - host: entry.host, - method: entry.method, - tls: entry.tls, - requestIp: entry.sourceAddr - } - ); - } - - logger.debug( - `Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})` - ); -}; diff --git a/server/private/routers/newt/index.ts b/server/private/routers/newt/index.ts index 94dfc8f05..59d8e980a 100644 --- a/server/private/routers/newt/index.ts +++ b/server/private/routers/newt/index.ts @@ -12,4 +12,3 @@ */ export * from "./handleConnectionLogMessage"; -export * from "./handleRequestLogMessage"; diff --git a/server/private/routers/ws/messageHandlers.ts b/server/private/routers/ws/messageHandlers.ts index b79b715b6..685f67848 100644 --- a/server/private/routers/ws/messageHandlers.ts +++ b/server/private/routers/ws/messageHandlers.ts @@ -18,12 +18,10 @@ import { import { MessageHandler } from "@server/routers/ws"; import { handleConnectionLogMessage, - handleRequestLogMessage } from "#private/routers/newt"; export const messageHandlers: Record = { "remoteExitNode/register": handleRemoteExitNodeRegisterMessage, "remoteExitNode/ping": handleRemoteExitNodePingMessage, "newt/access-log": handleConnectionLogMessage, - "newt/request-log": handleRequestLogMessage -}; +; diff --git a/server/routers/newt/handleRequestLogMessage.ts b/server/routers/newt/handleRequestLogMessage.ts index 190020ad1..f06c59bc6 100644 --- a/server/routers/newt/handleRequestLogMessage.ts +++ b/server/routers/newt/handleRequestLogMessage.ts @@ -1,9 +1,238 @@ +/* + * 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 } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; +import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import logger from "@server/logger"; +import { inflate } from "zlib"; +import { promisify } from "util"; +import { logRequestAudit } from "@server/routers/badger/logRequestAudit"; +import { getCountryCodeForIp } from "@server/lib/geoip"; export async function flushRequestLogToDb(): Promise { return; } +const zlibInflate = promisify(inflate); + +interface HTTPRequestLogData { + requestId: string; + resourceId: number; // siteResourceId + timestamp: string; // ISO 8601 + method: string; + scheme: string; // "http" or "https" + host: string; + path: string; + rawQuery?: string; + userAgent?: string; + sourceAddr: string; // ip:port + tls: boolean; +} + +/** + * Decompress a base64-encoded zlib-compressed string into parsed JSON. + */ +async function decompressRequestLog( + compressed: string +): Promise { + const compressedBuffer = Buffer.from(compressed, "base64"); + const decompressed = await zlibInflate(compressedBuffer); + const jsonString = decompressed.toString("utf-8"); + const parsed = JSON.parse(jsonString); + + if (!Array.isArray(parsed)) { + throw new Error("Decompressed request log data is not an array"); + } + + return parsed; +} + export const handleRequestLogMessage: MessageHandler = async (context) => { - return; -}; \ No newline at end of file + const { message, client } = context; + const newt = client as Newt; + + if (!newt) { + logger.warn("Request log received but no newt client in context"); + return; + } + + if (!newt.siteId) { + logger.warn("Request log received but newt has no siteId"); + return; + } + + if (!message.data?.compressed) { + logger.warn("Request log message missing compressed data"); + return; + } + + // Look up the org for this site and check retention settings + const [site] = await db + .select({ + orgId: sites.orgId, + orgSubnet: orgs.subnet, + settingsLogRetentionDaysRequest: + orgs.settingsLogRetentionDaysRequest + }) + .from(sites) + .innerJoin(orgs, eq(sites.orgId, orgs.orgId)) + .where(eq(sites.siteId, newt.siteId)); + + if (!site) { + logger.warn( + `Request log received but site ${newt.siteId} not found in database` + ); + return; + } + + const orgId = site.orgId; + + if (site.settingsLogRetentionDaysRequest === 0) { + logger.debug( + `Request log retention is disabled for org ${orgId}, skipping` + ); + return; + } + + let entries: HTTPRequestLogData[]; + try { + entries = await decompressRequestLog(message.data.compressed); + } catch (error) { + logger.error("Failed to decompress request log data:", error); + return; + } + + if (entries.length === 0) { + return; + } + + logger.debug(`Request log entries: ${JSON.stringify(entries)}`); + + // Build a map from sourceIp → external endpoint string by joining clients + // with clientSitesAssociationsCache. The endpoint is the real-world IP:port + // of the client device and is used for GeoIP lookup. + const ipToEndpoint = new Map(); + + const cidrSuffix = site.orgSubnet?.includes("/") + ? site.orgSubnet.substring(site.orgSubnet.indexOf("/")) + : null; + + if (cidrSuffix) { + const uniqueSourceAddrs = new Set(); + for (const entry of entries) { + if (entry.sourceAddr) { + uniqueSourceAddrs.add(entry.sourceAddr); + } + } + + if (uniqueSourceAddrs.size > 0) { + const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => { + const ip = addr.includes(":") ? addr.split(":")[0] : addr; + return `${ip}${cidrSuffix}`; + }); + + const matchedClients = await db + .select({ + subnet: clients.subnet, + endpoint: clientSitesAssociationsCache.endpoint + }) + .from(clients) + .innerJoin( + clientSitesAssociationsCache, + and( + eq( + clientSitesAssociationsCache.clientId, + clients.clientId + ), + eq(clientSitesAssociationsCache.siteId, newt.siteId) + ) + ) + .where( + and( + eq(clients.orgId, orgId), + inArray(clients.subnet, subnetQueries) + ) + ); + + for (const c of matchedClients) { + if (c.endpoint) { + const ip = c.subnet.split("/")[0]; + ipToEndpoint.set(ip, c.endpoint); + } + } + } + } + + for (const entry of entries) { + if ( + !entry.requestId || + !entry.resourceId || + !entry.method || + !entry.scheme || + !entry.host || + !entry.path || + !entry.sourceAddr + ) { + logger.debug( + `Skipping request log entry with missing required fields: ${JSON.stringify(entry)}` + ); + continue; + } + + const originalRequestURL = + entry.scheme + + "://" + + entry.host + + entry.path + + (entry.rawQuery ? "?" + entry.rawQuery : ""); + + // Resolve the client's external endpoint for GeoIP lookup. + // sourceAddr is the WireGuard IP (possibly ip:port), so strip the port. + const sourceIp = entry.sourceAddr.includes(":") + ? entry.sourceAddr.split(":")[0] + : entry.sourceAddr; + const endpoint = ipToEndpoint.get(sourceIp); + let location: string | undefined; + if (endpoint) { + const endpointIp = endpoint.includes(":") + ? endpoint.split(":")[0] + : endpoint; + location = await getCountryCodeForIp(endpointIp); + } + + await logRequestAudit( + { + action: true, + reason: 108, + siteResourceId: entry.resourceId, + orgId, + location + }, + { + path: entry.path, + originalRequestURL, + scheme: entry.scheme, + host: entry.host, + method: entry.method, + tls: entry.tls, + requestIp: entry.sourceAddr + } + ); + } + + logger.debug( + `Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})` + ); +}; diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index b8ac9baa4..b9eddcfc8 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -7,7 +7,8 @@ import { handleNewtExitNodesRequestMessage, handleApplyBlueprintMessage, handleNewtPingMessage, - handleNewtDisconnectingMessage + handleNewtDisconnectingMessage, + handleRequestLogMessage } from "../newt"; import { handleOlmRegisterMessage, @@ -46,5 +47,6 @@ export const messageHandlers: Record = { "newt/ping/request": handleNewtExitNodesRequestMessage, "newt/blueprint/apply": handleApplyBlueprintMessage, "newt/healthcheck/status": handleHealthcheckStatusMessage, + "newt/request-log": handleRequestLogMessage, "ws/round-trip/complete": handleRoundTripMessage }; From d5ea0ecbc16631b5b3c67d7a445ef52dc5008461 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 21 Aug 2026 13:50:56 -0400 Subject: [PATCH 11/42] Include user in the request logs --- server/routers/newt/handleRequestLogMessage.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/server/routers/newt/handleRequestLogMessage.ts b/server/routers/newt/handleRequestLogMessage.ts index f06c59bc6..6d9ff36f4 100644 --- a/server/routers/newt/handleRequestLogMessage.ts +++ b/server/routers/newt/handleRequestLogMessage.ts @@ -13,7 +13,7 @@ import { db } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db"; +import { sites, Newt, orgs, clients, clientSitesAssociationsCache, users } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import logger from "@server/logger"; import { inflate } from "zlib"; @@ -124,6 +124,8 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { // with clientSitesAssociationsCache. The endpoint is the real-world IP:port // of the client device and is used for GeoIP lookup. const ipToEndpoint = new Map(); + // Build a map from sourceIp → the user associated with the client (if any) + const ipToUser = new Map(); const cidrSuffix = site.orgSubnet?.includes("/") ? site.orgSubnet.substring(site.orgSubnet.indexOf("/")) @@ -146,7 +148,9 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { const matchedClients = await db .select({ subnet: clients.subnet, - endpoint: clientSitesAssociationsCache.endpoint + endpoint: clientSitesAssociationsCache.endpoint, + username: users.username, + userId: users.userId }) .from(clients) .innerJoin( @@ -159,6 +163,7 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { eq(clientSitesAssociationsCache.siteId, newt.siteId) ) ) + .leftJoin(users, eq(clients.userId, users.userId)) .where( and( eq(clients.orgId, orgId), @@ -167,10 +172,13 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { ); for (const c of matchedClients) { + const ip = c.subnet.split("/")[0]; if (c.endpoint) { - const ip = c.subnet.split("/")[0]; ipToEndpoint.set(ip, c.endpoint); } + if (c.userId && c.username) { + ipToUser.set(ip, { userId: c.userId, username: c.username }); + } } } } @@ -211,6 +219,7 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { : endpoint; location = await getCountryCodeForIp(endpointIp); } + const user = ipToUser.get(sourceIp); await logRequestAudit( { @@ -218,7 +227,8 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { reason: 108, siteResourceId: entry.resourceId, orgId, - location + location, + user }, { path: entry.path, From 4229ef917341cc832de271239806d323f6f625e5 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 21 Aug 2026 14:03:36 -0400 Subject: [PATCH 12/42] Change log to warn --- server/private/routers/ws/ws.ts | 2 +- server/routers/ws/ws.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index f014b0e57..34356a8c8 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -139,7 +139,7 @@ const processMessage = async ( } } } catch (error) { - logger.error("Message handling error:", error); + logger.warn("Message handling error:", error); // ws.send(JSON.stringify({ // type: "error", // data: { diff --git a/server/routers/ws/ws.ts b/server/routers/ws/ws.ts index dad29c4f0..62aee93bd 100644 --- a/server/routers/ws/ws.ts +++ b/server/routers/ws/ws.ts @@ -388,7 +388,7 @@ const setupConnection = async ( } } } catch (error) { - logger.error("Message handling error:", error); + logger.warn("Message handling error:", error); ws.send( JSON.stringify({ type: "error", From eca1c9044cbc276f2e372372dd24ff7bfde63c36 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 21 Aug 2026 15:24:20 -0400 Subject: [PATCH 13/42] Rename siteResourceId to resourceId --- server/routers/siteResource/addClientToSiteResource.ts | 2 +- server/routers/siteResource/addRoleToSiteResource.ts | 2 +- server/routers/siteResource/addUserToSiteResource.ts | 2 +- server/routers/siteResource/listSiteResourceClients.ts | 2 +- server/routers/siteResource/listSiteResourceRoles.ts | 2 +- server/routers/siteResource/listSiteResourceUsers.ts | 2 +- server/routers/siteResource/removeClientFromSiteResource.ts | 2 +- server/routers/siteResource/removeRoleFromSiteResource.ts | 2 +- server/routers/siteResource/removeUserFromSiteResource.ts | 2 +- server/routers/siteResource/setSiteResourceClients.ts | 2 +- server/routers/siteResource/setSiteResourceRoles.ts | 2 +- server/routers/siteResource/setSiteResourceUsers.ts | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/server/routers/siteResource/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts index c9ead2cbd..3bb05b918 100644 --- a/server/routers/siteResource/addClientToSiteResource.ts +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -64,7 +64,7 @@ registry.registerPath({ registry.registerPath({ method: "post", - path: "/private-resource/{siteResourceId}/clients/add", + path: "/private-resource/{resourceId}/clients/add", description: "Add a single client to a site resource. Clients with a userId cannot be added.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts index 2cdae780c..6dbd238f7 100644 --- a/server/routers/siteResource/addRoleToSiteResource.ts +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -64,7 +64,7 @@ registry.registerPath({ registry.registerPath({ method: "post", - path: "/private-resource/{siteResourceId}/roles/add", + path: "/private-resource/{resourceId}/roles/add", description: "Add a single role to a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], request: { diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts index 526494265..c464a1e5f 100644 --- a/server/routers/siteResource/addUserToSiteResource.ts +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -64,7 +64,7 @@ registry.registerPath({ registry.registerPath({ method: "post", - path: "/private-resource/{siteResourceId}/users/add", + path: "/private-resource/{resourceId}/users/add", description: "Add a single user to a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.User], request: { diff --git a/server/routers/siteResource/listSiteResourceClients.ts b/server/routers/siteResource/listSiteResourceClients.ts index 916345190..884d94f1e 100644 --- a/server/routers/siteResource/listSiteResourceClients.ts +++ b/server/routers/siteResource/listSiteResourceClients.ts @@ -63,7 +63,7 @@ registry.registerPath({ registry.registerPath({ method: "get", - path: "/private-resource/{siteResourceId}/clients", + path: "/private-resource/{resourceId}/clients", description: "List all clients for a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], request: { diff --git a/server/routers/siteResource/listSiteResourceRoles.ts b/server/routers/siteResource/listSiteResourceRoles.ts index 132b4fed0..0df8e73aa 100644 --- a/server/routers/siteResource/listSiteResourceRoles.ts +++ b/server/routers/siteResource/listSiteResourceRoles.ts @@ -64,7 +64,7 @@ registry.registerPath({ registry.registerPath({ method: "get", - path: "/private-resource/{siteResourceId}/roles", + path: "/private-resource/{resourceId}/roles", description: "List all roles for a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], request: { diff --git a/server/routers/siteResource/listSiteResourceUsers.ts b/server/routers/siteResource/listSiteResourceUsers.ts index 70604d718..e8bfb3d1f 100644 --- a/server/routers/siteResource/listSiteResourceUsers.ts +++ b/server/routers/siteResource/listSiteResourceUsers.ts @@ -67,7 +67,7 @@ registry.registerPath({ registry.registerPath({ method: "get", - path: "/private-resource/{siteResourceId}/users", + path: "/private-resource/{resourceId}/users", description: "List all users for a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.User], request: { diff --git a/server/routers/siteResource/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts index bf57668bf..ef6b0f776 100644 --- a/server/routers/siteResource/removeClientFromSiteResource.ts +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -64,7 +64,7 @@ registry.registerPath({ registry.registerPath({ method: "post", - path: "/private-resource/{siteResourceId}/clients/remove", + path: "/private-resource/{resourceId}/clients/remove", description: "Remove a single client from a site resource. Clients with a userId cannot be removed.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts index cd60b288d..27db81820 100644 --- a/server/routers/siteResource/removeRoleFromSiteResource.ts +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -64,7 +64,7 @@ registry.registerPath({ registry.registerPath({ method: "post", - path: "/private-resource/{siteResourceId}/roles/remove", + path: "/private-resource/{resourceId}/roles/remove", description: "Remove a single role from a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], request: { diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts index 42b719e85..585bbd946 100644 --- a/server/routers/siteResource/removeUserFromSiteResource.ts +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -64,7 +64,7 @@ registry.registerPath({ registry.registerPath({ method: "post", - path: "/private-resource/{siteResourceId}/users/remove", + path: "/private-resource/{resourceId}/users/remove", description: "Remove a single user from a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.User], request: { diff --git a/server/routers/siteResource/setSiteResourceClients.ts b/server/routers/siteResource/setSiteResourceClients.ts index e1915badc..d21789a8d 100644 --- a/server/routers/siteResource/setSiteResourceClients.ts +++ b/server/routers/siteResource/setSiteResourceClients.ts @@ -64,7 +64,7 @@ registry.registerPath({ registry.registerPath({ method: "post", - path: "/private-resource/{siteResourceId}/clients", + path: "/private-resource/{resourceId}/clients", description: "Set clients for a site resource. This will replace all existing clients. Clients with a userId cannot be added.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts index 7d1c8db15..f9536f929 100644 --- a/server/routers/siteResource/setSiteResourceRoles.ts +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -65,7 +65,7 @@ registry.registerPath({ registry.registerPath({ method: "post", - path: "/private-resource/{siteResourceId}/roles", + path: "/private-resource/{resourceId}/roles", description: "Set roles for a site resource. This will replace all existing roles.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], diff --git a/server/routers/siteResource/setSiteResourceUsers.ts b/server/routers/siteResource/setSiteResourceUsers.ts index 06953b927..eed1b0cd7 100644 --- a/server/routers/siteResource/setSiteResourceUsers.ts +++ b/server/routers/siteResource/setSiteResourceUsers.ts @@ -66,7 +66,7 @@ registry.registerPath({ registry.registerPath({ method: "post", - path: "/private-resource/{siteResourceId}/users", + path: "/private-resource/{resourceId}/users", description: "Set users for a site resource. This will replace all existing users.", tags: [OpenAPITags.PrivateResource, OpenAPITags.User], From 19ce2362621bcd66057e841dce9ae9fb94ea1fa4 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 21 Aug 2026 17:22:00 -0400 Subject: [PATCH 14/42] Support AI session log streaming --- messages/en-US.json | 2 ++ server/db/pg/schema/privateSchema.ts | 3 ++ server/db/sqlite/schema/privateSchema.ts | 3 ++ .../lib/logStreaming/LogStreamingManager.ts | 34 ++++++++++++++++++- server/private/lib/logStreaming/types.ts | 5 +-- .../createEventStreamingDestination.ts | 6 ++-- .../listEventStreamingDestinations.ts | 4 ++- .../updateEventStreamingDestination.ts | 6 ++-- src/components/HttpDestinationCredenza.tsx | 30 +++++++++++++++- src/components/S3DestinationCredenza.tsx | 29 +++++++++++++++- 10 files changed, 112 insertions(+), 10 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 0c59990a7..62ed7f30a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4087,6 +4087,8 @@ "httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.", "httpDestRequestLogsTitle": "HTTP Request Logs", "httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.", + "httpDestAISessionLogsTitle": "AI Session Logs", + "httpDestAISessionLogsDescription": "AI gateway request and response sessions, including prompts, model responses, and token usage.", "httpDestSaveChanges": "Save Changes", "httpDestCreateDestination": "Create Destination", "httpDestUpdatedSuccess": "Destination updated successfully", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index e10b459e9..8e88527d6 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -468,6 +468,9 @@ export const eventStreamingDestinations = pgTable( sendRequestLogs: boolean("sendRequestLogs").notNull().default(false), sendActionLogs: boolean("sendActionLogs").notNull().default(false), sendAccessLogs: boolean("sendAccessLogs").notNull().default(false), + sendAISessionLogs: boolean("sendAISessionLogs") + .notNull() + .default(false), type: varchar("type", { length: 50 }).notNull(), // e.g. "http", "kafka", etc. config: text("config").notNull(), // JSON string with the configuration for the destination enabled: boolean("enabled").notNull().default(true), diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index da77bfed2..0003ae432 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -459,6 +459,9 @@ export const eventStreamingDestinations = sqliteTable( sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" }) .notNull() .default(false), + sendAISessionLogs: integer("sendAISessionLogs", { mode: "boolean" }) + .notNull() + .default(false), type: text("type").notNull(), // e.g. "http", "kafka", etc. config: text("config").notNull(), // JSON string with the configuration for the destination enabled: integer("enabled", { mode: "boolean" }) diff --git a/server/private/lib/logStreaming/LogStreamingManager.ts b/server/private/lib/logStreaming/LogStreamingManager.ts index 03efc2809..14ada27b0 100644 --- a/server/private/lib/logStreaming/LogStreamingManager.ts +++ b/server/private/lib/logStreaming/LogStreamingManager.ts @@ -19,7 +19,8 @@ import { requestAuditLog, actionAuditLog, accessAuditLog, - connectionAuditLog + connectionAuditLog, + aiSessionLog } from "@server/db"; import logger from "@server/logger"; import { and, eq, gt, desc, max, sql } from "drizzle-orm"; @@ -309,6 +310,7 @@ export class LogStreamingManager { if (dest.sendActionLogs) enabledTypes.push("action"); if (dest.sendAccessLogs) enabledTypes.push("access"); if (dest.sendConnectionLogs) enabledTypes.push("connection"); + if (dest.sendAISessionLogs) enabledTypes.push("aiSession"); if (enabledTypes.length === 0) return; @@ -585,6 +587,13 @@ export class LogStreamingManager { .where(eq(connectionAuditLog.orgId, orgId)); return row?.maxId ?? 0; } + case "aiSession": { + const [row] = await logsDb + .select({ maxId: max(aiSessionLog.id) }) + .from(aiSessionLog) + .where(eq(aiSessionLog.orgId, orgId)); + return row?.maxId ?? 0; + } } } catch (err) { logger.warn( @@ -670,6 +679,21 @@ export class LogStreamingManager { .limit(limit)) as Array< Record & { id: number } >; + + case "aiSession": + return (await logsDb + .select() + .from(aiSessionLog) + .where( + and( + eq(aiSessionLog.orgId, orgId), + gt(aiSessionLog.id, afterId) + ) + ) + .orderBy(aiSessionLog.id) + .limit(limit)) as Array< + Record & { id: number } + >; } } @@ -694,6 +718,14 @@ export class LogStreamingManager { timestamp = typeof row.startedAt === "number" ? row.startedAt : 0; break; + case "aiSession": + // createdAt is stored as epoch milliseconds; normalise to + // epoch seconds to match the other log types. + timestamp = + typeof row.createdAt === "number" + ? Math.floor(row.createdAt / 1000) + : 0; + break; } const orgId = typeof row.orgId === "string" ? row.orgId : ""; diff --git a/server/private/lib/logStreaming/types.ts b/server/private/lib/logStreaming/types.ts index 193a5ff6b..06d5603e9 100644 --- a/server/private/lib/logStreaming/types.ts +++ b/server/private/lib/logStreaming/types.ts @@ -15,13 +15,14 @@ // Log type identifiers // --------------------------------------------------------------------------- -export type LogType = "request" | "action" | "access" | "connection"; +export type LogType = "request" | "action" | "access" | "connection" | "aiSession"; export const LOG_TYPES: LogType[] = [ "request", "action", "access", - "connection" + "connection", + "aiSession" ]; // --------------------------------------------------------------------------- diff --git a/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts index 7b000c5d8..c7f28f48a 100644 --- a/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts +++ b/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts @@ -37,7 +37,8 @@ const bodySchema = z.strictObject({ sendConnectionLogs: z.boolean().optional().default(false), sendRequestLogs: z.boolean().optional().default(false), sendActionLogs: z.boolean().optional().default(false), - sendAccessLogs: z.boolean().optional().default(false) + sendAccessLogs: z.boolean().optional().default(false), + sendAISessionLogs: z.boolean().optional().default(false) }); export type CreateEventStreamingDestinationResponse = { @@ -122,7 +123,8 @@ export async function createEventStreamingDestination( sendAccessLogs: parsedBody.data.sendAccessLogs, sendActionLogs: parsedBody.data.sendActionLogs, sendConnectionLogs: parsedBody.data.sendConnectionLogs, - sendRequestLogs: parsedBody.data.sendRequestLogs + sendRequestLogs: parsedBody.data.sendRequestLogs, + sendAISessionLogs: parsedBody.data.sendAISessionLogs }) .returning(); diff --git a/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts b/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts index dc741d482..d0f3d75c2 100644 --- a/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts +++ b/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts @@ -60,6 +60,7 @@ export type ListEventStreamingDestinationsResponse = { sendRequestLogs: boolean; sendActionLogs: boolean; sendAccessLogs: boolean; + sendAISessionLogs: boolean; }[]; pagination: { total: number; @@ -83,7 +84,8 @@ const ListEventStreamingDestinationsResponseDataSchema = z.object({ sendConnectionLogs: z.boolean(), sendRequestLogs: z.boolean(), sendActionLogs: z.boolean(), - sendAccessLogs: z.boolean() + sendAccessLogs: z.boolean(), + sendAISessionLogs: z.boolean() }) ), pagination: z.object({ diff --git a/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts index 84202bf8e..ddf22cfba 100644 --- a/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts +++ b/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts @@ -40,7 +40,8 @@ const bodySchema = z.strictObject({ sendConnectionLogs: z.boolean().optional(), sendRequestLogs: z.boolean().optional(), sendActionLogs: z.boolean().optional(), - sendAccessLogs: z.boolean().optional() + sendAccessLogs: z.boolean().optional(), + sendAISessionLogs: z.boolean().optional() }); export type UpdateEventStreamingDestinationResponse = { @@ -125,7 +126,7 @@ export async function updateEventStreamingDestination( ); } - const { type, config: configToUpdate, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs } = parsedBody.data; + const { type, config: configToUpdate, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs, sendAISessionLogs } = parsedBody.data; const updateData: Record = { updatedAt: Date.now() @@ -141,6 +142,7 @@ export async function updateEventStreamingDestination( if (sendActionLogs !== undefined) updateData.sendActionLogs = sendActionLogs; if (sendConnectionLogs !== undefined) updateData.sendConnectionLogs = sendConnectionLogs; if (sendRequestLogs !== undefined) updateData.sendRequestLogs = sendRequestLogs; + if (sendAISessionLogs !== undefined) updateData.sendAISessionLogs = sendAISessionLogs; await db .update(eventStreamingDestinations) diff --git a/src/components/HttpDestinationCredenza.tsx b/src/components/HttpDestinationCredenza.tsx index 85d32fd5c..92b684756 100644 --- a/src/components/HttpDestinationCredenza.tsx +++ b/src/components/HttpDestinationCredenza.tsx @@ -57,6 +57,7 @@ export interface Destination { sendActionLogs: boolean; sendConnectionLogs: boolean; sendRequestLogs: boolean; + sendAISessionLogs: boolean; lastError: string | null; lastErrorAt: number | null; createdAt: number; @@ -180,6 +181,7 @@ export function HttpDestinationCredenza({ const [sendActionLogs, setSendActionLogs] = useState(false); const [sendConnectionLogs, setSendConnectionLogs] = useState(false); const [sendRequestLogs, setSendRequestLogs] = useState(false); + const [sendAISessionLogs, setSendAISessionLogs] = useState(false); useEffect(() => { if (open) { @@ -190,6 +192,7 @@ export function HttpDestinationCredenza({ setSendActionLogs(editing?.sendActionLogs ?? false); setSendConnectionLogs(editing?.sendConnectionLogs ?? false); setSendRequestLogs(editing?.sendRequestLogs ?? false); + setSendAISessionLogs(editing?.sendAISessionLogs ?? false); } }, [open, editing]); @@ -226,7 +229,8 @@ export function HttpDestinationCredenza({ sendAccessLogs, sendActionLogs, sendConnectionLogs, - sendRequestLogs + sendRequestLogs, + sendAISessionLogs }; if (editing) { await api.post( @@ -778,6 +782,30 @@ export function HttpDestinationCredenza({

+ +
+ + setSendAISessionLogs(v === true) + } + className="mt-0.5" + /> +
+ +

+ {t( + "httpDestAISessionLogsDescription" + )} +

+
+
diff --git a/src/components/S3DestinationCredenza.tsx b/src/components/S3DestinationCredenza.tsx index e6c128805..a66406f00 100644 --- a/src/components/S3DestinationCredenza.tsx +++ b/src/components/S3DestinationCredenza.tsx @@ -90,6 +90,7 @@ export function S3DestinationCredenza({ const [sendActionLogs, setSendActionLogs] = useState(false); const [sendConnectionLogs, setSendConnectionLogs] = useState(false); const [sendRequestLogs, setSendRequestLogs] = useState(false); + const [sendAISessionLogs, setSendAISessionLogs] = useState(false); useEffect(() => { if (open) { @@ -98,6 +99,7 @@ export function S3DestinationCredenza({ setSendActionLogs(editing?.sendActionLogs ?? false); setSendConnectionLogs(editing?.sendConnectionLogs ?? false); setSendRequestLogs(editing?.sendRequestLogs ?? false); + setSendAISessionLogs(editing?.sendAISessionLogs ?? false); } }, [open, editing]); @@ -121,7 +123,8 @@ export function S3DestinationCredenza({ sendAccessLogs, sendActionLogs, sendConnectionLogs, - sendRequestLogs + sendRequestLogs, + sendAISessionLogs }; if (editing) { await api.post( @@ -510,6 +513,30 @@ export function S3DestinationCredenza({

+ +
+ + setSendAISessionLogs(v === true) + } + className="mt-0.5" + /> +
+ +

+ {t( + "httpDestAISessionLogsDescription" + )} +

+
+
From 9b0e049a21b2cc84629b64f8201a518191ee1b3f Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 24 Aug 2026 10:44:54 -0400 Subject: [PATCH 15/42] Scope exit node creation to orgs --- server/lib/exitNodes/subnet.ts | 41 ++++++++++++------- .../remoteExitNode/createRemoteExitNode.ts | 15 +++++-- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/server/lib/exitNodes/subnet.ts b/server/lib/exitNodes/subnet.ts index 8c4f3e99e..15d986426 100644 --- a/server/lib/exitNodes/subnet.ts +++ b/server/lib/exitNodes/subnet.ts @@ -1,20 +1,26 @@ -import { db, exitNodes, Transaction } from "@server/db"; +import { db, exitNodes, exitNodeOrgs, Transaction } from "@server/db"; import config from "@server/lib/config"; import { findNextAvailableCidr } from "@server/lib/ip"; import { lockManager } from "#dynamic/lib/lock"; +import { eq } from "drizzle-orm"; /** * Reserves the next available exit node subnet. * - * Exit node subnets must never overlap with one another - regardless of - * which org(s) they belong to - since HA exit nodes can end up routing for - * the same org. This acquires a lock that the caller MUST release (via the - * returned `release`) only after the chosen address has been durably - * persisted (e.g. after the enclosing transaction commits), otherwise - * concurrent callers can race and pick the same subnet. + * There isn't enough address space to give every exit node in every org a + * globally unique subnet, so we only guarantee uniqueness among exit nodes + * that already belong to the same org - that's all that actually matters, + * since HA only routes multiple exit nodes for a single org. Pass `orgId` to + * scope the search to that org's existing exit nodes; without it, the search + * considers every exit node (used by flows with no org context, e.g. the + * initial gerbil exit node bootstrap). This acquires a lock that the caller + * MUST release (via the returned `release`) only after the chosen address + * has been durably persisted (e.g. after the enclosing transaction commits), + * otherwise concurrent callers can race and pick the same subnet. */ export async function getNextAvailableSubnet( - trx: Transaction | typeof db = db + trx: Transaction | typeof db = db, + orgId?: string ): Promise<{ value: string; release: () => Promise }> { const lockKey = "exit-node-subnet-allocation"; const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000); @@ -24,12 +30,19 @@ export async function getNextAvailableSubnet( const release = () => lockManager.releaseLock(lockKey, acquired); try { - // Get all existing subnets from routes table - const existingAddresses = await trx - .select({ - address: exitNodes.address - }) - .from(exitNodes); + // Get existing subnets, scoped to this org's exit nodes when known + const existingAddresses = orgId + ? await trx + .select({ address: exitNodes.address }) + .from(exitNodes) + .innerJoin( + exitNodeOrgs, + eq(exitNodeOrgs.exitNodeId, exitNodes.exitNodeId) + ) + .where(eq(exitNodeOrgs.orgId, orgId)) + : await trx + .select({ address: exitNodes.address }) + .from(exitNodes); const addresses = existingAddresses.map((a) => a.address); let subnet = findNextAvailableCidr( diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts index bf86ed107..3462e13ff 100644 --- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts @@ -191,13 +191,20 @@ export async function createRemoteExitNode( // If this remote exit node isn't already backing an exit node in // another org, we're about to create a brand new one. Reserve a - // subnet for it up front so the allocation lock is held across the - // whole insert - this guarantees exit node subnets never overlap, - // even under concurrent creation, which matters for HA setups. + // subnet for it up front, scoped to this org's existing exit nodes, + // so the allocation lock is held across the whole insert - this + // guarantees exit node subnets never overlap within the org, even + // under concurrent creation, which matters for HA setups. Subnets + // may still be reused across different orgs; there isn't enough + // address space to avoid that, and it isn't necessary since HA only + // routes multiple exit nodes for the same org. let releaseSubnetLock: (() => Promise) | null = null; let newExitNodeAddress: string | null = null; if (!existingExitNode) { - const { value, release } = await getNextAvailableSubnet(); + const { value, release } = await getNextAvailableSubnet( + db, + orgId + ); newExitNodeAddress = value; releaseSubnetLock = release; } From 8ae42e1852f000e9ed5bac1b0ade890dcee6dd9c Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 24 Aug 2026 11:18:09 -0400 Subject: [PATCH 16/42] Fix list users not respecting policy Fixes #3632 --- server/routers/resource/listResourceUsers.ts | 41 ++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/server/routers/resource/listResourceUsers.ts b/server/routers/resource/listResourceUsers.ts index afabd3052..9a39444d2 100644 --- a/server/routers/resource/listResourceUsers.ts +++ b/server/routers/resource/listResourceUsers.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { idp, userResources, users } from "@server/db"; // Assuming these are the correct tables +import { idp, resources, userPolicies, userResources, users } from "@server/db"; // Assuming these are the correct tables import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -14,7 +14,23 @@ const listResourceUsersSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); -async function queryUsers(resourceId: number) { +async function queryUsers(resourceId: number, policyId: number | null) { + if (policyId !== null) { + return await db + .select({ + userId: userPolicies.userId, + username: users.username, + type: users.type, + idpName: idp.name, + idpId: users.idpId, + email: users.email + }) + .from(userPolicies) + .innerJoin(users, eq(userPolicies.userId, users.userId)) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .where(eq(userPolicies.resourcePolicyId, policyId)); + } + return await db .select({ userId: userResources.userId, @@ -104,7 +120,26 @@ export async function listResourceUsers( const { resourceId } = parsedParams.data; - const resourceUsersList = await queryUsers(resourceId); + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; + + const resourceUsersList = await queryUsers( + resourceId, + isInlinePolicy ? resource.defaultResourcePolicyId! : null + ); return response(res, { data: { From 7d2af1837edcf062fa27431bf42b0b8ae5681445 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:21 -0400 Subject: [PATCH 17/42] New translations en-us.json (French) [ci skip] --- messages/fr-FR.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 635e26b6d..1654279ee 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Outil de codage agentique d'Anthropic pour le terminal.", "aiClientConfigDescriptionCodex": "Outil de codage agentique d'OpenAI pour le terminal.", "aiClientConfigDescriptionOpencode": "Agent de codage terminal open source.", - "aiClientConfigDescriptionCursor": "Éditeur de code IA basé sur VS Code.", + "aiClientConfigDescriptionGemini": "Outil de codage agentique de Google pour le terminal.", "aiClientConfigSetup": "Configuration", "aiClientConfigTabCli": "Automatique (CLI)", "aiClientConfigTabManual": "Configuration manuelle", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "Route à travers les cibles sur vos sites", "aiProviderRoutingModeTargetNote": "Après avoir créé ce fournisseur, configurez les cibles du site dans l'onglet Paramètres du réseau.", "aiProviderTargetNoOne": "Ce fournisseur n'a aucune cible. Ajoutez une cible pour acheminer les requêtes via vos sites.", + "aiProviderRemoteNodeTargetsWarning": "Les sites connectés à des nœuds distants sont inaccessibles pour être routés vers les fournisseurs de passerelles AI.", "aiProviderSkipTlsVerification": "Ignorer la vérification TLS", "aiProviderSkipTlsVerificationDescription": "Désactiver la vérification du certificat TLS pour la connexion amont", "aiProviderBudget": "Budget", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "Prend en charge /v1/responses", "aiCapabilityAnthropicMessages": "Messages Anthropiques", "aiCapabilityAnthropicMessagesDescription": "Prend en charge /v1/messages", + "aiCapabilityV1Models": "Liste des modèles", + "aiCapabilityV1ModelsDescription": "Prise en charge de la découverte de modèles /v1/models", "aiCapabilityGeminiGenerateContent": "Générer du Contenu Gemini", "aiCapabilityGeminiGenerateContentDescription": "Prend en charge l'API directe de Gemini", "aiCapabilityBedrockModelInvoke": "Invocation du Modèle Bedrock", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "Journaux des actions", "logRetention": "Journaliser la rétention", "logRetentionDescription": "Gérer la durée de conservation des différents types de logs pour cette organisation ou les désactiver", + "logRetentionDisabledWarningTitle": "Conservation des journaux désactivée", + "logRetentionDisabledWarningDescription": "{logType} ne sont pas conservés pour cette organisation, donc aucune nouvelle activité n’apparaîtra ici. Activez la conservation dans les paramètres de sécurité pour commencer à collecter ces journaux.", + "logRetentionDisabledWarningButton": "Aller aux paramètres de sécurité", "requestLogsDescription": "Voir les journaux détaillés des requêtes pour les ressources de cette organisation", "aiSessionLogs": "Journaux de Session du Portail AI", "aiSessionLogsDescription": "Voir les transcriptions de l'invite et de la réponse pour les requêtes de portail AI dans cette organisation", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "Événements de connexion du site et du tunnel, y compris les connexions et les déconnexions.", "httpDestRequestLogsTitle": "Journal des Requêtes HTTP", "httpDestRequestLogsDescription": "Journaux des requêtes HTTP pour les ressources proxiées, y compris la méthode, le chemin et le code de réponse.", + "httpDestAISessionLogsTitle": "Journaux de session AI", + "httpDestAISessionLogsDescription": "Sessions de requête et de réponse de la passerelle AI, y compris les invites, les réponses du modèle et l'utilisation des jetons.", "httpDestSaveChanges": "Enregistrer les modifications", "httpDestCreateDestination": "Créer une destination", "httpDestUpdatedSuccess": "Destination mise à jour avec succès", From 547ac2284a6cf3d6454059d919c63e865c9dff50 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:24 -0400 Subject: [PATCH 18/42] New translations en-us.json (Spanish) [ci skip] --- messages/es-ES.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/es-ES.json b/messages/es-ES.json index e4cb80639..c027f8dfe 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Herramienta de codificación agentic de Anthropic para el terminal.", "aiClientConfigDescriptionCodex": "Herramienta de codificación agentic de OpenAI para el terminal.", "aiClientConfigDescriptionOpencode": "Agente de codificación de terminal de código abierto.", - "aiClientConfigDescriptionCursor": "Editor de código AI construido sobre VS Code.", + "aiClientConfigDescriptionGemini": "La herramienta de codificación de agente de Google para el terminal.", "aiClientConfigSetup": "Configuración", "aiClientConfigTabCli": "Automático (CLI)", "aiClientConfigTabManual": "Configuración manual", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "Ruta a través de objetivos en sus sitios", "aiProviderRoutingModeTargetNote": "Después de crear este proveedor, configure objetivos de sitio en la pestaña de Configuración de Red.", "aiProviderTargetNoOne": "Este proveedor no tiene objetivos. Agregue un objetivo para enrutar solicitudes a través de sus sitios.", + "aiProviderRemoteNodeTargetsWarning": "Los sitios conectados a nodos remotos son inaccesibles para ser enrutados a los proveedores de AI Gateway.", "aiProviderSkipTlsVerification": "Omitir verificación de TLS", "aiProviderSkipTlsVerificationDescription": "Deshabilitar la verificación del certificado TLS para la conexión de upstream", "aiProviderBudget": "Presupuesto", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "Admite /v1/responses", "aiCapabilityAnthropicMessages": "Mensajes Antropicos", "aiCapabilityAnthropicMessagesDescription": "Admite /v1/messages", + "aiCapabilityV1Models": "Lista de Modelos", + "aiCapabilityV1ModelsDescription": "Soporta el descubrimiento de modelos /v1/models", "aiCapabilityGeminiGenerateContent": "Generar contenido Gemini", "aiCapabilityGeminiGenerateContentDescription": "Admite la API directa de Gemini", "aiCapabilityBedrockModelInvoke": "Invocar modelo de Bedrock", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "Registros de acción", "logRetention": "Retención de Log", "logRetentionDescription": "Administrar cuánto tiempo se conservan los diferentes tipos de registros para esta organización o desactivarlos", + "logRetentionDisabledWarningTitle": "Retención de Registros Deshabilitada", + "logRetentionDisabledWarningDescription": "{logType} no se están reteniendo para esta organización, por lo que la nueva actividad no aparecerá aquí. Habilita la retención en la configuración de seguridad para comenzar a recopilar estos registros.", + "logRetentionDisabledWarningButton": "Ir a Configuración de Seguridad", "requestLogsDescription": "Ver registros de solicitudes detallados para los recursos de esta organización", "aiSessionLogs": "Registros de Sesiones del Portal de IA", "aiSessionLogsDescription": "Ver transcripciones de solicitud y respuesta para solicitudes del portal de IA en esta organización", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "Eventos de conexión de sitios y túneles, incluyendo conexiones y desconexiones.", "httpDestRequestLogsTitle": "Registros de Solicitud HTTP", "httpDestRequestLogsDescription": "Registros de peticiones HTTP para recursos proxyficados, incluyendo método, ruta y código de respuesta.", + "httpDestAISessionLogsTitle": "Registros de Sesión AI", + "httpDestAISessionLogsDescription": "Sesiones de solicitud y respuesta de AI gateway, incluyendo indicaciones, respuestas de modelos, y uso de tokens.", "httpDestSaveChanges": "Guardar Cambios", "httpDestCreateDestination": "Crear destino", "httpDestUpdatedSuccess": "Destino actualizado correctamente", From 262aaa27562fe0a8cf4cfff0a4d37c90e0d531e2 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:26 -0400 Subject: [PATCH 19/42] New translations en-us.json (Bulgarian) [ci skip] --- messages/bg-BG.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 1e74971af..45b5e444a 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Инструментът за кодиране на Anthropic за терминала.", "aiClientConfigDescriptionCodex": "Инструментът за кодиране на OpenAI за терминала.", "aiClientConfigDescriptionOpencode": "Отворен кодиращ агент за терминал.", - "aiClientConfigDescriptionCursor": "AI редактор на код, базиран на VS Code.", + "aiClientConfigDescriptionGemini": "Агентски инструмент на Google за кодиране на терминал.", "aiClientConfigSetup": "Настройка", "aiClientConfigTabCli": "Автоматичен (CLI)", "aiClientConfigTabManual": "Ръчна конфигурация", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "Маршрутиране чрез цели на вашите сайтове", "aiProviderRoutingModeTargetNote": "След създаването на този доставчик, конфигурирайте целите на сайта в раздела Настройки на мрежата.", "aiProviderTargetNoOne": "Този доставчик няма цели. Добавете цел за маршрутиране на заявки чрез вашите сайтове.", + "aiProviderRemoteNodeTargetsWarning": "Уебсайтовете, свързани с отдалечени възли, са недостъпни за пренасочване към AI Gateway доставчици.", "aiProviderSkipTlsVerification": "Пропуснете проверката на TLS", "aiProviderSkipTlsVerificationDescription": "Деактивирайте проверката на TLS сертификат за възходящото свързване", "aiProviderBudget": "Бюджет", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "Поддържа /v1/responses", "aiCapabilityAnthropicMessages": "Anthropic Съобщения", "aiCapabilityAnthropicMessagesDescription": "Поддържа /v1/messages", + "aiCapabilityV1Models": "Списък на модели", + "aiCapabilityV1ModelsDescription": "Поддържа /v1/models откриване на модели", "aiCapabilityGeminiGenerateContent": "Gemini Генериране на Съдържание", "aiCapabilityGeminiGenerateContentDescription": "Поддържа директния Gemini API", "aiCapabilityBedrockModelInvoke": "Бедрок Модел Активирай", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "Дневници на действията", "logRetention": "Задържане на логове", "logRetentionDescription": "Управлявайте времето за задържане на различни видове логове за тази организация или ги деактивирайте", + "logRetentionDisabledWarningTitle": "Деактивирано съхранение на дневници", + "logRetentionDisabledWarningDescription": "{logType} не се съхраняват за тази организация, така че новите дейности няма да се показват тук. Активирайте съхранението в настройките за сигурност, за да започнете събирането на тези дневници.", + "logRetentionDisabledWarningButton": "Отидете на настройки за сигурност", "requestLogsDescription": "Прегледайте подробни логове на заявки за ресурси в тази организация", "aiSessionLogs": "Журнали на AI Портал Сесиите", "aiSessionLogsDescription": "Прегледайте подканянета и транскрипции на отговори за запитванията към AI портал в тази организация", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "Събития на свързване и прекъсване на сайта и тунела, включително свръзки и прекъсвания.", "httpDestRequestLogsTitle": "Логове за HTTP заявки", "httpDestRequestLogsDescription": "Регистри за HTTP заявките към проксирани ресурси, включително метод, път и код на отговор.", + "httpDestAISessionLogsTitle": "Дневници за AI сесии", + "httpDestAISessionLogsDescription": "AI заявки до шлюза и отговори на сесии, включително подканвания, отговори на модели и използване на жетони.", "httpDestSaveChanges": "Запази промените", "httpDestCreateDestination": "Създаване на дестинация", "httpDestUpdatedSuccess": "Дестинацията беше актуализирана успешно", From d237d6545eb233c69da4b209f16fee53a68b155d Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:28 -0400 Subject: [PATCH 20/42] New translations en-us.json (Czech) [ci skip] --- messages/cs-CZ.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 16912f1b8..ba5c60c17 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Antropický agentický kódovací nástroj pro terminál.", "aiClientConfigDescriptionCodex": "Agentický kódovací nástroj OpenAI pro terminál.", "aiClientConfigDescriptionOpencode": "Open source terminální kódovací agent.", - "aiClientConfigDescriptionCursor": "AI editor kódu postavený na VS Code.", + "aiClientConfigDescriptionGemini": "Agentický nástroj Google pro kódování v terminálu.", "aiClientConfigSetup": "Nastavení", "aiClientConfigTabCli": "Automatické (CLI)", "aiClientConfigTabManual": "Ruční konfigurace", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "Směrujte přes cíle na svých stránkách", "aiProviderRoutingModeTargetNote": "Po vytvoření tohoto poskytovatele, nakonfigurujte cíle stránek na záložce Nastavení sítě.", "aiProviderTargetNoOne": "Tento poskytovatel nemá žádné cíle. Přidejte cíl pro směrování požadavků přes vaše stránky.", + "aiProviderRemoteNodeTargetsWarning": "Stránky připojené k vzdáleným uzlům nejsou dostupné pro přesměrování na poskytovatele AI Gateway.", "aiProviderSkipTlsVerification": "Přeskočit ověření TLS", "aiProviderSkipTlsVerificationDescription": "Zakázat ověření certifikátu TLS pro upstream připojení", "aiProviderBudget": "Rozpočet", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "Podporuje /v1/responses", "aiCapabilityAnthropicMessages": "Zprávy Anthropic", "aiCapabilityAnthropicMessagesDescription": "Podporuje /v1/messages", + "aiCapabilityV1Models": "Seznam modelů", + "aiCapabilityV1ModelsDescription": "Podporuje objevování modelů /v1/models", "aiCapabilityGeminiGenerateContent": "Generování obsahu Gemini", "aiCapabilityGeminiGenerateContentDescription": "Podporuje přímé API Gemini", "aiCapabilityBedrockModelInvoke": "Vyvolání modelu Bedrock", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "Záznamy akcí", "logRetention": "Zaznamenávání záznamu", "logRetentionDescription": "Spravovat, jak dlouho jsou různé typy logů uloženy pro tuto organizaci nebo je zakázat", + "logRetentionDisabledWarningTitle": "Zakázáno uchování logů", + "logRetentionDisabledWarningDescription": "{logType} nejsou uchovávány pro tuto organizaci, takže nová aktivita se zde neprojeví. Aktivujte uchovávání v nastavení zabezpečení pro zahájení sběru těchto logů.", + "logRetentionDisabledWarningButton": "Přejít na nastavení zabezpečení", "requestLogsDescription": "Zobrazit podrobné protokoly požadavků pro zdroje v této organizaci", "aiSessionLogs": "Protokoly AI Gateway Session", "aiSessionLogsDescription": "Zobrazit uložené výzvy a odpovědi na žádosti AI brány v této organizaci", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "Události týkající se připojení lokality a tunelu, včetně připojení a odpojení.", "httpDestRequestLogsTitle": "Záznamy HTTP požadavků", "httpDestRequestLogsDescription": "HTTP záznamy požadavků pro proxy zdroje, včetně metod, cesty a kódu odpovědi.", + "httpDestAISessionLogsTitle": "Logy AI sezení", + "httpDestAISessionLogsDescription": "Relace požadavků a odpovědí AI gateway, včetně podnětů, odpovědí modelů a využití tokenů.", "httpDestSaveChanges": "Uložit změny", "httpDestCreateDestination": "Vytvořit cíl", "httpDestUpdatedSuccess": "Cíl byl úspěšně aktualizován", From 90a77ee4505f079365e47431268c5ffeb850f573 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:30 -0400 Subject: [PATCH 21/42] New translations en-us.json (Danish) [ci skip] --- messages/da-DK.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/da-DK.json b/messages/da-DK.json index 06a75ee44..bdc1e5260 100644 --- a/messages/da-DK.json +++ b/messages/da-DK.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Anthropics agentikodningsværktøj til terminalen.", "aiClientConfigDescriptionCodex": "OpenAIs agentikodningsværktøj til terminalen.", "aiClientConfigDescriptionOpencode": "Open source terminal kodningsagent.", - "aiClientConfigDescriptionCursor": "AI-kodeeditor bygget på VS Code.", + "aiClientConfigDescriptionGemini": "Googles agentiske kodningsværktøj til terminalen.", "aiClientConfigSetup": "Opsætning", "aiClientConfigTabCli": "Automatisk (CLI)", "aiClientConfigTabManual": "Manuel Konfiguration", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "Rute gennem mål på dine steder", "aiProviderRoutingModeTargetNote": "Efter oprettelse af denne udbyder, konfigurer mål på Netværksindstillinger fanen.", "aiProviderTargetNoOne": "Denne udbyder har ingen mål. Tilføj et mål for at rute forespørgsler gennem dine steder.", + "aiProviderRemoteNodeTargetsWarning": "Websteder, der er forbundet til eksterne noder, kan ikke tilgås for at blive dirigeret til via AI Gateway-udbydere.", "aiProviderSkipTlsVerification": "Spring TLS-verifikation over", "aiProviderSkipTlsVerificationDescription": "Deaktiver TLS-certifikat verifikation for opstrømsforbindelsen", "aiProviderBudget": "Budget", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "Understøtter /v1/responses", "aiCapabilityAnthropicMessages": "Anthropic Beskeder", "aiCapabilityAnthropicMessagesDescription": "Understøtter /v1/messages", + "aiCapabilityV1Models": "Model Liste", + "aiCapabilityV1ModelsDescription": "Understøtter opdagelse af /v1/models modeller", "aiCapabilityGeminiGenerateContent": "Gemini Generer Indhold", "aiCapabilityGeminiGenerateContentDescription": "Understøtter den direkte Gemini API", "aiCapabilityBedrockModelInvoke": "Bedrock Modeller Invoker", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "Handlingsloger", "logRetention": "Logopbevaring", "logRetentionDescription": "Håndter hvor længe ulike typer logs beholdes for denne organisation, eller deaktivér dem", + "logRetentionDisabledWarningTitle": "Logbevaring deaktiveret", + "logRetentionDisabledWarningDescription": "{logType} gemmes ikke for denne organisation, så nye aktiviteter vises ikke her. Aktiver logbevaring i sikkerhedsindstillingerne for at begynde at indsamle disse logs.", + "logRetentionDisabledWarningButton": "Gå til sikkerhedsindstillinger", "requestLogsDescription": "Se detaljerede forespørgselslogs for ressourcer i denne organisation", "aiSessionLogs": "AI Gateway Øktsprotokoller", "aiSessionLogsDescription": "Se prompt og svarudskrifter for AI gateway forespørgsler i denne organisation", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "Udstyrs- og tunnelforbindelseshændelser, inklusive forbindelser og frakobling.", "httpDestRequestLogsTitle": "HTTP-forespørgselslogs", "httpDestRequestLogsDescription": "HTTP-forespørgsel logs for bekræftede ressourcer, inklusive metode, sti og responskode.", + "httpDestAISessionLogsTitle": "AI-session Logs", + "httpDestAISessionLogsDescription": "AI-gateway anmodninger og respons-sessioner, inklusive prompts, modelresponser og tokenforbrug.", "httpDestSaveChanges": "Gem ændringer", "httpDestCreateDestination": "Opret mål", "httpDestUpdatedSuccess": "Målet er opdateret", From 4aa43fd14d0cefb602ecdc8ed51316556efec343 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:32 -0400 Subject: [PATCH 22/42] New translations en-us.json (German) [ci skip] --- messages/de-DE.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 8a66310d6..a41fadfc3 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Agentisches Codierwerkzeug von Anthropic für das Terminal.", "aiClientConfigDescriptionCodex": "Agentisches Codierwerkzeug von OpenAI für das Terminal.", "aiClientConfigDescriptionOpencode": "Open-Source-Coding-Agent für das Terminal.", - "aiClientConfigDescriptionCursor": "KI-Code-Editor basierend auf VS Code.", + "aiClientConfigDescriptionGemini": "Googles agentic Coding-Tool für das Terminal.", "aiClientConfigSetup": "Einrichtung", "aiClientConfigTabCli": "Automatisch (CLI)", "aiClientConfigTabManual": "Manuelle Konfiguration", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "Über Ziele auf Ihren Sites routen", "aiProviderRoutingModeTargetNote": "Konfigurieren Sie nach der Erstellung dieses Anbieters Site-Ziele auf der Registerkarte 'Netzwerkeinstellungen'.", "aiProviderTargetNoOne": "Dieser Anbieter hat keine Ziele. Fügen Sie ein Ziel hinzu, um Anfragen über Ihre Sites zu leiten.", + "aiProviderRemoteNodeTargetsWarning": "Sites, die mit entfernten Knoten verbunden sind, können bei AI-Gateway-Anbietern nicht weitergeleitet werden.", "aiProviderSkipTlsVerification": "TLS-Überprüfung überspringen", "aiProviderSkipTlsVerificationDescription": "TLS-Zertifikatsüberprüfung für die Upstream-Verbindung deaktivieren", "aiProviderBudget": "Budget", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "Unterstützt /v1/antworten", "aiCapabilityAnthropicMessages": "Anthropic Nachrichten", "aiCapabilityAnthropicMessagesDescription": "Unterstützt /v1/nachrichten", + "aiCapabilityV1Models": "Modellliste", + "aiCapabilityV1ModelsDescription": "Unterstützt /v1/models Modellentdeckung", "aiCapabilityGeminiGenerateContent": "Gemini Inhalt erzeugen", "aiCapabilityGeminiGenerateContentDescription": "Unterstützt die direkte Gemini-API", "aiCapabilityBedrockModelInvoke": "Bedrock Modell Aufruf", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "Aktionsprotokolle", "logRetention": "Log-Speicherung", "logRetentionDescription": "Verwalten, wie lange verschiedene Logs für diese Organisation gespeichert werden oder deaktivieren", + "logRetentionDisabledWarningTitle": "Protokoll-Aufbewahrung deaktiviert", + "logRetentionDisabledWarningDescription": "{logType} werden für diese Organisation nicht aufbewahrt, daher erscheinen neue Aktivitäten hier nicht. Aktivieren Sie die Aufbewahrung in den Sicherheitseinstellungen, um diese Protokolle zu sammeln.", + "logRetentionDisabledWarningButton": "Zu den Sicherheitseinstellungen gehen", "requestLogsDescription": "Detaillierte Request-Logs für Ressourcen in dieser Organisation anzeigen", "aiSessionLogs": "AI-Gateway Sitzungsprotokolle", "aiSessionLogsDescription": "Zeigen Sie Aufforderungs- und Antwortprotokolle für Anfragen des KI-Gateways in dieser Organisation an", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "Site- und Tunnelverbindungen, einschließlich Verbindungen und Trennungen.", "httpDestRequestLogsTitle": "HTTP Anforderungsprotokolle", "httpDestRequestLogsDescription": "HTTP-Request-Protokolle für proxiierte Ressourcen, einschließlich Methode, Pfad und Antwort-Code.", + "httpDestAISessionLogsTitle": "AI-Sitzungsprotokolle", + "httpDestAISessionLogsDescription": "AI-Gateway-Anfrage- und Antwortsitzungen, einschließlich Eingabeaufforderungen, Modellantworten und Token-Nutzung.", "httpDestSaveChanges": "Änderungen speichern", "httpDestCreateDestination": "Ziel erstellen", "httpDestUpdatedSuccess": "Ziel erfolgreich aktualisiert", From 85257b941b0ffea93f54eafa664c5d3eb6e698be Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:34 -0400 Subject: [PATCH 23/42] New translations en-us.json (Italian) [ci skip] --- messages/it-IT.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/it-IT.json b/messages/it-IT.json index 041ae497b..ecbe771b1 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Strumento di coding agente di Anthropic per il terminale.", "aiClientConfigDescriptionCodex": "Strumento di coding agente di OpenAI per il terminale.", "aiClientConfigDescriptionOpencode": "Agente di coding open source per il terminale.", - "aiClientConfigDescriptionCursor": "Editor di codice AI basato su VS Code.", + "aiClientConfigDescriptionGemini": "Lo strumento di codifica agentica di Google per il terminale.", "aiClientConfigSetup": "Impostazione", "aiClientConfigTabCli": "Automatico (CLI)", "aiClientConfigTabManual": "Configurazione Manuale", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "Instrada tramite target sui tuoi siti", "aiProviderRoutingModeTargetNote": "Dopo aver creato questo provider, configura i target del sito nella scheda Impostazioni di Rete.", "aiProviderTargetNoOne": "Questo provider non ha alcun target. Aggiungi un target per instradare le richieste attraverso i tuoi siti.", + "aiProviderRemoteNodeTargetsWarning": "I siti collegati a nodi remoti non sono accessibili per essere instradati sui fornitori di AI Gateway.", "aiProviderSkipTlsVerification": "Salta la verifica TLS", "aiProviderSkipTlsVerificationDescription": "Disabilita la verifica del certificato TLS per la connessione a monte", "aiProviderBudget": "Budget", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "Supporta /v1/responses", "aiCapabilityAnthropicMessages": "Messaggi Anthropic", "aiCapabilityAnthropicMessagesDescription": "Supporta /v1/messages", + "aiCapabilityV1Models": "Elenco dei Modelli", + "aiCapabilityV1ModelsDescription": "Supporta la scoperta del modello /v1/models", "aiCapabilityGeminiGenerateContent": "Generazione di Contenuti Gemini", "aiCapabilityGeminiGenerateContentDescription": "Supporta l'API diretta di Gemini", "aiCapabilityBedrockModelInvoke": "Invoca Modello Bedrock", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "Log Azioni", "logRetention": "Ritenzione Registro", "logRetentionDescription": "Gestisci per quanto tempo i diversi tipi di log sono mantenuti per questa organizzazione o disabilitali", + "logRetentionDisabledWarningTitle": "Conservazione del Log Disabilitata", + "logRetentionDisabledWarningDescription": "{logType} non vengono conservati per questa organizzazione, quindi le nuove attività non appariranno qui. Abilita la conservazione nelle impostazioni di sicurezza per iniziare a raccogliere questi log.", + "logRetentionDisabledWarningButton": "Vai alle Impostazioni di Sicurezza", "requestLogsDescription": "Visualizza i registri di richiesta dettagliati per le risorse in questa organizzazione", "aiSessionLogs": "Log delle Sessioni AI Gateway", "aiSessionLogsDescription": "Visualizza trascrizioni di prompt e risposte per le richieste del gateway AI in questa organizzazione", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "Eventi di connessione al sito e al tunnel, inclusi collegamenti e disconnessioni.", "httpDestRequestLogsTitle": "Log Richieste HTTP", "httpDestRequestLogsDescription": "Registri di richiesta HTTP per le risorse proxy, inclusi metodo, percorso e codice di risposta.", + "httpDestAISessionLogsTitle": "Log di Sessione AI", + "httpDestAISessionLogsDescription": "Sessioni di richiesta e risposta AI gateway, comprese le domande, le risposte del modello e l'utilizzo dei token.", "httpDestSaveChanges": "Salva Modifiche", "httpDestCreateDestination": "Crea Destinazione", "httpDestUpdatedSuccess": "Destinazione aggiornata con successo", From 1831b1af58a3a0a20786afec18d74026264fba3c Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:36 -0400 Subject: [PATCH 24/42] New translations en-us.json (Korean) [ci skip] --- messages/ko-KR.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index e33f65f95..b334f3bc0 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Anthropic의 터미널 에이전트 코딩 도구입니다.", "aiClientConfigDescriptionCodex": "OpenAI의 터미널 에이전트 코딩 도구입니다.", "aiClientConfigDescriptionOpencode": "오픈 소스 터미널 코딩 에이전트.", - "aiClientConfigDescriptionCursor": "VS Code를 기반으로 한 AI 코드 편집기.", + "aiClientConfigDescriptionGemini": "터미널용 구글의 에이전시 코딩 도구.", "aiClientConfigSetup": "설정", "aiClientConfigTabCli": "자동 (CLI)", "aiClientConfigTabManual": "수동 구성", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "사이트의 타겟을 통해 라우트", "aiProviderRoutingModeTargetNote": "이 공급자를 생성한 후 네트워크 설정 탭에 사이트 타겟을 구성합니다.", "aiProviderTargetNoOne": "이 공급자에게 타겟이 없습니다. 사이트를 통해 요청을 라우트하기 위한 타겟을 추가하십시오.", + "aiProviderRemoteNodeTargetsWarning": "원격 노드에 연결된 사이트는 AI 게이트웨이 공급자에게 라우팅되지 않습니다.", "aiProviderSkipTlsVerification": "TLS 검증 건너뛰기", "aiProviderSkipTlsVerificationDescription": "상류 연결에 대한 TLS 인증서 검증 비활성화", "aiProviderBudget": "예산", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "/v1/responses 지원", "aiCapabilityAnthropicMessages": "Anthropic 메시지", "aiCapabilityAnthropicMessagesDescription": "/v1/messages 지원", + "aiCapabilityV1Models": "모델 목록", + "aiCapabilityV1ModelsDescription": "/v1/models 모델 검색 지원", "aiCapabilityGeminiGenerateContent": "Gemini 콘텐츠 생성", "aiCapabilityGeminiGenerateContentDescription": "직접 Gemini API 지원", "aiCapabilityBedrockModelInvoke": "Bedrock 모델 실행", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "작업 로그", "logRetention": "로그 보관", "logRetentionDescription": "다양한 유형의 로그를 이 조직에 대해 얼마나 오래 보관할지 관리하거나 비활성화합니다", + "logRetentionDisabledWarningTitle": "로그 보존 비활성화", + "logRetentionDisabledWarningDescription": "{logType}이/가 이 조직에 대해 보존되지 않으므로 새로운 활동이 여기에 나타나지 않습니다. 보안을 설정해서 보존을 활성화하여 이러한 로그를 수집하기 시작하세요.", + "logRetentionDisabledWarningButton": "보안 설정으로 이동", "requestLogsDescription": "이 조직의 자원에 대한 상세한 요청 로그를 봅니다", "aiSessionLogs": "AI 게이트웨이 세션 로그", "aiSessionLogsDescription": "이 조직의 AI 게이트웨이 요청에 대한 프롬프트 및 응답 대본을 봅니다", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "사이트 및 터널 연결 이벤트, 연결 및 연결 끊기를 포함합니다.", "httpDestRequestLogsTitle": "HTTP 요청 로그", "httpDestRequestLogsDescription": "프록시된 리소스에 대한 HTTP 요청 로그, 메서드, 경로 및 응답 코드를 포함합니다.", + "httpDestAISessionLogsTitle": "AI 세션 로그", + "httpDestAISessionLogsDescription": "AI 게이트웨이 요청 및 응답 세션, 프롬프트, 모델 응답 및 토큰 사용을 포함합니다.", "httpDestSaveChanges": "변경 사항 저장", "httpDestCreateDestination": "대상지 생성", "httpDestUpdatedSuccess": "대상지가 성공적으로 업데이트되었습니다", From 0f00a2337dff93288ce764d5f8020ad736f4ac0d Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:38 -0400 Subject: [PATCH 25/42] New translations en-us.json (Dutch) [ci skip] --- messages/nl-NL.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 41cbfc38a..16224ef12 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Anthropic's agentische coderingstool voor de terminal.", "aiClientConfigDescriptionCodex": "OpenAI's agentische coderingstool voor de terminal.", "aiClientConfigDescriptionOpencode": "Open source terminal coderingsagent.", - "aiClientConfigDescriptionCursor": "AI-code-editor gebouwd op VS Code.", + "aiClientConfigDescriptionGemini": "Agentisch coderingstool van Google voor de terminal.", "aiClientConfigSetup": "Instellen", "aiClientConfigTabCli": "Automatisch (CLI)", "aiClientConfigTabManual": "Handmatige configuratie", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "Routeer door doelen op uw sites", "aiProviderRoutingModeTargetNote": "Nadat u deze provider hebt aangemaakt, configureert u site-doelen op het tabblad Netwerkinstellingen.", "aiProviderTargetNoOne": "Deze provider heeft geen doelen. Voeg een doel toe om verzoeken via uw sites te routeren.", + "aiProviderRemoteNodeTargetsWarning": "Sites die verbonden zijn met externe nodes zijn niet toegankelijk om naar te worden gerouteerd op AI Gateway-providers.", "aiProviderSkipTlsVerification": "Sla TLS-verificatie over", "aiProviderSkipTlsVerificationDescription": "Schakel TLS-certificaatverificatie voor de upstream-verbinding uit", "aiProviderBudget": "Budget", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "Ondersteunt /v1/responses", "aiCapabilityAnthropicMessages": "Anthropic Berichten", "aiCapabilityAnthropicMessagesDescription": "Ondersteunt /v1/messages", + "aiCapabilityV1Models": "Modellenlijst", + "aiCapabilityV1ModelsDescription": "Ondersteunt /v1/models modelontdekking", "aiCapabilityGeminiGenerateContent": "Gemini Inhoud Genereren", "aiCapabilityGeminiGenerateContentDescription": "Ondersteunt de directe Gemini API", "aiCapabilityBedrockModelInvoke": "Bedrock Model Aanroep", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "Actie logs", "logRetention": "Log bewaring", "logRetentionDescription": "Beheren hoe lang verschillende soorten logs bewaard worden voor deze organisatie of schakel ze uit", + "logRetentionDisabledWarningTitle": "Logboekbewaring Uitgeschakeld", + "logRetentionDisabledWarningDescription": "{logType} worden niet bewaard voor deze organisatie, dus nieuwe activiteiten zullen hier niet verschijnen. Schakel bewaren in beveiligingsinstellingen in om deze logboeken te verzamelen.", + "logRetentionDisabledWarningButton": "Ga naar Beveiligingsinstellingen", "requestLogsDescription": "Bekijk gedetailleerde verzoeklogboeken voor resources in deze organisatie", "aiSessionLogs": "AI Gateway Sessie Logs", "aiSessionLogsDescription": "Bekijk prompt- en reactie-transcripten voor AI-gateway-aanvragen in deze organisatie", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "Verbinding met de Site en tunnel maken verbroken, inclusief verbindingen en verbindingen.", "httpDestRequestLogsTitle": "HTTP-aanvraaglogboeken", "httpDestRequestLogsDescription": "HTTP request logs voor proxied hulpmiddelen, waaronder methode, pad en response code.", + "httpDestAISessionLogsTitle": "AI Sessielogboeken", + "httpDestAISessionLogsDescription": "AI gateway verzoek- en reactiesessies, inclusief prompts, modelreacties en tokengebruik.", "httpDestSaveChanges": "Wijzigingen opslaan", "httpDestCreateDestination": "Maak bestemming aan", "httpDestUpdatedSuccess": "Bestemming succesvol bijgewerkt", From c3140c5da361b88bdfd8a43f790956322aad560d Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:40 -0400 Subject: [PATCH 26/42] New translations en-us.json (Polish) [ci skip] --- messages/pl-PL.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 39c49f445..eab578797 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Agent narzędzia kodującego Anthropic dla terminala.", "aiClientConfigDescriptionCodex": "Agent narzędzia kodującego OpenAI dla terminala.", "aiClientConfigDescriptionOpencode": "Agent open source do kodowania w terminalu.", - "aiClientConfigDescriptionCursor": "Edytor kodu AI oparty na VS Code.", + "aiClientConfigDescriptionGemini": "Agent narzędzi kodowych Google dla terminala.", "aiClientConfigSetup": "Ustawienie", "aiClientConfigTabCli": "Automatyczne (CLI)", "aiClientConfigTabManual": "Konfiguracja ręczna", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "Trasa przez cele na Twoich witrynach", "aiProviderRoutingModeTargetNote": "Po utworzeniu tego dostawcy, skonfiguruj cele witryny na karcie Ustawienia sieci.", "aiProviderTargetNoOne": "Ten dostawca nie ma żadnych celów. Dodaj cel, aby trasować zapytania przez swoje witryny.", + "aiProviderRemoteNodeTargetsWarning": "Witryny podłączone do zdalnych węzłów są niedostępne do trasowania przez dostawców AI Gateway.", "aiProviderSkipTlsVerification": "Pomiń weryfikację TLS", "aiProviderSkipTlsVerificationDescription": "Wyłącz weryfikację certyfikatu TLS dla połączenia w górę", "aiProviderBudget": "Budżet", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "Obsługuje /v1/responses", "aiCapabilityAnthropicMessages": "Anthropic Wiadomości", "aiCapabilityAnthropicMessagesDescription": "Obsługuje /v1/messages", + "aiCapabilityV1Models": "Lista modeli", + "aiCapabilityV1ModelsDescription": "Obsługuje odkrywanie modeli /v1/models", "aiCapabilityGeminiGenerateContent": "Gemini Generowanie Treści", "aiCapabilityGeminiGenerateContentDescription": "Obsługuje bezpośredni Gemini API", "aiCapabilityBedrockModelInvoke": "Model Bedrock Wywołanie", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "Dzienniki działań", "logRetention": "Zachowanie dziennika", "logRetentionDescription": "Zarządzaj jak długo różne typy logów są zachowane dla tej organizacji lub wyłącz je", + "logRetentionDisabledWarningTitle": "Wyłączone przechowywanie logów", + "logRetentionDisabledWarningDescription": "{logType} nie są przechowywane dla tej organizacji, więc nowe aktywności nie pojawią się tutaj. Włącz przechowywanie w ustawieniach bezpieczeństwa, aby zacząć zbierać te logi.", + "logRetentionDisabledWarningButton": "Przejdź do ustawień bezpieczeństwa", "requestLogsDescription": "Zobacz szczegółowe dzienniki żądań zasobów w tej organizacji", "aiSessionLogs": "Dzienniki Sesji Bramy AI", "aiSessionLogsDescription": "Zobacz transkrypcje podpowiedzi i odpowiedzi dla żądań bramy AI w tej organizacji", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "Zdarzenia związane z miejscem i tunelem, w tym połączenia i rozłączenia.", "httpDestRequestLogsTitle": "Dzienniki żądań HTTP", "httpDestRequestLogsDescription": "Logi żądań HTTP dla zasobów proxy, w tym metody, ścieżki i kodu odpowiedzi.", + "httpDestAISessionLogsTitle": "Dzienniki sesji AI", + "httpDestAISessionLogsDescription": "Żądania i sesje odpowiedzi bramki AI, w tym zapytania, odpowiedzi modeli i użycie tokenów.", "httpDestSaveChanges": "Zapisz zmiany", "httpDestCreateDestination": "Utwórz cel", "httpDestUpdatedSuccess": "Cel został pomyślnie zaktualizowany", From 10d2c6438b9651ab0f21769131b571796895a61b Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:42 -0400 Subject: [PATCH 27/42] New translations en-us.json (Portuguese) [ci skip] --- messages/pt-PT.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 068927238..df2261e49 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Ferramenta de codificação agentic de Anthropic para o terminal.", "aiClientConfigDescriptionCodex": "Ferramenta de codificação agentic da OpenAI para o terminal.", "aiClientConfigDescriptionOpencode": "Agente de codificação de terminal de código aberto.", - "aiClientConfigDescriptionCursor": "Editor de código de IA baseado no VS Code.", + "aiClientConfigDescriptionGemini": "Ferramenta de codificação agêntica do terminal do Google.", "aiClientConfigSetup": "Configuração", "aiClientConfigTabCli": "Automático (CLI)", "aiClientConfigTabManual": "Configuração Manual", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "Roteie através de alvos em seus sites", "aiProviderRoutingModeTargetNote": "Após criar este provedor, configure alvos do site na aba Configurações de Rede.", "aiProviderTargetNoOne": "Este provedor não tem alvos. Adicione um alvo para rotear pedidos pelos seus sites.", + "aiProviderRemoteNodeTargetsWarning": "Sites conectados a nós remotos estão inacessíveis para serem roteados para os provedores do Gateway de IA.", "aiProviderSkipTlsVerification": "Pular Verificação TLS", "aiProviderSkipTlsVerificationDescription": "Desativar a verificação de certificado TLS para a conexão upstream", "aiProviderBudget": "Orçamento", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "Suporta /v1/responses", "aiCapabilityAnthropicMessages": "Mensagens Antropicas", "aiCapabilityAnthropicMessagesDescription": "Suporta /v1/messages", + "aiCapabilityV1Models": "Lista de Modelos", + "aiCapabilityV1ModelsDescription": "Suporta descoberta de modelos /v1/models", "aiCapabilityGeminiGenerateContent": "Gêmeos Gerar Conteúdo", "aiCapabilityGeminiGenerateContentDescription": "Suporta a API diretta do Gêmeos", "aiCapabilityBedrockModelInvoke": "Modelo Bedrock Invocar", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "Logs de Ações", "logRetention": "Retenção de Log", "logRetentionDescription": "Gerenciar quanto tempo os diferentes tipos de logs são mantidos para esta organização ou desativá-los", + "logRetentionDisabledWarningTitle": "Retenção de Logs Desativada", + "logRetentionDisabledWarningDescription": "{logType} não estão sendo retidos para esta organização, portanto, novas atividades não aparecerão aqui. Ative a retenção nas configurações de segurança para começar a coletar esses logs.", + "logRetentionDisabledWarningButton": "Ir para Configurações de Segurança", "requestLogsDescription": "Ver registros de pedidos detalhados de recursos nesta organização", "aiSessionLogs": "Registros de Sessão do Gateway de IA", "aiSessionLogsDescription": "Veja as transcrições de prompt e resposta para solicitações de gateway de IA nesta organização", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "Eventos de conexão de site e túnel, incluindo conexões e desconexões.", "httpDestRequestLogsTitle": "Registros de Pedidos HTTP", "httpDestRequestLogsDescription": "Logs de solicitação HTTP para recursos proxy incluindo o método, o caminho e o código de resposta.", + "httpDestAISessionLogsTitle": "Logs de Sessão de IA", + "httpDestAISessionLogsDescription": "Sessões de solicitação e resposta de gateway de IA, incluindo prompts, respostas de modelos e uso de tokens.", "httpDestSaveChanges": "Salvar as alterações", "httpDestCreateDestination": "Criar destino", "httpDestUpdatedSuccess": "Destino atualizado com sucesso", From 4b31326b341267f115addd95b265fa8066b9fb50 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:44 -0400 Subject: [PATCH 28/42] New translations en-us.json (Russian) [ci skip] --- messages/ru-RU.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index e38604b9b..ff6809529 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Агентивное кодирующее средство Anthropic для терминала.", "aiClientConfigDescriptionCodex": "Агентивное кодирующее средство OpenAI для терминала.", "aiClientConfigDescriptionOpencode": "Открытый исходный агент для кодирования в терминале.", - "aiClientConfigDescriptionCursor": "AI редактор кода на основе VS Code.", + "aiClientConfigDescriptionGemini": "Инструмент программирования Google для терминала.", "aiClientConfigSetup": "Настройка", "aiClientConfigTabCli": "Автоматическое (CLI)", "aiClientConfigTabManual": "Ручная конфигурация", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "Маршрутизация через цели на ваших сайтах", "aiProviderRoutingModeTargetNote": "После создания этого провайдера настройте целевые сайты на вкладке Сетевые настройки.", "aiProviderTargetNoOne": "У этого провайдера нет целей. Добавьте цель для маршрутизации запросов через ваши сайты.", + "aiProviderRemoteNodeTargetsWarning": "Сайты, подключенные к удалённым узлам, недоступны для маршрутизации с помощью провайдеров AI Gateway.", "aiProviderSkipTlsVerification": "Пропустить проверку TLS", "aiProviderSkipTlsVerificationDescription": "Отключить проверку сертификата TLS для исходного соединения", "aiProviderBudget": "Бюджет", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "Поддерживает /v1/responses", "aiCapabilityAnthropicMessages": "Сообщения Anthropic", "aiCapabilityAnthropicMessagesDescription": "Поддерживает /v1/messages", + "aiCapabilityV1Models": "Список моделей", + "aiCapabilityV1ModelsDescription": "Поддерживает обнаружение моделей /v1/models", "aiCapabilityGeminiGenerateContent": "Gemini Создание контента", "aiCapabilityGeminiGenerateContentDescription": "Поддерживает прямой API Gemini", "aiCapabilityBedrockModelInvoke": "Вызов модели Bedrock", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "Журнал действий", "logRetention": "Сохранение журнала", "logRetentionDescription": "Управление сохранением различных типов журналов для этой организации или отключение их", + "logRetentionDisabledWarningTitle": "Хранение логов отключено", + "logRetentionDisabledWarningDescription": "Логи {logType} не сохраняются для этой организации, поэтому здесь не будет отображаться новая активность. Включите хранение в настройках безопасности, чтобы начать собирать эти логи.", + "logRetentionDisabledWarningButton": "Перейти в настройки безопасности", "requestLogsDescription": "Просмотреть подробные журналы запроса ресурсов в этой организации", "aiSessionLogs": "AI Логи сессии шлюза", "aiSessionLogsDescription": "Просмотр транскриптов запросов и ответов для шлюзов AI в этой организации", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "События связи с сайтами и туннелями, включая соединения и отключения.", "httpDestRequestLogsTitle": "HTTP Запросы Логи", "httpDestRequestLogsDescription": "Журналы запросов HTTP для проксируемых ресурсов, включая метод, путь и код ответа.", + "httpDestAISessionLogsTitle": "Логи AI сессий", + "httpDestAISessionLogsDescription": "Запросы и ответы AI gateway, включая подсказки, ответы моделей и использование токенов.", "httpDestSaveChanges": "Сохранить изменения", "httpDestCreateDestination": "Создать адрес назначения", "httpDestUpdatedSuccess": "Адрес назначения успешно обновлен", From c1e576900318a1c78f89021a90f0941cce5361d5 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:46 -0400 Subject: [PATCH 29/42] New translations en-us.json (Turkish) [ci skip] --- messages/tr-TR.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 3bc4c8bfc..407a31dfd 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Anthropic'in terminal için aracılık kodlama aracı.", "aiClientConfigDescriptionCodex": "OpenAI'nin terminal için aracılık kodlama aracı.", "aiClientConfigDescriptionOpencode": "Açık kaynak terminal kodlama aracı.", - "aiClientConfigDescriptionCursor": "VS Code üzerine kurulu yapay zeka kod editörü.", + "aiClientConfigDescriptionGemini": "Google'un terminal için agentik kodlama aracı.", "aiClientConfigSetup": "Kurulum", "aiClientConfigTabCli": "Otomatik (CLI)", "aiClientConfigTabManual": "Manuel Yapılandırma", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "Siteniz üzerindeki hedefler üzerinden yönlendirin", "aiProviderRoutingModeTargetNote": "Bu sağlayıcıyı oluşturduktan sonra, site hedeflerini Ağ Ayarları sekmesinde yapılandırın.", "aiProviderTargetNoOne": "Bu sağlayıcının herhangi bir hedefi yok. Sitemiz üzerinden istekleri yönlendirmek için bir hedef ekleyin.", + "aiProviderRemoteNodeTargetsWarning": "Uzaktaki düğümlere bağlı siteler, AI Geçidi sağlayıcılarına yönlendirilemez durumda.", "aiProviderSkipTlsVerification": "TLS Doğrulamayı Atla", "aiProviderSkipTlsVerificationDescription": "Yukarı akış bağlantısı için TLS sertifika doğrulamasını devre dışı bırakın", "aiProviderBudget": "Bütçe", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "/v1/yanıtlar desteği sağlar", "aiCapabilityAnthropicMessages": "Anthropic Mesajlar", "aiCapabilityAnthropicMessagesDescription": "/v1/mesajlar desteği sağlar", + "aiCapabilityV1Models": "Modeller Listesi", + "aiCapabilityV1ModelsDescription": "T /v1/models model keşfini destekler", "aiCapabilityGeminiGenerateContent": "Gemini İçerik Üret", "aiCapabilityGeminiGenerateContentDescription": "Doğrudan Gemini API desteği sağlar", "aiCapabilityBedrockModelInvoke": "Bedrock Modeli Çağır", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "Eylem Günlükleri", "logRetention": "Kayıt Saklama", "logRetentionDescription": "Bu organizasyon için farklı türdeki günlüklerin ne kadar süre saklanacağını yönetin veya devre dışı bırakın", + "logRetentionDisabledWarningTitle": "Günlük Saklama Devre Dışı Bırakıldı", + "logRetentionDisabledWarningDescription": "{logType} bu organizasyon için saklanmıyor, bu nedenle yeni etkinlikler burada görünmeyecek. Bu günlükleri toplamak için güvenlik ayarlarında saklamayı etkinleştirin.", + "logRetentionDisabledWarningButton": "Güvenlik Ayarlarına Git", "requestLogsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek günlüklerini görüntüleyin", "aiSessionLogs": "AI Ağ Geçidi Oturum Günlükleri", "aiSessionLogsDescription": "Bu organizasyondaki AI ağ geçidi isteklerinin istem ve yanıt transkriptlerini görüntüleyin", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "Site ve tünel bağlantı olayları, bağlantılar ve bağlantı kesilmeleri dahil.", "httpDestRequestLogsTitle": "HTTP İstek Günlükleri", "httpDestRequestLogsDescription": "Yönlendirilmiş kaynaklar için HTTP istek kayıtları, yöntem, yol ve yanıt kodu dahil.", + "httpDestAISessionLogsTitle": "AI Oturum Günlükleri", + "httpDestAISessionLogsDescription": "AI geçidi istek ve yanıt oturumları, istemler, model yanıtları ve token kullanımı dahil.", "httpDestSaveChanges": "Değişiklikleri Kaydet", "httpDestCreateDestination": "Hedef Oluştur", "httpDestUpdatedSuccess": "Hedef başarıyla güncellendi", From e4ec6f7cbef25800c3dab20d8c3cd9090fb42619 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:49 -0400 Subject: [PATCH 30/42] New translations en-us.json (Chinese Simplified) [ci skip] --- messages/zh-CN.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 61b0ace85..84e8ad40e 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Anthropic 的终端代理编码工具。", "aiClientConfigDescriptionCodex": "OpenAI 的终端代理编码工具。", "aiClientConfigDescriptionOpencode": "开源终端编码代理。", - "aiClientConfigDescriptionCursor": "基于 VS Code 的 AI 代码编辑器。", + "aiClientConfigDescriptionGemini": "Google的终端代理编码工具。", "aiClientConfigSetup": "设置", "aiClientConfigTabCli": "自动 (CLI)", "aiClientConfigTabManual": "手动配置", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "通过您站点上的目标进行路由", "aiProviderRoutingModeTargetNote": "创建此提供商后,在“网络设置”选项卡中配置站点目标。", "aiProviderTargetNoOne": "该提供商没有任何目标。 添加目标以通过您的站点路由请求。", + "aiProviderRemoteNodeTargetsWarning": "连接到远程节点的站点无法在AI网关供应商上被路由。", "aiProviderSkipTlsVerification": "跳过TLS验证", "aiProviderSkipTlsVerificationDescription": "禁用上游连接的TLS证书验证", "aiProviderBudget": "预算", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "支持 /v1/responses", "aiCapabilityAnthropicMessages": "Anthropic 消息", "aiCapabilityAnthropicMessagesDescription": "支持 /v1/messages", + "aiCapabilityV1Models": "模型列表", + "aiCapabilityV1ModelsDescription": "支持/v1/models模型发现", "aiCapabilityGeminiGenerateContent": "Gemini 生成内容", "aiCapabilityGeminiGenerateContentDescription": "支持直接Gemini API", "aiCapabilityBedrockModelInvoke": "Bedrock 模型调用", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "操作日志", "logRetention": "日志保留", "logRetentionDescription": "管理不同类型的日志为这个机构保留多长时间或禁用这些日志", + "logRetentionDisabledWarningTitle": "日志保留已禁用", + "logRetentionDisabledWarningDescription": "{logType}未在此组织中被保留,因此新活动不会显示在此处。请在安全设置中启用日志保留以开始收集这些日志。", + "logRetentionDisabledWarningButton": "转到安全设置", "requestLogsDescription": "查看此机构资源的详细请求日志", "aiSessionLogs": "AI 网关会话日志", "aiSessionLogsDescription": "查看此组织中AI网关请求的提示和响应记录", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "站点和隧道连接事件,包括连接和断开连接。", "httpDestRequestLogsTitle": "请求日志", "httpDestRequestLogsDescription": "HTTP 请求代理资源日志,包括方法、路径和响应代码。", + "httpDestAISessionLogsTitle": "AI会话日志", + "httpDestAISessionLogsDescription": "AI网关请求和响应会话,包括提示、模型响应和令牌使用。", "httpDestSaveChanges": "保存更改", "httpDestCreateDestination": "创建目标", "httpDestUpdatedSuccess": "目标已成功更新", From ddf89d0afadd26fcadae4d4cf9e01f84e76c1edc Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 24 Aug 2026 11:23:51 -0400 Subject: [PATCH 31/42] New translations en-us.json (Norwegian Bokmal) [ci skip] --- messages/nb-NO.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 9c28a6fa6..c5a66effe 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -1785,7 +1785,7 @@ "aiClientConfigDescriptionClaude": "Anthropics agentiske kodingsverktøy for terminalen.", "aiClientConfigDescriptionCodex": "OpenAIs agentiske kodingsverktøy for terminalen.", "aiClientConfigDescriptionOpencode": "Åpen kildekode terminal kodeagent.", - "aiClientConfigDescriptionCursor": "AI-kodeeditor bygget på VS Code.", + "aiClientConfigDescriptionGemini": "Googles agentiske koding verktøy for terminalen.", "aiClientConfigSetup": "Oppsett", "aiClientConfigTabCli": "Automatisk (CLI)", "aiClientConfigTabManual": "Manuell konfigurasjon", @@ -1891,6 +1891,7 @@ "aiProviderRoutingModeTargetDescription": "Rute gjennom mål på dine nettsteder", "aiProviderRoutingModeTargetNote": "Etter å ha opprettet denne leverandøren, konfigurer områdemål på fanen Nettverksinnstillinger.", "aiProviderTargetNoOne": "Denne leverandøren har ingen mål. Legg til et mål for å rute forespørsler gjennom dine nettsteder.", + "aiProviderRemoteNodeTargetsWarning": "Nettsteder tilkoblet eksterne noder er utilgjengelige for ruting til på AI Gateway leverandører.", "aiProviderSkipTlsVerification": "Hopp over TLS-verifisering", "aiProviderSkipTlsVerificationDescription": "Deaktiver TLS-sertifikatverifisering for oppstrøms tilkobling", "aiProviderBudget": "Budsjett", @@ -1923,6 +1924,8 @@ "aiCapabilityOpenaiResponsesDescription": "Støtter /v1/responser", "aiCapabilityAnthropicMessages": "Anthropic Meldinger", "aiCapabilityAnthropicMessagesDescription": "Støtter /v1/meldinger", + "aiCapabilityV1Models": "Modelliste", + "aiCapabilityV1ModelsDescription": "Støtter /v1/modeller modelloppdagelse", "aiCapabilityGeminiGenerateContent": "Gemini Generer Innhold", "aiCapabilityGeminiGenerateContentDescription": "Støtter direkte Gemini API", "aiCapabilityBedrockModelInvoke": "Bedrock Modell Påkalling", @@ -3548,6 +3551,9 @@ "sidebarLogsAction": "Handlingslogger", "logRetention": "Logg tilbaketrekning", "logRetentionDescription": "Håndter hvor lenge ulike typer logger beholdes for denne organisasjonen, eller deaktiver dem", + "logRetentionDisabledWarningTitle": "Loggbevaring deaktivert", + "logRetentionDisabledWarningDescription": "{logType} blir ikke lagret for denne organisasjonen, så ny aktivitet vises ikke her. Aktiver lagring i sikkerhetsinnstillingene for å begynne å samle inn disse loggene.", + "logRetentionDisabledWarningButton": "Gå til sikkerhetsinnstillinger", "requestLogsDescription": "Se detaljerte forespørselslogger for ressurser i denne organisasjonen", "aiSessionLogs": "AI Portal Sesjonslogger", "aiSessionLogsDescription": "Vis stikkord- og responsutskrifter for AI-portal forespørsler i denne organisasjonen", @@ -4081,6 +4087,8 @@ "httpDestConnectionLogsDescription": "Utstyrs- og tunneltilkoblingshendelser, inkludert forbindelser og frakobling.", "httpDestRequestLogsTitle": "HTTP-forespørselslogger", "httpDestRequestLogsDescription": "HTTP-forespørsel logger for bekreftede ressurser, inkludert metode, bane og responskode.", + "httpDestAISessionLogsTitle": "AI øktlogger", + "httpDestAISessionLogsDescription": "Forespørsels- og svarøkter for AI gateway, inkludert forespørsler, modellresponser og tokenbruk.", "httpDestSaveChanges": "Lagre endringer", "httpDestCreateDestination": "Opprett mål", "httpDestUpdatedSuccess": "Målet er oppdatert", From 935410b15ea1d9728b50a41016ecdb025c5b3f95 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 24 Aug 2026 11:34:22 -0400 Subject: [PATCH 32/42] Fixes #2612 --- .../[orgId]/settings/(private)/remote-exit-nodes/page.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx index 890a14564..ff444c6f1 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx @@ -8,6 +8,8 @@ import ExitNodesTable, { import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; import type { Metadata } from "next"; +import { build } from "@server/build"; +import { redirect } from "next/navigation"; export const metadata: Metadata = { title: "Remote Exit Nodes" @@ -22,6 +24,10 @@ export const dynamic = "force-dynamic"; export default async function RemoteExitNodesPage( props: RemoteExitNodesPageProps ) { + if (build != "saas") { + redirect("/"); + } + const params = await props.params; let remoteExitNodes: ListRemoteExitNodesResponse["remoteExitNodes"] = []; try { From 5b782a842c31e0230ebca9d9c88fdf95ccaa99b2 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 24 Aug 2026 11:42:35 -0400 Subject: [PATCH 33/42] Fix #2937 --- .../routers/certificates/createCertificate.ts | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/server/routers/certificates/createCertificate.ts b/server/routers/certificates/createCertificate.ts index e75bfe05f..5eaed4d64 100644 --- a/server/routers/certificates/createCertificate.ts +++ b/server/routers/certificates/createCertificate.ts @@ -23,6 +23,12 @@ export async function createCertificate( throw new Error(`Domain with ID ${domainId} not found`); } + // Note: certificates.domain has a global UNIQUE constraint (it is not + // scoped per-domainId), so existence must be checked by domain value + // alone. Filtering on domainId here as well can cause this check to + // miss an existing cert (e.g. if it was stored under a different but + // still-valid domainId), leading to an INSERT that then fails on the + // unique constraint. let existing: Certificate[] = []; if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { const domainLevelDown = domain.split(".").slice(1).join("."); @@ -32,16 +38,13 @@ export async function createCertificate( .select() .from(certificates) .where( - and( - eq(certificates.domainId, domainId), - or( - eq(certificates.domain, domain), - and( - eq(certificates.wildcard, true), - or( - eq(certificates.domain, domainLevelDown), - eq(certificates.domain, wildcardPrefixed) - ) + or( + eq(certificates.domain, domain), + and( + eq(certificates.wildcard, true), + or( + eq(certificates.domain, domainLevelDown), + eq(certificates.domain, wildcardPrefixed) ) ) ) @@ -51,12 +54,7 @@ export async function createCertificate( existing = await trx .select() .from(certificates) - .where( - and( - eq(certificates.domainId, domainId), - eq(certificates.domain, domain) // exact match for non-NS domains - ) - ); + .where(eq(certificates.domain, domain)); // exact match for non-NS domains } if (existing.length > 0) { @@ -87,16 +85,22 @@ export async function createCertificate( } } - // No cert found, create a new one in pending state - await trx.insert(certificates).values({ - domain: domainToWrite, - domainId, - wildcard: - domainRecord.type == "ns" || - (domainRecord.type == "wildcard" && - domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains - status: "pending", - updatedAt: Math.floor(Date.now() / 1000), - createdAt: Math.floor(Date.now() / 1000) - }); + // No cert found, create a new one in pending state. onConflictDoNothing + // guards against the domain having been inserted concurrently (or under + // a different domainId) between the existence check above and this + // insert, since certificates.domain is globally unique. + await trx + .insert(certificates) + .values({ + domain: domainToWrite, + domainId, + wildcard: + domainRecord.type == "ns" || + (domainRecord.type == "wildcard" && + domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains + status: "pending", + updatedAt: Math.floor(Date.now() / 1000), + createdAt: Math.floor(Date.now() / 1000) + }) + .onConflictDoNothing(); } From 1c2fe44c54ff3cf5100b85da42f59174edd5c49c Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 24 Aug 2026 11:43:28 -0400 Subject: [PATCH 34/42] Fix #2648 --- src/app/[orgId]/settings/resources/private/page.tsx | 4 ++-- src/lib/fetchSiteResourceByNiceId.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/[orgId]/settings/resources/private/page.tsx b/src/app/[orgId]/settings/resources/private/page.tsx index 86bba120d..6aa1a4726 100644 --- a/src/app/[orgId]/settings/resources/private/page.tsx +++ b/src/app/[orgId]/settings/resources/private/page.tsx @@ -88,8 +88,8 @@ export default async function ClientResourcesPage( siteNiceIds: siteResource.siteNiceIds, niceId: siteResource.niceId, enabled: siteResource.enabled, - tcpPortRangeString: siteResource.tcpPortRangeString || null, - udpPortRangeString: siteResource.udpPortRangeString || null, + tcpPortRangeString: siteResource.tcpPortRangeString ?? null, + udpPortRangeString: siteResource.udpPortRangeString ?? null, disableIcmp: siteResource.disableIcmp || false, authDaemonMode: siteResource.authDaemonMode ?? null, authDaemonPort: siteResource.authDaemonPort ?? null, diff --git a/src/lib/fetchSiteResourceByNiceId.ts b/src/lib/fetchSiteResourceByNiceId.ts index 57ddf3f42..6a6fe5672 100644 --- a/src/lib/fetchSiteResourceByNiceId.ts +++ b/src/lib/fetchSiteResourceByNiceId.ts @@ -43,8 +43,8 @@ export async function fetchSiteResourceByNiceId( aliasAddress: match.aliasAddress || null, siteNiceIds: match.siteNiceIds, niceId: match.niceId, - tcpPortRangeString: match.tcpPortRangeString || null, - udpPortRangeString: match.udpPortRangeString || null, + tcpPortRangeString: match.tcpPortRangeString ?? null, + udpPortRangeString: match.udpPortRangeString ?? null, disableIcmp: match.disableIcmp || false, authDaemonMode: match.authDaemonMode ?? null, authDaemonPort: match.authDaemonPort ?? null, From 753cbd45d03479bd2938d6b52f7d837cfc15a17a Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 24 Aug 2026 11:55:39 -0400 Subject: [PATCH 35/42] Add AI disclosure request --- .github/PULL_REQUEST_TEMPLATE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index aeee133a9..6e1333750 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,6 +4,10 @@ perpetual license to use, modify, and redistribute these contributions under any choose, including both the AGPLv3 and the Fossorial Commercial license terms. I represent that I have the right to grant this license for all contributed content. +## AI Disclosure + +> Please disclose how AI was used in this pull request. The use of AI does not preclude this from being merged but is an important factor in how we review your request. + ## Description From 85b40b7164b6d5b46442a7a5b7d52f3a7f39023c Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 24 Aug 2026 14:05:31 -0400 Subject: [PATCH 36/42] Fix expanded row display issue on page change in LogDataTable --- src/components/LogDataTable.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 64833b0a0..6c17eb76c 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -313,6 +313,15 @@ export function LogDataTable({ } }, [currentPage, table, isServerPagination]); + // Collapse any expanded rows whenever the page changes, since row ids + // are reused across pages and would otherwise show the wrong content + // in the same expanded position. + const pageIndex = table.getState().pagination.pageIndex; + useEffect(() => { + setExpandedRows(new Set()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pageIndex]); + const handleTabChange = (value: string) => { if (disabled) return; From 26f902662103ec8f45786d46e5029be4ffe620cf Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 24 Aug 2026 14:31:17 -0400 Subject: [PATCH 37/42] Fix width too big by adding min-width constraints --- src/components/AiSessionChatView.tsx | 10 +++++----- src/components/LogDataTable.tsx | 10 +++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/components/AiSessionChatView.tsx b/src/components/AiSessionChatView.tsx index b46cd5cf2..211f71da6 100644 --- a/src/components/AiSessionChatView.tsx +++ b/src/components/AiSessionChatView.tsx @@ -79,7 +79,7 @@ function MessageBubble({ message }: { message: NormalizedAiMessage }) { )}
+
{label} {pretty && unparsedLabel && ( @@ -117,7 +117,7 @@ function RawFallbackBlock({ )}
-
+            
                 {pretty ?? noDataLabel}
             
@@ -172,7 +172,7 @@ export function AiSessionChatView({
{rawMode ? ( -
+
) : ( -
+
{hasRequestMessages ? ( requestMessages!.map((message, i) => ( diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 6c17eb76c..b6e34ef80 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -524,9 +524,13 @@ export function LogDataTable({ } className="p-4 bg-muted/50" > - {renderExpandedRow( - row.original - )} + {/* w-0 min-w-full keeps this cell's content from */} + {/* blowing out the table's auto column widths */} +
+ {renderExpandedRow( + row.original + )} +
) From 2051e5df3732f415f5a913c560b496e022c164cf Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 24 Aug 2026 14:32:44 -0400 Subject: [PATCH 38/42] fix syntax --- server/private/routers/ws/messageHandlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/private/routers/ws/messageHandlers.ts b/server/private/routers/ws/messageHandlers.ts index 685f67848..c024fcca7 100644 --- a/server/private/routers/ws/messageHandlers.ts +++ b/server/private/routers/ws/messageHandlers.ts @@ -24,4 +24,4 @@ export const messageHandlers: Record = { "remoteExitNode/register": handleRemoteExitNodeRegisterMessage, "remoteExitNode/ping": handleRemoteExitNodePingMessage, "newt/access-log": handleConnectionLogMessage, -; +}; From 77cab56fa93650e87efaf6f062ef3f6230feabc9 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 24 Aug 2026 14:39:17 -0400 Subject: [PATCH 39/42] Add valueFormatter prop to ToggleableTrendChart and ChartTooltipContent so we see more decimals --- .../ToggleableTrendChart.tsx | 2 ++ src/components/ai-usage-analytics/shared.ts | 3 ++- src/components/ui/chart.tsx | 26 ++++++++++++------- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/components/ai-usage-analytics/ToggleableTrendChart.tsx b/src/components/ai-usage-analytics/ToggleableTrendChart.tsx index 3b0f1e7c5..09a88b359 100644 --- a/src/components/ai-usage-analytics/ToggleableTrendChart.tsx +++ b/src/components/ai-usage-analytics/ToggleableTrendChart.tsx @@ -129,6 +129,7 @@ export function ToggleableTrendChart(props: ToggleableTrendChartProps) { payload?.[0]?.payload?.day ) } + valueFormatter={valueFormatter} /> } /> @@ -172,6 +173,7 @@ export function ToggleableTrendChart(props: ToggleableTrendChartProps) { payload?.[0]?.payload?.day ) } + valueFormatter={valueFormatter} /> } /> diff --git a/src/components/ai-usage-analytics/shared.ts b/src/components/ai-usage-analytics/shared.ts index 30ad525b4..e785330d7 100644 --- a/src/components/ai-usage-analytics/shared.ts +++ b/src/components/ai-usage-analytics/shared.ts @@ -47,7 +47,8 @@ export function buildSeriesFromData( export const currencyFormatter = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD", - maximumFractionDigits: 2 + minimumFractionDigits: 2, + maximumFractionDigits: 4 }); export const compactNumberFormatter = new Intl.NumberFormat(undefined, { diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx index ec2e1c880..6c4323d79 100644 --- a/src/components/ui/chart.tsx +++ b/src/components/ui/chart.tsx @@ -135,6 +135,7 @@ type ChartTooltipContentProps = React.ComponentProps<"div"> & { labelKey?: string; color?: string; labelClassName?: string; + valueFormatter?: (value: number) => string; }; const ChartTooltipContent = React.forwardRef< @@ -155,7 +156,8 @@ const ChartTooltipContent = React.forwardRef< formatter, color, nameKey, - labelKey + labelKey, + valueFormatter }, ref ) => { @@ -302,19 +304,23 @@ const ChartTooltipContent = React.forwardRef< item.name}
- {item.value && ( + {item.value !== undefined && ( {!isNaN( item.value as number ) - ? new Intl.NumberFormat( - navigator.language, - { - maximumFractionDigits: 0 - } - ).format( - item.value as number - ) + ? valueFormatter + ? valueFormatter( + item.value as number + ) + : new Intl.NumberFormat( + navigator.language, + { + maximumFractionDigits: 0 + } + ).format( + item.value as number + ) : item.value.toLocaleString()} )} From 2d18db35978663ddad4bac202971a535804ffaba Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 24 Aug 2026 22:32:40 +0200 Subject: [PATCH 40/42] =?UTF-8?q?=F0=9F=90=9B=20fix=20loginIdps=20using=20?= =?UTF-8?q?variant=20instead=20of=20type=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/auth/login/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index db523f650..dcf90d826 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -100,7 +100,7 @@ export default async function Page(props: { loginIdps = idpsRes.data.data.idps.map((idp) => ({ idpId: idp.idpId, name: idp.name, - variant: idp.type + variant: idp.variant ?? idp.type })) as LoginFormIDP[]; } } else { From 23764feb4fe798d96a0391045004ff1110de8ead Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 24 Aug 2026 17:01:37 -0400 Subject: [PATCH 41/42] Move the messaging out of the transaction --- server/lib/blueprints/applyBlueprint.ts | 159 +++++++++++++----------- 1 file changed, 85 insertions(+), 74 deletions(-) diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 44646e0bf..077b2112a 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -1,5 +1,6 @@ import { db, + primaryDb, newts, blueprints, Blueprint, @@ -80,93 +81,103 @@ export async function applyBlueprint({ trx, siteId ); + }); - // We need to update the targets on the newts from the successfully updated information - for (const result of publicResourcesResults) { - for (const target of result.targetsToUpdate) { - const [site] = await trx - .select() - .from(sites) - .innerJoin(newts, eq(sites.siteId, newts.siteId)) - .where( - and( - eq(sites.siteId, target.siteId), - eq(sites.orgId, orgId), - eq(sites.type, "newt"), - isNotNull(sites.pubKey) - ) + // Push updates to newts/clients only after the transaction has + // committed. Doing this while the transaction is still open can + // race with the writes (e.g. newts requesting config before the + // new targets/resources are actually visible), leaving them out + // of sync until manually toggled. + + // We need to update the targets on the newts from the successfully updated information + for (const result of publicResourcesResults) { + for (const target of result.targetsToUpdate) { + // read from the primary: this determines whether/how we push + // the just-created target to the newt, so a lagging replica + // returning stale or missing data here would silently skip + // the push + const [site] = await primaryDb + .select() + .from(sites) + .innerJoin(newts, eq(sites.siteId, newts.siteId)) + .where( + and( + eq(sites.siteId, target.siteId), + eq(sites.orgId, orgId), + eq(sites.type, "newt"), + isNotNull(sites.pubKey) ) - .limit(1); + ) + .limit(1); - if (site) { - logger.debug( - `Updating target ${target.targetId} on site ${site.sites.siteId}` + if (site) { + logger.debug( + `Updating target ${target.targetId} on site ${site.sites.siteId}` + ); + + // see if you can find a matching target health check from the healthchecksToUpdate array + const matchingHealthcheck = + result.healthchecksToUpdate.find( + (hc) => hc.targetId === target.targetId ); - // see if you can find a matching target health check from the healthchecksToUpdate array - const matchingHealthcheck = - result.healthchecksToUpdate.find( - (hc) => hc.targetId === target.targetId - ); - - if (["http", "tcp", "udp"].includes(target.mode)) { - await addProxyTargets( - site.newt.newtId, - [target], - matchingHealthcheck - ? [matchingHealthcheck] - : [], - result.proxyResource.mode === "udp" - ? "udp" - : "tcp", - site.newt.version - ); - } else if ( - ["ssh", "rdp", "vnc"].includes(target.mode) - ) { - await sendBrowserGatewayTargets( - site.newt.newtId, - [target], - site.newt.version - ); - } + if (["http", "tcp", "udp"].includes(target.mode)) { + await addProxyTargets( + site.newt.newtId, + [target], + matchingHealthcheck + ? [matchingHealthcheck] + : [], + result.proxyResource.mode === "udp" + ? "udp" + : "tcp", + site.newt.version + ); + } else if ( + ["ssh", "rdp", "vnc"].includes(target.mode) + ) { + await sendBrowserGatewayTargets( + site.newt.newtId, + [target], + site.newt.version + ); } } } + } - logger.debug( - `Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}` - ); + logger.debug( + `Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}` + ); - // We need to update the targets on the newts from the successfully updated information - for (const result of privateResourcesResults) { - rebuildClientAssociationsFromSiteResource( - result.newSiteResource + // We need to update the targets on the newts from the successfully updated information + for (const result of privateResourcesResults) { + rebuildClientAssociationsFromSiteResource( + result.newSiteResource + ) + .then(() => + waitForSiteResourceRebuildIdle( + result.newSiteResource.siteResourceId + ) ) - .then(() => - waitForSiteResourceRebuildIdle( - result.newSiteResource.siteResourceId - ) + .then(() => + handleMessagingForUpdatedSiteResource( + result.oldSiteResource, + result.newSiteResource, + result.oldSites.map((s) => s.siteId), + result.newSites.map((s) => s.siteId) ) - .then(() => - handleMessagingForUpdatedSiteResource( - result.oldSiteResource, - result.newSiteResource, - result.oldSites.map((s) => s.siteId), - result.newSites.map((s) => s.siteId) - ) - ) - .catch((e) => { - logger.error( - `Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}` - ); - }); - } + ) + .catch((e) => { + logger.error( + `Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}` + ); + }); + } - logger.debug( - `Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}` - ); - }); + logger.debug( + `Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}` + ); blueprintSucceeded = true; blueprintMessage = "Blueprint applied successfully"; From 72d2c79793097d9c84d31fa8e5c2c6bba09a1b1d Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 25 Aug 2026 10:42:56 -0400 Subject: [PATCH 42/42] Add migration for streaming table --- server/setup/scriptsPg/1.22.0.ts | 3 +++ server/setup/scriptsSqlite/1.22.0.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/server/setup/scriptsPg/1.22.0.ts b/server/setup/scriptsPg/1.22.0.ts index e85f0fd33..37b4f0aab 100644 --- a/server/setup/scriptsPg/1.22.0.ts +++ b/server/setup/scriptsPg/1.22.0.ts @@ -345,6 +345,9 @@ export default async function migration() { await db.execute( sql`ALTER TABLE "virtualApiKeys" ADD CONSTRAINT "virtualApiKeys_createdByUserId_user_id_fk" FOREIGN KEY ("createdByUserId") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;` ); + await db.execute( + sql`ALTER TABLE "eventStreamingDestinations" ADD "sendAISessionLogs" boolean DEFAULT false NOT NULL;` + ); await db.execute( sql`CREATE INDEX "idx_ai_budget_breach_events_budget_created" ON "aiBudgetBreachEvents" USING btree ("budgetId","createdAt");` ); diff --git a/server/setup/scriptsSqlite/1.22.0.ts b/server/setup/scriptsSqlite/1.22.0.ts index d0e65e443..b66853a82 100644 --- a/server/setup/scriptsSqlite/1.22.0.ts +++ b/server/setup/scriptsSqlite/1.22.0.ts @@ -402,6 +402,9 @@ export default async function migration() { db.prepare( `ALTER TABLE 'siteResources' ADD 'requiresExitNodeConnection' integer DEFAULT false NOT NULL;` ).run(); + db.prepare( + `ALTER TABLE 'eventStreamingDestinations' ADD 'sendAISessionLogs' integer DEFAULT false NOT NULL;` + ).run(); const insertRoleAction = db.prepare(` INSERT INTO 'roleActions' ("roleId", "actionId", "orgId")