diff --git a/README.md b/README.md
index 1827f2570..41ea77447 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,19 @@ Access private resources like SSH servers, databases, RDP, and entire network ra
+### Identity-aware AI gateway
+
+Put an identity-aware proxy in front of public cloud (OpenAI, Anthropic, Gemini, etc.) and self-hosted model servers (Ollama, vLLM, Mistral, etc.) so coding agents and AI clients call a single Pangolin URL. Publish it as a public resource with personal API keys, or keep it private on a client tunnel where the connected client is the credential for keykless access. Budgets, session history, and usage analytics sit in front of every call.
+
+* Access self-hosted models (vLLM, Ollama, etc) alongside cloud models (OpenAI, Anthropic, etc) in one place
+* Keyless access by authenticating users with the Pangolin desktop client
+* Or, provide users with personal API keys
+* Control costs and token usage by setting budgets
+* Audit with detailed session history and analytics
+* Integrate AI clients and coding agents (Claude Code, Codex, OpenCode, etc)
+
+
+
### Give users and roles access to resources
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
diff --git a/public/screenshots/expanded-session-logs.png b/public/screenshots/expanded-session-logs.png
new file mode 100644
index 000000000..9a5bf0877
Binary files /dev/null and b/public/screenshots/expanded-session-logs.png differ
diff --git a/server/lib/telemetry.ts b/server/lib/telemetry.ts
index 4f1adbd53..f722c656a 100644
--- a/server/lib/telemetry.ts
+++ b/server/lib/telemetry.ts
@@ -3,6 +3,8 @@ import config from "./config";
import { getHostMeta } from "./hostMeta";
import logger from "@server/logger";
import {
+ aiProviders,
+ aiUsageRecords,
alertRules,
apiKeys,
blueprints,
@@ -11,7 +13,16 @@ import {
siteResources
} from "@server/db";
import { sites, users, orgs, resources, clients, idp } from "@server/db";
-import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
+import {
+ eq,
+ count,
+ countDistinct,
+ notInArray,
+ and,
+ isNotNull,
+ isNull,
+ gte
+} from "drizzle-orm";
import { APP_VERSION } from "./consts";
import crypto from "crypto";
import { UserType } from "@server/types/UserTypes";
@@ -172,6 +183,25 @@ class TelemetryClient {
.select({ count: count() })
.from(blueprints);
+ const [aiProvidersCount] = await db
+ .select({ count: count() })
+ .from(aiProviders);
+ const [orgsWithAiProviders] = await db
+ .select({ count: countDistinct(aiProviders.orgId) })
+ .from(aiProviders);
+
+ const usageWindowStart =
+ Math.floor(Date.now() / 1000) -
+ this.collectionIntervalDays * 24 * 60 * 60;
+ const [aiUsageRecordsRecent] = await db
+ .select({ count: count() })
+ .from(aiUsageRecords)
+ .where(gte(aiUsageRecords.createdAt, usageWindowStart));
+ const [orgsWithRecentAiUsage] = await db
+ .select({ count: countDistinct(aiUsageRecords.orgId) })
+ .from(aiUsageRecords)
+ .where(gte(aiUsageRecords.createdAt, usageWindowStart));
+
const supporterKey = config.getSupporterData();
const allPrivateResources = await db.select().from(siteResources);
@@ -182,6 +212,7 @@ class TelemetryClient {
let numPrivResourceCidr = 0;
let numPrivResourceHttp = 0;
let numPrivResourceSsh = 0;
+ let numPrivResourceInference = 0;
for (const res of allPrivateResources) {
if (res.mode === "host") {
numPrivResourceHosts += 1;
@@ -191,6 +222,8 @@ class TelemetryClient {
numPrivResourceHttp += 1;
} else if (res.mode === "ssh") {
numPrivResourceSsh += 1;
+ } else if (res.mode === "inference") {
+ numPrivResourceInference += 1;
}
if (res.alias) {
@@ -211,6 +244,11 @@ class TelemetryClient {
numPrivateResourceCidr: numPrivResourceCidr,
numPrivateResourceHttp: numPrivResourceHttp,
numPrivateResourceSsh: numPrivResourceSsh,
+ numPrivateResourceInference: numPrivResourceInference,
+ numAiProviders: aiProvidersCount.count,
+ numOrgsWithAiProviders: orgsWithAiProviders.count,
+ numAiUsageRecordsRecent: aiUsageRecordsRecent.count,
+ numOrgsWithRecentAiUsage: orgsWithRecentAiUsage.count,
numAlertRules: numAlertRules.count,
numUserDevices: userDevicesCount.count,
numMachineClients: machineClients.count,
@@ -323,6 +361,17 @@ class TelemetryClient {
num_resources_non_http: stats.resources.filter(
(r) => r.mode !== "http"
).length,
+ num_resources_ai_gateway: stats.resources.filter(
+ (r) => r.mode === "inference"
+ ).length,
+ num_private_resources_ai_gateway:
+ stats.numPrivateResourceInference,
+ num_ai_providers: stats.numAiProviders,
+ num_orgs_with_ai_providers: stats.numOrgsWithAiProviders,
+ num_ai_usage_records_recent:
+ stats.numAiUsageRecordsRecent,
+ num_orgs_with_recent_ai_usage:
+ stats.numOrgsWithRecentAiUsage,
num_newt_sites: stats.sites.filter((s) => s.type === "newt")
.length,
num_local_sites: stats.sites.filter(
diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx
index 9685722c5..f5b5b2868 100644
--- a/src/app/[orgId]/settings/resources/public/create/page.tsx
+++ b/src/app/[orgId]/settings/resources/public/create/page.tsx
@@ -50,8 +50,6 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
-import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
-import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
createBrowserGatewayTargetFormSchema,
@@ -59,7 +57,6 @@ import {
selectedSiteSchema,
type SshSettingsFormValues
} from "@app/lib/browserGatewayTargetFormSchema";
-import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries } from "@app/lib/queries";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -328,19 +325,20 @@ export default function Page() {
const rawResourcesAllowed =
env.flags.allowRawResources &&
(build !== "saas" || remoteExitNodes.length > 0);
- const enterpriseModesAllowed =
- !env.flags.disableEnterpriseFeatures;
const availableTypes = useMemo((): NewResourceType[] => {
- const base: NewResourceType[] = ["http", "inference"];
- if (enterpriseModesAllowed) {
- base.push("ssh", "rdp", "vnc");
- }
+ const base: NewResourceType[] = [
+ "http",
+ "inference",
+ "ssh",
+ "rdp",
+ "vnc"
+ ];
if (rawResourcesAllowed) {
base.push("tcp", "udp");
}
return base;
- }, [enterpriseModesAllowed, rawResourcesAllowed]);
+ }, [rawResourcesAllowed]);
useEffect(() => {
if (!availableTypes.includes(resourceType)) {