Merge branch 'dev' into feat/ip-filtering

This commit is contained in:
Fred KISSIE
2026-08-21 23:24:09 +02:00
622 changed files with 64427 additions and 8040 deletions
+79
View File
@@ -0,0 +1,79 @@
import type { AiBudget } from "@server/db";
export type AiBudgetScopeType =
| "provider"
| "model"
| "resource"
| "siteResource"
| "role"
| "virtualApiKey";
export type AiBudgetScope = {
type: AiBudgetScopeType;
id: number | string;
};
export type AiBudgetScopeBodyField =
| "providerId"
| "modelId"
| "resourceId"
| "siteResourceId"
| "roleId"
| "virtualApiKeyId";
const scopeConfig: Record<
AiBudgetScopeType,
{
listPath: (id: number | string) => string;
bodyField: AiBudgetScopeBodyField;
}
> = {
provider: {
listPath: (id) => `/ai-provider/${id}/ai-budgets`,
bodyField: "providerId"
},
model: {
listPath: (id) => `/ai-model/${id}/ai-budgets`,
bodyField: "modelId"
},
resource: {
listPath: (id) => `/resource/${id}/ai-budgets`,
bodyField: "resourceId"
},
siteResource: {
listPath: (id) => `/site-resource/${id}/ai-budgets`,
bodyField: "siteResourceId"
},
role: {
listPath: (id) => `/role/${id}/ai-budgets`,
bodyField: "roleId"
},
virtualApiKey: {
listPath: (id) => `/virtual-api-key/${id}/ai-budgets`,
bodyField: "virtualApiKeyId"
}
};
export function getAiBudgetScopeListPath(scope: AiBudgetScope): string {
return scopeConfig[scope.type].listPath(scope.id);
}
export function getAiBudgetScopeBodyField(
scope: AiBudgetScope
): AiBudgetScopeBodyField {
return scopeConfig[scope.type].bodyField;
}
export type AiBudgetUnit = AiBudget["unit"];
export type AiBudgetPeriod = AiBudget["period"];
export const AI_BUDGET_UNITS: AiBudgetUnit[] = ["usd", "tokens"];
export const AI_BUDGET_PERIODS: AiBudgetPeriod[] = [
"hourly",
"daily",
"weekly",
"monthly",
"yearly",
"lifetime"
];
+13
View File
@@ -0,0 +1,13 @@
export const AI_CAPABILITIES = [
"openai_chat",
"openai_responses",
"anthropic_messages",
"v1_models",
"gemini_generate_content",
"bedrock_model_invoke",
"google_generate_content",
"google_raw_predict",
"bedrock_converse"
] as const;
export type AiCapability = (typeof AI_CAPABILITIES)[number];
+431
View File
@@ -0,0 +1,431 @@
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<AiClientId, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
gemini: "Gemini CLI"
};
/** Auth as supplied by callers: the real key isn't fetched yet. */
export type AiClientAuthInput =
| { mode: "keyed"; getKeyText: () => Promise<string> }
| { mode: "keyless" };
/** Auth once the real key (if any) has been resolved. */
export type AiClientAuth = { mode: "keyed"; key: string } | { mode: "keyless" };
export type AiConfigBlock = {
id: string;
label: string;
kind?: "code" | "steps";
displayText: string;
/** True when the snippet includes example values the user must replace. */
hasPlaceholders?: boolean;
};
export type AiClientPresetId = "default" | "bedrock" | "vertex" | "kimi";
export type AiConfigRelation = "options" | "steps";
export type AiConfigPreset = {
id: AiClientPresetId;
label: string;
relation: AiConfigRelation;
blocks: AiConfigBlock[];
};
export type AiClientGuide = {
id: AiClientId;
name: string;
/** Alternative CLI commands. Empty/null means this client has no CLI setup. */
cli: AiConfigBlock[] | null;
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 : KEYLESS_PLACEHOLDER_KEY;
}
function block(
id: string,
label: string,
build: (keyValue: string) => string,
auth: AiClientAuth,
kind: "code" | "steps" = "code",
hasPlaceholders = false
): AiConfigBlock {
return {
id,
label,
kind,
displayText: build(keyValue(auth)),
hasPlaceholders
};
}
const EXAMPLE_RESOURCE_HOST = "example.resource.url.com";
export function aiConfigBlockHasPlaceholders(block: AiConfigBlock): boolean {
return (
block.hasPlaceholders === true ||
block.displayText.includes(EXAMPLE_RESOURCE_HOST)
);
}
function buildCli(
clientArg: "claude" | "codex" | "opencode" | "gemini",
auth: AiClientAuth,
resourceNiceId?: string
): AiConfigBlock[] {
const resourceFlag = resourceNiceId ? ` --resource ${resourceNiceId}` : "";
const configure: AiConfigBlock = {
id: `cli-configure-${clientArg}`,
label: "Configure",
displayText: `pangolin configure ${clientArg}${resourceFlag}`
};
if (auth.mode !== "keyed") {
return [configure];
}
return [
configure,
{
id: `cli-configure-key-${clientArg}`,
label: "Configure with an API key",
displayText: `pangolin configure ${clientArg} ${auth.key}${resourceFlag}`
}
];
}
function buildClaudeGuide(
endpoint: string,
auth: AiClientAuth,
resourceNiceId?: string
): AiClientGuide {
const defaultSettings = block(
"claude-default-settings",
"~/.claude/settings.json",
(key) =>
[
"{",
` "apiKeyHelper": "echo '${key}'",`,
' "env": {',
` "ANTHROPIC_BASE_URL": "${endpoint}"`,
" }",
"}"
].join("\n"),
auth
);
const defaultShell = block(
"claude-default-shell",
"Shell",
(key) =>
[
`export ANTHROPIC_BASE_URL=${endpoint}`,
`export ANTHROPIC_API_KEY=${key}`,
"claude"
].join("\n"),
auth
);
const bedrockSettings = block(
"claude-bedrock-settings",
"~/.claude/settings.json",
() =>
[
"{",
' "env": {',
' "ANTHROPIC_MODEL": "claude-sonnet-4-6",',
` "ANTHROPIC_BEDROCK_BASE_URL": "${endpoint}/bedrock",`,
' "CLAUDE_CODE_USE_BEDROCK": "1",',
' "CLAUDE_CODE_SKIP_BEDROCK_AUTH": "1"',
" }",
"}"
].join("\n"),
auth,
"code",
true
);
const vertexSettings = block(
"claude-vertex-settings",
"~/.claude/settings.json",
() =>
[
"{",
' "env": {',
' "CLOUD_ML_REGION": "global",',
' "ANTHROPIC_VERTEX_PROJECT_ID": "<your-gcp-project-id>",',
' "CLAUDE_CODE_USE_VERTEX": "1",',
' "CLAUDE_CODE_SKIP_VERTEX_AUTH": "1",',
` "ANTHROPIC_VERTEX_BASE_URL": "${endpoint}/v1"`,
" }",
"}"
].join("\n"),
auth,
"code",
true
);
const kimiSettings = block(
"claude-kimi-settings",
"~/.claude/settings.json",
(key) =>
[
"{",
` "apiKeyHelper": "echo '${key}'",`,
' "env": {',
` "ANTHROPIC_BASE_URL": "${endpoint}/anthropic",`,
' "ANTHROPIC_MODEL": "kimi-k2",',
' "ANTHROPIC_DEFAULT_OPUS_MODEL": "kimi-k2",',
' "ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2",',
' "ANTHROPIC_DEFAULT_HAIKU_MODEL": "kimi-k2",',
' "CLAUDE_CODE_SUBAGENT_MODEL": "kimi-k2",',
' "ENABLE_TOOL_SEARCH": "false"',
" }",
"}"
].join("\n"),
auth,
"code",
true
);
return {
id: "claude",
name: AI_CLIENT_NAMES.claude,
cli: buildCli("claude", auth, resourceNiceId),
presets: [
{
id: "default",
label: "Default (Anthropic)",
relation: "options",
blocks: [defaultSettings, defaultShell]
},
{
id: "bedrock",
label: "Amazon Bedrock",
relation: "options",
blocks: [bedrockSettings]
},
{
id: "vertex",
label: "Google Vertex AI",
relation: "options",
blocks: [vertexSettings]
},
{
id: "kimi",
label: "Kimi K2 (Moonshot AI)",
relation: "options",
blocks: [kimiSettings]
}
]
};
}
function buildCodexGuide(
endpoint: string,
auth: AiClientAuth,
resourceNiceId?: string
): AiClientGuide {
const settings = block(
"codex-settings",
"~/.codex/config.toml",
() =>
[
'model_provider = "pangolin"',
"",
"[model_providers.pangolin]",
'name = "Pangolin AI Gateway"',
`base_url = "${endpoint}/v1"`,
'wire_api = "responses"',
...(auth.mode === "keyed"
? ['env_key = "PANGOLIN_API_KEY"']
: [])
].join("\n"),
auth
);
const shell =
auth.mode === "keyed"
? block(
"codex-shell",
"Shell",
(key) => `export PANGOLIN_API_KEY=${key}`,
auth
)
: null;
return {
id: "codex",
name: AI_CLIENT_NAMES.codex,
cli: buildCli("codex", auth, resourceNiceId),
presets: [
{
id: "default",
label: "Default",
relation: "steps",
blocks: shell ? [settings, shell] : [settings]
}
]
};
}
function buildOpencodeGuide(
endpoint: string,
auth: AiClientAuth,
resourceNiceId?: string
): AiClientGuide {
const config = block(
"opencode-config",
"opencode.json",
() =>
[
"{",
' "$schema": "https://opencode.ai/config.json",',
' "provider": {',
' "anthropic": {',
' "options": {',
` "baseURL": "${endpoint}/v1"`,
" }",
" },",
' "openai": {',
' "options": {',
` "baseURL": "${endpoint}/v1"`,
" }",
" }",
" }",
"}"
].join("\n"),
auth
);
const authFile = block(
"opencode-auth",
"auth.json",
(key) =>
[
"{",
' "anthropic": {',
' "type": "api",',
` "key": "${key}"`,
" },",
' "openai": {',
' "type": "api",',
` "key": "${key}"`,
" }",
"}"
].join("\n"),
auth
);
const moreProviders = block(
"opencode-more-providers",
"More providers",
() =>
"OpenCode configures providers individually, so Anthropic and OpenAI are just the ones set up above. " +
'You can point any other OpenCode-supported provider (e.g. "openrouter", "google", "groq") at this gateway the same way: add a matching entry under "provider" in opencode.json, and a matching key in auth.json.',
auth,
"steps"
);
return {
id: "opencode",
name: AI_CLIENT_NAMES.opencode,
cli: buildCli("opencode", auth, resourceNiceId),
presets: [
{
id: "default",
label: "Default",
relation: "steps",
// 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]
}
]
};
}
function buildGeminiGuide(
endpoint: string,
auth: AiClientAuth,
resourceNiceId?: string
): AiClientGuide {
const defaultEnv = block(
"gemini-default-env",
"~/.gemini/.env",
(key) =>
[
`GOOGLE_GEMINI_BASE_URL=${endpoint}`,
`GEMINI_API_KEY=${key}`
].join("\n"),
auth
);
const defaultShell = block(
"gemini-default-shell",
"Shell",
(key) =>
[
`export GOOGLE_GEMINI_BASE_URL=${endpoint}`,
`export GEMINI_API_KEY=${key}`,
"gemini"
].join("\n"),
auth
);
return {
id: "gemini",
name: AI_CLIENT_NAMES.gemini,
cli: buildCli("gemini", auth, resourceNiceId),
presets: [
{
id: "default",
label: "Default",
relation: "options",
blocks: [defaultEnv, defaultShell]
}
]
};
}
const GUIDE_BUILDERS: Record<
AiClientId,
(
endpoint: string,
auth: AiClientAuth,
resourceNiceId?: string
) => AiClientGuide
> = {
claude: buildClaudeGuide,
codex: buildCodexGuide,
opencode: buildOpencodeGuide,
gemini: buildGeminiGuide
};
export function buildAiClientGuide(
clientId: AiClientId,
endpoint: string,
auth: AiClientAuth,
resourceNiceId?: string
): AiClientGuide {
return GUIDE_BUILDERS[clientId](endpoint, auth, resourceNiceId);
}
+107
View File
@@ -0,0 +1,107 @@
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
export type AiProviderType =
| "openai"
| "anthropic"
| "googleGemini"
| "vertexAi"
| "bedrock"
| "microsoftFoundry"
| "openRouter"
| "vercelAiGateway"
| "custom";
export const AI_PROVIDER_AUTH_TYPES = [
"bearer",
"x-api-key",
"x-goog-api-key",
"hec",
"cf-aig-authorization",
"none",
"passthrough"
] as const;
export type AiProviderAuthType = (typeof AI_PROVIDER_AUTH_TYPES)[number];
export type AiBudgetUnit = "usd" | "tokens";
export type AiProviderRoutingMode = "url" | "target";
type AiProviderDefaults = {
upstreamUrl: string | null;
authType: AiProviderAuthType;
capabilities: readonly AiCapability[];
};
export const AI_PROVIDER_DEFAULTS: Record<
Exclude<AiProviderType, "custom">,
AiProviderDefaults
> = {
openai: {
upstreamUrl: "https://api.openai.com/v1",
authType: "bearer",
capabilities: ["openai_chat", "openai_responses", "v1_models"]
},
anthropic: {
upstreamUrl: "https://api.anthropic.com",
authType: "x-api-key",
capabilities: ["anthropic_messages", "v1_models"]
},
googleGemini: {
upstreamUrl: "https://generativelanguage.googleapis.com",
authType: "x-goog-api-key",
capabilities: ["gemini_generate_content"]
},
vertexAi: {
upstreamUrl: null,
authType: "bearer",
capabilities: ["google_generate_content", "google_raw_predict"]
},
bedrock: {
upstreamUrl: null,
authType: "bearer",
capabilities: ["bedrock_converse"]
},
microsoftFoundry: {
upstreamUrl: null,
authType: "bearer",
capabilities: [
"openai_chat",
"openai_responses",
"anthropic_messages",
"v1_models"
]
},
openRouter: {
upstreamUrl: "https://openrouter.ai/api/v1",
authType: "bearer",
capabilities: ["openai_chat"]
},
vercelAiGateway: {
upstreamUrl: "https://ai-gateway.vercel.sh/v1",
authType: "bearer",
capabilities: ["openai_chat", "openai_responses"]
}
};
export function authTypeRequiresApiKey(authType: AiProviderAuthType): boolean {
return authType !== "none" && authType !== "passthrough";
}
export function providerRequiresUpstreamUrl(
type: AiProviderType,
routingMode: AiProviderRoutingMode = "url"
): boolean {
const mode = type === "custom" ? routingMode : "url";
return mode !== "target";
}
export function defaultsForProviderType(
type: AiProviderType
): readonly AiCapability[] {
if (type === "custom") {
return [];
}
return AI_PROVIDER_DEFAULTS[type].capabilities;
}
export { AI_CAPABILITIES };
export type { AiCapability };
+238
View File
@@ -0,0 +1,238 @@
import { z } from "zod";
import {
AI_CAPABILITIES,
AI_PROVIDER_AUTH_TYPES,
AI_PROVIDER_DEFAULTS,
authTypeRequiresApiKey,
defaultsForProviderType,
providerRequiresUpstreamUrl,
type AiCapability,
type AiProviderAuthType,
type AiProviderType
} from "@app/lib/aiProviderDefaults";
type TranslateFn = (key: string) => string;
export const aiProviderTypeValues = [
"openai",
"anthropic",
"googleGemini",
"vertexAi",
"bedrock",
"microsoftFoundry",
"openRouter",
"vercelAiGateway",
"custom"
] as const satisfies readonly AiProviderType[];
export const aiCapabilityValues = AI_CAPABILITIES;
export function createAiProviderFormSchema(t: TranslateFn) {
return z
.object({
name: z
.string()
.trim()
.min(1, { message: t("nameRequired") }),
type: z.enum(aiProviderTypeValues),
upstreamUrl: z.string().optional().nullable(),
apiKey: z.string().optional(),
authType: z.enum(AI_PROVIDER_AUTH_TYPES).optional().nullable(),
routingMode: z.enum(["url", "target"]).optional(),
capabilities: z.array(z.enum(AI_CAPABILITIES)).optional(),
headers: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable()
.optional(),
skipTlsVerification: z.boolean().optional(),
enabled: z.boolean().optional()
})
.superRefine((data, ctx) => {
const routingMode =
data.type === "custom" ? (data.routingMode ?? "url") : "url";
if (data.type !== "custom" && data.routingMode === "target") {
ctx.addIssue({
code: "custom",
message: t("aiProviderErrorRoutingModeTarget"),
path: ["routingMode"]
});
}
const upstreamUrl =
data.upstreamUrl && data.upstreamUrl.trim().length > 0
? data.upstreamUrl.trim()
: null;
if (upstreamUrl) {
try {
new URL(upstreamUrl);
} catch {
ctx.addIssue({
code: "custom",
message: t("aiProviderErrorUpstreamUrlInvalid"),
path: ["upstreamUrl"]
});
}
}
if (
providerRequiresUpstreamUrl(data.type, routingMode) &&
!upstreamUrl
) {
ctx.addIssue({
code: "custom",
message: t("aiProviderErrorUpstreamUrlRequired"),
path: ["upstreamUrl"]
});
}
if (!data.authType) {
ctx.addIssue({
code: "custom",
message: t("aiProviderErrorAuthTypeRequired"),
path: ["authType"]
});
}
if (!data.capabilities || data.capabilities.length === 0) {
ctx.addIssue({
code: "custom",
message: t("aiProviderErrorCapabilitiesRequired"),
path: ["capabilities"]
});
}
});
}
export function createAiProviderCreateFormSchema(t: TranslateFn) {
return createAiProviderFormSchema(t).superRefine((data, ctx) => {
const authType: AiProviderAuthType = data.authType ?? "bearer";
if (authTypeRequiresApiKey(authType) && !data.apiKey?.trim()) {
ctx.addIssue({
code: "custom",
message: t("aiProviderErrorApiKeyRequired"),
path: ["apiKey"]
});
}
});
}
export type AiProviderFormValues = z.infer<
ReturnType<typeof createAiProviderFormSchema>
>;
export function defaultAuthTypeForProvider(
type: AiProviderType
): AiProviderAuthType {
if (type === "custom") {
return "bearer";
}
return AI_PROVIDER_DEFAULTS[type].authType;
}
export function defaultCapabilitiesForProvider(
type: AiProviderType
): AiCapability[] {
return [...defaultsForProviderType(type)];
}
export function emptyUpstreamForType(type: AiProviderType): string {
if (type === "custom") {
return "";
}
return AI_PROVIDER_DEFAULTS[type].upstreamUrl ?? "";
}
export function showsUpstreamUrlField(
type: AiProviderType,
routingMode: "url" | "target" | undefined
): boolean {
const mode = type === "custom" ? (routingMode ?? "url") : "url";
return providerRequiresUpstreamUrl(type, mode);
}
export function toAiProviderCreatePayload(values: AiProviderFormValues) {
const routingMode =
values.type === "custom" ? (values.routingMode ?? "url") : "url";
const upstreamRaw = values.upstreamUrl?.trim() ?? "";
const upstreamUrl =
routingMode === "target"
? null
: upstreamRaw.length > 0
? upstreamRaw
: null;
return {
name: values.name.trim(),
type: values.type,
routingMode: values.type === "custom" ? routingMode : undefined,
upstreamUrl,
apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined,
authType: values.authType ?? "bearer",
capabilities: values.capabilities ?? [],
headers:
values.headers && values.headers.length > 0 ? values.headers : null,
skipTlsVerification: values.skipTlsVerification,
enabled: values.enabled ?? true
};
}
export function toAiProviderUpdatePayload(values: AiProviderFormValues) {
const routingMode =
values.type === "custom" ? (values.routingMode ?? "url") : "url";
const upstreamRaw = values.upstreamUrl?.trim() ?? "";
const upstreamUrl =
routingMode === "target"
? null
: upstreamRaw.length > 0
? upstreamRaw
: null;
const payload: Record<string, unknown> = {
name: values.name.trim(),
routingMode: values.type === "custom" ? routingMode : "url",
upstreamUrl,
authType: values.authType ?? "bearer",
skipTlsVerification: values.skipTlsVerification ?? false,
enabled: values.enabled ?? true
};
if (values.capabilities) {
payload.capabilities = values.capabilities;
}
if (values.apiKey?.trim()) {
payload.apiKey = values.apiKey.trim();
}
return payload;
}
export function toAiProviderNetworkPayload(values: AiProviderFormValues) {
const full = toAiProviderUpdatePayload(values);
return {
routingMode: full.routingMode,
upstreamUrl: full.upstreamUrl,
skipTlsVerification: full.skipTlsVerification,
headers:
values.headers && values.headers.length > 0 ? values.headers : null
};
}
export function toAiProviderAuthPayload(values: AiProviderFormValues) {
return {
authType: values.authType ?? "bearer",
...(values.apiKey !== undefined ? { apiKey: values.apiKey.trim() } : {})
};
}
export function toAiProviderConfigurationPayload(values: AiProviderFormValues) {
const {
name: _name,
enabled: _enabled,
...payload
} = toAiProviderUpdatePayload(values);
return payload;
}
+7 -2
View File
@@ -4,9 +4,13 @@ import { getCachedSubscription } from "./getCachedSubscription";
import { priv } from ".";
import { AxiosResponse } from "axios";
import { GetLicenseStatusResponse } from "@server/routers/license/types";
import { Tier } from "@server/types/Tiers";
export const isOrgSubscribed = cache(async (orgId: string) => {
const DEFAULT_PAID_TIERS: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
export const isOrgSubscribed = cache(async (orgId: string, tiers?: Tier[]) => {
let subscribed = false;
const allowedTiers = tiers ?? DEFAULT_PAID_TIERS;
if (build === "enterprise") {
try {
@@ -20,7 +24,8 @@ export const isOrgSubscribed = cache(async (orgId: string) => {
try {
const subRes = await getCachedSubscription(orgId);
subscribed =
(subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3" || subRes.data.data.tier == "enterprise") &&
!!subRes.data.data.tier &&
allowedTiers.includes(subRes.data.data.tier as Tier) &&
subRes.data.data.active;
} catch {}
}
+1
View File
@@ -0,0 +1 @@
export const LAST_USED_IDP_COOKIE_NAME = "p__last_used_idp";
+24
View File
@@ -0,0 +1,24 @@
import type {
Control,
FieldValues,
UseFormSetValue,
UseFormWatch
} from "react-hook-form";
export function asAnyControl<T extends FieldValues>(
control: Control<T>
): Control<any> {
return control as Control<any>;
}
export function asAnySetValue<T extends FieldValues>(
setValue: UseFormSetValue<T>
): UseFormSetValue<any> {
return setValue as UseFormSetValue<any>;
}
export function asAnyWatch<T extends FieldValues>(
watch: UseFormWatch<T>
): UseFormWatch<any> {
return watch as UseFormWatch<any>;
}
+3 -1
View File
@@ -1,5 +1,7 @@
import { SiteResource } from "@server/db";
export type SiteResourceDestinationInput = {
mode: "host" | "cidr" | "http" | "ssh";
mode: SiteResource["mode"];
destination: string | null;
destinationPort: number | null;
scheme: "http" | "https" | null;
+5
View File
@@ -0,0 +1,5 @@
export function getRandomItemInArray<T>(array: T[]) {
// Source - https://stackoverflow.com/a/4550514
const randomElement = array[Math.floor(Math.random() * array.length)];
return randomElement;
}
+13 -10
View File
@@ -37,7 +37,7 @@ export type LauncherAccessFields = {
export function formatPublicResourceAccess(
resource: PublicResourceAccessInput
): LauncherAccessFields {
const browserModes = ["http", "ssh", "rdp", "vnc"];
const browserModes = ["http", "ssh", "rdp", "vnc", "inference"];
if (!browserModes.includes(resource.mode)) {
const port = resource.proxyPort?.toString() ?? "";
return {
@@ -66,15 +66,10 @@ export function formatPublicResourceAccess(
export function formatSiteResourceAccess(
resource: SiteResourceAccessInput
): LauncherAccessFields {
if (resource.alias) {
return {
accessDisplay: resource.alias,
accessCopyValue: resource.alias,
accessUrl: null
};
}
if (resource.mode === "http" && resource.fullDomain) {
if (
(resource.mode === "http" || resource.mode === "inference") &&
resource.fullDomain
) {
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
return {
accessDisplay: url,
@@ -83,6 +78,14 @@ export function formatSiteResourceAccess(
};
}
if (resource.alias) {
return {
accessDisplay: resource.alias,
accessCopyValue: resource.alias,
accessUrl: null
};
}
const destination = formatSiteResourceDestinationDisplay({
mode: resource.mode as SiteResourceDestinationInput["mode"],
destination: resource.destination,
+5 -1
View File
@@ -4,7 +4,7 @@ import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSi
export type PublicAuthState = "protected" | "not_protected" | "none";
const BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
const BROWSER_MODES = ["http", "ssh", "rdp", "vnc", "inference"];
export function derivePublicAuthState(
mode: string | null,
@@ -37,6 +37,10 @@ export function formatPublicResourceType(
return resource.ssl ? "HTTPS" : "HTTP";
}
if (resource.mode === "inference") {
return "AI Gateway";
}
const mode = (resource.mode || "").toLowerCase();
if (mode === "tcp") {
return "TCP";
+5 -1
View File
@@ -1,6 +1,7 @@
import { priv } from "@app/lib/api";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types";
import { AxiosResponse } from "axios";
@@ -11,7 +12,10 @@ export async function loadOrgLoginPageBranding(orgId: string): Promise<{
return { primaryColor: null };
}
const subscribed = await isOrgSubscribed(orgId);
const subscribed = await isOrgSubscribed(
orgId,
tierMatrix.loginPageBranding
);
if (!subscribed) {
return { primaryColor: null };
}
+103 -25
View File
@@ -1,9 +1,10 @@
import { z } from "zod";
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable";
import type { PrivateResourceRow } from "@app/components/PrivateResourcesTable";
import { SiteResource } from "@server/db";
export type PrivateResourceMode = "host" | "cidr" | "http" | "ssh";
export type PrivateResourceMode = SiteResource["mode"];
export type SiteResourceData = InternalResourceRow & {
export type SiteResourceData = PrivateResourceRow & {
enabled: boolean;
};
@@ -20,7 +21,7 @@ export type PrivateResourceClient = {
export type PrivateResourceFormValues = {
name: string;
siteIds: number[];
siteIds?: number[];
mode: PrivateResourceMode;
destination: string | null;
alias?: string | null;
@@ -41,6 +42,7 @@ export type PrivateResourceFormValues = {
roles?: PrivateResourceAccessTag[];
users?: PrivateResourceAccessTag[];
clients?: PrivateResourceClient[];
providerIds?: number[];
};
export type SiteResourceAccess = {
@@ -162,10 +164,12 @@ export function buildCreateSiteResourcePayload(
return {
name: data.name,
siteIds: data.siteIds,
siteIds: data.siteIds ?? [],
mode: data.mode,
destination: isNativeSsh ? undefined : (data.destination ?? undefined),
enabled: true,
destination:
isNativeSsh || data.mode === "inference"
? undefined
: (data.destination ?? undefined),
...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
@@ -211,6 +215,18 @@ export function buildCreateSiteResourcePayload(
authDaemonPort: data.authDaemonPort
})
}),
...(data.mode === "inference" && {
aiProviders: (data.providerIds ?? []).map((providerId) => ({
providerId
})),
ssl: data.ssl ?? false,
domainId: data.httpConfigDomainId
? data.httpConfigDomainId
: undefined,
subdomain: data.httpConfigSubdomain
? data.httpConfigSubdomain
: undefined
}),
...((data.mode === "host" || data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
@@ -237,7 +253,9 @@ export function buildUpdateSiteResourcePayload(
enabled: data.enabled,
...(isNativeSsh
? { destination: null, destinationPort: null }
: { destination: data.destination ?? undefined }),
: data.mode !== "inference"
? { destination: data.destination ?? undefined }
: {}),
...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
@@ -281,6 +299,21 @@ export function buildUpdateSiteResourcePayload(
authDaemonPort: data.authDaemonPort || null
})
}),
...(data.mode === "inference" && {
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: null,
ssl: data.ssl ?? false,
domainId: data.httpConfigDomainId
? data.httpConfigDomainId
: undefined,
subdomain: data.httpConfigSubdomain
? data.httpConfigSubdomain
: undefined
}),
...((data.mode === "host" || data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
@@ -332,18 +365,33 @@ export function siteResourceToFormValues(
};
}
export function createGeneralFormSchema(t: TranslateFn) {
return z.object({
name: z
.string()
.min(1, t("editInternalResourceDialogNameRequired"))
.max(255, t("editInternalResourceDialogNameMaxLength")),
niceId: z
.string()
.min(1)
.max(255)
.regex(/^[a-zA-Z0-9-]+$/)
});
export function createGeneralFormSchema(
t: TranslateFn,
options?: { requireAlias?: boolean }
) {
return z
.object({
name: z
.string()
.min(1, t("editInternalResourceDialogNameRequired"))
.max(255, t("editInternalResourceDialogNameMaxLength")),
niceId: z
.string()
.min(1)
.max(255)
.regex(/^[a-zA-Z0-9-]+$/),
enabled: z.boolean(),
alias: z.string().nullish()
})
.superRefine((data, ctx) => {
if (options?.requireAlias && !data.alias?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("aiResourceAliasRequired"),
path: ["alias"]
});
}
});
}
export function createAccessFormSchema() {
@@ -372,10 +420,8 @@ export function createCreateFormSchema(t: TranslateFn) {
.string()
.min(1, t("createInternalResourceDialogNameRequired"))
.max(255, t("createInternalResourceDialogNameMaxLength")),
siteIds: z
.array(z.number().int().positive())
.min(1, t("createInternalResourceDialogPleaseSelectSite")),
mode: z.enum(["host", "cidr", "http", "ssh"]),
siteIds: z.array(z.number().int().positive()).optional(),
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]),
destination: z.string().nullish(),
alias: z.string().nullish(),
destinationPort: z
@@ -402,14 +448,26 @@ export function createCreateFormSchema(t: TranslateFn) {
pamMode: z.enum(["passthrough", "push"]).optional().nullable(),
tcpPortRangeString: createPortRangeStringSchema(t),
udpPortRangeString: createPortRangeStringSchema(t),
disableIcmp: z.boolean().optional()
disableIcmp: z.boolean().optional(),
providerIds: z.array(z.number().int().positive()).optional()
})
.superRefine((data, ctx) => {
const isNativeSsh =
data.mode === "ssh" && data.authDaemonMode === "native";
const trimmedDestination = data.destination?.trim();
if (
data.mode !== "inference" &&
(!data.siteIds || data.siteIds.length < 1)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("createInternalResourceDialogPleaseSelectSite"),
path: ["siteIds"]
});
}
if (
data.mode !== "ssh" &&
data.mode !== "inference" &&
(!trimmedDestination || trimmedDestination.length < 1)
) {
ctx.addIssue({
@@ -484,6 +542,7 @@ function destinationRefine(
const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native";
const trimmedDestination = data.destination?.trim();
if (
data.mode !== "inference" &&
!isNativeSsh &&
(!trimmedDestination || trimmedDestination.length < 1)
) {
@@ -554,6 +613,25 @@ export function createHostFormSchema(t: TranslateFn) {
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
}
export function createInferenceFormSchema(t: TranslateFn) {
return z
.object({
mode: z.literal("inference"),
alias: z.string().nullish(),
providerIds: z.array(z.number().int().positive()).optional()
})
.superRefine((data, ctx) => {
const trimmedAlias = data.alias?.trim();
if (!trimmedAlias) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("aiResourceAliasRequired"),
path: ["alias"]
});
}
});
}
export function createCidrFormSchema(t: TranslateFn) {
return z
.object({
+36
View File
@@ -0,0 +1,36 @@
"use client";
import type { Selectedsite } from "@app/components/site-selector";
import type { SiteResourceData } from "@app/lib/privateResourceForm";
export function buildSelectedSitesForResource(
resource: Pick<SiteResourceData, "siteIds" | "siteNames">
): Selectedsite[] {
return resource.siteIds.map((siteId, idx) => ({
name: resource.siteNames[idx] ?? "",
siteId,
type: "newt" as const
}));
}
export function getSshSingleSiteMode(
authDaemonMode?: string | null,
pamMode?: string | null
): boolean {
return (
authDaemonMode === "native" ||
(pamMode === "push" && authDaemonMode === "site")
);
}
export function getSshUseMultiSiteTargetForm(
isNative: boolean,
authDaemonMode?: string | null,
pamMode?: string | null
): boolean {
if (isNative) {
return false;
}
return authDaemonMode !== "site" || pamMode === "passthrough";
}
+696 -41
View File
@@ -1,15 +1,39 @@
import {
getAiBudgetScopeListPath,
type AiBudgetScope
} from "@app/lib/aiBudgetScope";
import type { AiProviderType } from "@app/lib/aiProviderDefaults";
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import { build } from "@server/build";
import { StatusHistoryResponse } from "@server/lib/statusHistory";
import {
StatusHistoryResponse,
type BatchedStatusHistoryResponse
} from "@server/lib/statusHistory";
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
import type {
ListAiModelsResponse,
ListAiProvidersResponse,
ListCatalogModelsResponse
} from "@server/routers/aiProvider/types";
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
import type {
QueryAiUsageFilterOptionsResponse,
QueryAiUsageOverviewResponse,
QueryAiUsageProvidersResponse,
QueryAiUsageResourcesResponse,
QueryAiUsageUsersRolesResponse,
QueryAiUsageVirtualApiKeysResponse,
QueryRequestAnalyticsResponse
} from "@server/routers/auditLogs";
import type {
QueryAccessAuditLogResponse,
QueryActionAuditLogResponse,
QueryAiSessionLogResponse,
QueryConnectionAuditLogResponse,
QueryRequestAuditLogResponse
} from "@server/routers/auditLogs/types";
import type { GetCertificateResponse } from "@server/routers/certificates/types";
import type {
ListClientsResponse,
ListUserDevicesResponse
@@ -21,6 +45,7 @@ import type {
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import type { ListLauncherAiModelsResponse } from "@server/routers/launcher/listLauncherAiModels";
import type {
LauncherResource,
ListLauncherGroupsResponse,
@@ -31,9 +56,11 @@ import type {
ListLauncherViewsResponse
} from "@server/routers/launcher/types";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import type {
GetResourcePoliciesResponse,
GetResourceWhitelistResponse,
ListResourceAiModelsResponse,
ListResourceNamesResponse,
ListResourceRolesResponse,
ListResourceRulesResponse,
@@ -47,6 +74,7 @@ import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site";
import type {
ListAllSiteResourcesByOrgResponse,
ListSiteResourceAiModelsResponse,
ListSiteResourceClientsResponse,
ListSiteResourceRolesResponse,
ListSiteResourceUsersResponse
@@ -54,13 +82,14 @@ import type {
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
import type { ListTargetsResponse } from "@server/routers/target";
import type { ListUsersResponse } from "@server/routers/user";
import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
import type ResponseT from "@server/types/Response";
import {
infiniteQueryOptions,
keepPreviousData,
queryOptions
} from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { isAxiosError, type AxiosResponse } from "axios";
import z from "zod";
import { remote } from "./api";
import { durationToMs } from "./durationToMs";
@@ -302,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<ListRemoteExitNodesResponse>
>(`/org/${orgId}/remote-exit-nodes`, { signal });
return res.data.data.remoteExitNodes;
}
}),
labels: ({
orgId,
query,
@@ -366,18 +406,20 @@ export const orgQueries = {
proxyResources: ({
orgId,
query,
perPage = 10_000
perPage = 10_000,
protocol
}: {
orgId: string;
query?: string;
perPage?: number;
protocol?: string;
}) =>
queryOptions({
queryKey: [
"ORG",
orgId,
"PROXY_RESOURCES",
{ query, perPage }
{ query, perPage, protocol }
] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
@@ -388,6 +430,10 @@ export const orgQueries = {
sp.set("query", query);
}
if (protocol) {
sp.set("protocol", protocol);
}
const res = await meta!.api.get<
AxiosResponse<ListResourcesResponse>
>(`/org/${orgId}/resources?${sp.toString()}`, { signal });
@@ -638,6 +684,143 @@ export const orgQueries = {
};
}
}),
batchedSiteStatusHistory: ({
siteIds,
orgId,
days = 90
}: {
orgId: string;
siteIds: number[];
days?: number;
}) =>
queryOptions({
queryKey: [
"ORG",
orgId,
"BATCHED_SITE_STATUS_HISTORY",
siteIds,
days
] as const,
queryFn: async ({ signal, meta }) => {
// Negated because getTimezoneOffset() returns UTC - local,
// while the API expects minutes to add to UTC to get local
const tzOffsetMinutes = -new Date().getTimezoneOffset();
const sp = new URLSearchParams([
["days", days.toString()],
["tzOffsetMinutes", tzOffsetMinutes.toString()],
...siteIds.map((id) => ["siteIds", id.toString()])
]);
const res = await meta!.api.get<
AxiosResponse<BatchedStatusHistoryResponse>
>(`/org/${orgId}/site-status-histories?${sp.toString()}`, {
signal
});
return res.data.data;
},
staleTime: durationToMs(5, "seconds")
}),
batchedHealthCheckStatusHistory: ({
healthCheckIds,
orgId,
days = 90
}: {
orgId: string;
healthCheckIds: number[];
days?: number;
}) =>
queryOptions({
queryKey: [
"ORG",
orgId,
"BATCHED_HEALTH_CHECK_STATUS_HISTORY",
healthCheckIds,
days
] as const,
queryFn: async ({ signal, meta }) => {
// Negated because getTimezoneOffset() returns UTC - local,
// while the API expects minutes to add to UTC to get local
const tzOffsetMinutes = -new Date().getTimezoneOffset();
const sp = new URLSearchParams([
["days", days.toString()],
["tzOffsetMinutes", tzOffsetMinutes.toString()],
...healthCheckIds.map((id) => [
"healthCheckIds",
id.toString()
])
]);
const res = await meta!.api.get<
AxiosResponse<BatchedStatusHistoryResponse>
>(
`/org/${orgId}/health-check-status-histories?${sp.toString()}`,
{ signal }
);
return res.data.data;
},
staleTime: durationToMs(5, "seconds")
}),
batchedDomainCertificates: ({
domains,
orgId
}: {
orgId: string;
domains: string[];
}) =>
queryOptions({
queryKey: ["ORG", orgId, "BATCHED_CERTIFICATES", domains] as const,
queryFn: async ({ signal, meta }) => {
// Negated because getTimezoneOffset() returns UTC - local,
// while the API expects minutes to add to UTC to get local
const sp = new URLSearchParams([
...domains.map((domain) => ["domains", domain.toString()])
]);
const res = await meta!.api.get<
AxiosResponse<BatchedStatusHistoryResponse>
>(`/org/${orgId}/batched-certificates?${sp.toString()}`, {
signal
});
return res.data.data;
},
staleTime: durationToMs(5, "seconds")
}),
batchedResourceStatusHistory: ({
resourceIds,
orgId,
days = 90
}: {
orgId: string;
resourceIds: number[];
days?: number;
}) =>
queryOptions({
queryKey: [
"ORG",
orgId,
"BATCHED_RESOURCE_STATUS_HISTORY",
resourceIds,
days
] as const,
queryFn: async ({ signal, meta }) => {
// Negated because getTimezoneOffset() returns UTC - local,
// while the API expects minutes to add to UTC to get local
const tzOffsetMinutes = -new Date().getTimezoneOffset();
const sp = new URLSearchParams([
["days", days.toString()],
["tzOffsetMinutes", tzOffsetMinutes.toString()],
...resourceIds.map((id) => ["resourceIds", id.toString()])
]);
const res = await meta!.api.get<
AxiosResponse<BatchedStatusHistoryResponse>
>(`/org/${orgId}/resource-status-histories?${sp.toString()}`, {
signal
});
return res.data.data;
},
staleTime: durationToMs(5, "seconds")
}),
siteStatusHistory: ({
siteId,
days = 90
@@ -647,10 +830,15 @@ export const orgQueries = {
}) =>
queryOptions({
queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const,
staleTime: durationToMs(5, "seconds"),
queryFn: async ({ signal, meta }) => {
const tzOffsetMinutes = -new Date().getTimezoneOffset();
const res = await meta!.api.get<
AxiosResponse<StatusHistoryResponse>
>(`/site/${siteId}/status-history?days=${days}`, { signal });
>(
`/site/${siteId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`,
{ signal }
);
return res.data.data;
}
}),
@@ -664,12 +852,15 @@ export const orgQueries = {
}) =>
queryOptions({
queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const,
staleTime: durationToMs(5, "seconds"),
queryFn: async ({ signal, meta }) => {
const tzOffsetMinutes = -new Date().getTimezoneOffset();
const res = await meta!.api.get<
AxiosResponse<StatusHistoryResponse>
>(`/resource/${resourceId}/status-history?days=${days}`, {
signal
});
>(
`/resource/${resourceId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`,
{ signal }
);
return res.data.data;
}
}),
@@ -684,6 +875,7 @@ export const orgQueries = {
days?: number;
}) =>
queryOptions({
staleTime: durationToMs(5, "seconds"),
queryKey: [
"HC_STATUS_HISTORY",
orgId,
@@ -691,10 +883,11 @@ export const orgQueries = {
days
] as const,
queryFn: async ({ signal, meta }) => {
const tzOffsetMinutes = -new Date().getTimezoneOffset();
const res = await meta!.api.get<
AxiosResponse<StatusHistoryResponse>
>(
`/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}`,
`/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`,
{ signal }
);
return res.data.data;
@@ -756,6 +949,33 @@ export const logAnalyticsFiltersSchema = z.object({
export type LogAnalyticsFilters = z.output<typeof logAnalyticsFiltersSchema>;
export const aiUsageAnalyticsFiltersSchema = z.object({
timeStart: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string"
})
.optional()
.catch(undefined),
timeEnd: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string"
})
.optional()
.catch(undefined),
providerId: z.coerce.number().optional().catch(undefined),
model: z.string().optional().catch(undefined),
resourceId: z.coerce.number().optional().catch(undefined),
roleId: z.coerce.number().optional().catch(undefined),
userId: z.string().optional().catch(undefined),
virtualApiKeyId: z.string().optional().catch(undefined)
});
export type AiUsageAnalyticsFilters = z.output<
typeof aiUsageAnalyticsFiltersSchema
>;
export const httpLogsFiltersSchema = z.object({
timeStart: z
.string()
@@ -862,6 +1082,34 @@ export const connectionLogsFiltersSchema = z.object({
export type ConnectionLogFilters = z.output<typeof connectionLogsFiltersSchema>;
export const aiSessionLogsFiltersSchema = z.object({
timeStart: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string"
})
.optional()
.catch(undefined),
timeEnd: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string"
})
.optional()
.catch(undefined),
page: z.coerce.number().optional().catch(0).default(0),
pageSize: z.coerce.number().optional().catch(20).default(20),
providerId: z.string().optional().catch(undefined),
capability: z.string().optional().catch(undefined),
resourceId: z.string().optional().catch(undefined),
actor: z.string().optional().catch(undefined),
virtualApiKeyId: z.string().optional().catch(undefined),
model: z.string().optional().catch(undefined),
isStream: z.string().optional().catch(undefined)
});
export type AiSessionLogFilters = z.output<typeof aiSessionLogsFiltersSchema>;
export const logQueries = {
requestAnalytics: ({
orgId,
@@ -880,12 +1128,6 @@ export const logQueries = {
signal
});
return res.data.data;
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
}
return false;
}
}),
@@ -914,12 +1156,6 @@ export const logQueries = {
signal
});
return res.data.data;
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
}
return false;
}
}),
@@ -948,12 +1184,6 @@ export const logQueries = {
signal
});
return res.data.data;
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
}
return false;
}
}),
@@ -979,12 +1209,6 @@ export const logQueries = {
signal
});
return res.data.data;
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
}
return false;
}
}),
@@ -1010,12 +1234,269 @@ export const logQueries = {
signal
});
return res.data.data;
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
}
return false;
}
}),
aiSessions: ({
orgId,
filters
}: {
orgId: string;
filters: AiSessionLogFilters;
}) =>
queryOptions({
queryKey: ["AI_SESSION_LOGS", orgId, "ALL", filters] as const,
queryFn: async ({ signal, meta }) => {
const { page, pageSize, ...rest } = filters;
const res = await meta!.api.get<
AxiosResponse<QueryAiSessionLogResponse>
>(`/org/${orgId}/logs/ai`, {
params: {
...rest,
limit: pageSize,
offset: page * pageSize
},
signal
});
return res.data.data;
}
})
};
export const aiUsageAnalyticsQueries = {
filterOptions: ({
orgId,
filters
}: {
orgId: string;
filters: Pick<AiUsageAnalyticsFilters, "timeStart" | "timeEnd">;
}) =>
queryOptions({
queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"FILTERS",
filters
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<QueryAiUsageFilterOptionsResponse>
>(`/org/${orgId}/logs/ai/usage/filters`, {
params: filters,
signal
});
return res.data.data;
}
}),
overview: ({
orgId,
filters
}: {
orgId: string;
filters: AiUsageAnalyticsFilters;
}) =>
queryOptions({
queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"OVERVIEW",
filters
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<QueryAiUsageOverviewResponse>
>(`/org/${orgId}/logs/ai/usage/overview`, {
params: filters,
signal
});
return res.data.data;
}
}),
providers: ({
orgId,
filters
}: {
orgId: string;
filters: AiUsageAnalyticsFilters;
}) =>
queryOptions({
queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"PROVIDERS",
filters
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<QueryAiUsageProvidersResponse>
>(`/org/${orgId}/logs/ai/usage/providers`, {
params: filters,
signal
});
return res.data.data;
}
}),
resources: ({
orgId,
filters
}: {
orgId: string;
filters: AiUsageAnalyticsFilters;
}) =>
queryOptions({
queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"RESOURCES",
filters
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<QueryAiUsageResourcesResponse>
>(`/org/${orgId}/logs/ai/usage/resources`, {
params: filters,
signal
});
return res.data.data;
}
}),
usersRoles: ({
orgId,
filters
}: {
orgId: string;
filters: AiUsageAnalyticsFilters;
}) =>
queryOptions({
queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"USERS_ROLES",
filters
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<QueryAiUsageUsersRolesResponse>
>(`/org/${orgId}/logs/ai/usage/users-roles`, {
params: filters,
signal
});
return res.data.data;
}
}),
virtualApiKeys: ({
orgId,
filters
}: {
orgId: string;
filters: AiUsageAnalyticsFilters;
}) =>
queryOptions({
queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"VIRTUAL_API_KEYS",
filters
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<QueryAiUsageVirtualApiKeysResponse>
>(`/org/${orgId}/logs/ai/usage/virtual-api-keys`, {
params: filters,
signal
});
return res.data.data;
}
})
};
export const aiProviderQueries = {
providerTargets: ({ providerId }: { providerId: number }) =>
queryOptions({
queryKey: ["AI_PROVIDERS", providerId, "TARGETS"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListTargetsResponse>
>(`/ai-provider/${providerId}/targets`, { signal });
return res.data.data.targets;
}
}),
providerModels: ({ providerId }: { providerId: number }) =>
queryOptions({
queryKey: ["AI_PROVIDERS", providerId, "MODELS"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListAiModelsResponse>
>(`/ai-provider/${providerId}/models`, {
params: { page: 1, pageSize: 1000 },
signal
});
return res.data.data.models;
}
}),
catalogModels: ({ providerId }: { providerId: number }) =>
queryOptions({
queryKey: ["AI_PROVIDERS", providerId, "CATALOG_MODELS"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListCatalogModelsResponse>
>(`/ai-provider/${providerId}/catalog-models`, { signal });
return res.data.data.models;
}
}),
catalogModelsByType: ({
orgId,
type
}: {
orgId: string;
type: AiProviderType;
}) =>
queryOptions({
queryKey: ["AI_PROVIDERS", orgId, "CATALOG_MODELS", type] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListCatalogModelsResponse>
>(`/org/${orgId}/ai-catalog-models`, {
params: { type },
signal
});
return res.data.data.models;
}
}),
orgProviders: ({ orgId, query }: { orgId: string; query?: string }) =>
queryOptions({
queryKey: ["AI_PROVIDERS", orgId, "LIST", query ?? ""] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListAiProvidersResponse>
>(`/org/${orgId}/ai-providers`, {
params: {
page: 1,
pageSize: 100,
...(query ? { query } : {})
},
signal
});
return res.data.data.providers;
}
})
};
export const aiBudgetQueries = {
scoped: ({ scope }: { scope: AiBudgetScope }) =>
queryOptions({
queryKey: ["AI_BUDGETS", scope.type, scope.id] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListAiBudgetsByScopeResponse>
>(getAiBudgetScopeListPath(scope), { signal });
return res.data.data.budgets;
}
})
};
@@ -1085,6 +1566,74 @@ export const resourceQueries = {
return res.data.data.clients;
}
}),
siteResourceAiProviders: ({ siteResourceId }: { siteResourceId: number }) =>
queryOptions({
queryKey: [
"SITE_RESOURCES",
siteResourceId,
"AI_PROVIDERS"
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<{
providers: Array<{
providerId: number;
niceId: string;
name: string;
type: string;
enabled: boolean;
providerEnabled: boolean;
accessMode: "inherit" | "select";
}>;
}>
>(`/site-resource/${siteResourceId}/ai-providers`, {
signal
});
return res.data.data.providers;
}
}),
resourceAiProviders: ({ resourceId }: { resourceId: number }) =>
queryOptions({
queryKey: ["RESOURCES", resourceId, "AI_PROVIDERS"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<{
providers: Array<{
providerId: number;
niceId: string;
name: string;
type: string;
enabled: boolean;
providerEnabled: boolean;
accessMode: "inherit" | "select";
}>;
}>
>(`/resource/${resourceId}/ai-providers`, {
signal
});
return res.data.data.providers;
}
}),
resourceAiModels: ({ resourceId }: { resourceId: number }) =>
queryOptions({
queryKey: ["RESOURCES", resourceId, "AI_MODELS"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListResourceAiModelsResponse>
>(`/resource/${resourceId}/ai-models`, { signal });
return res.data.data.models;
}
}),
siteResourceAiModels: ({ siteResourceId }: { siteResourceId: number }) =>
queryOptions({
queryKey: ["SITE_RESOURCES", siteResourceId, "AI_MODELS"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListSiteResourceAiModelsResponse>
>(`/site-resource/${siteResourceId}/ai-models`, { signal });
return res.data.data.models;
}
}),
resourceTargets: ({ resourceId }: { resourceId: number }) =>
queryOptions({
queryKey: ["RESOURCES", resourceId, "TARGETS"] as const,
@@ -1228,7 +1777,7 @@ export const approvalQueries = {
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
return durationToMs(1.5, "minutes");
}
return false;
}
@@ -1236,6 +1785,48 @@ export const approvalQueries = {
};
export const domainQueries = {
getCertificate: ({
orgId,
domainId,
domain
}: {
orgId: string;
domainId: string;
domain: string;
}) =>
queryOptions({
queryKey: [
"ORG",
orgId,
"DOMAIN",
domainId,
"CERTIFICATE",
domain
] as const,
queryFn: async ({ signal, meta }) => {
try {
const res = await meta!.api.get<
AxiosResponse<GetCertificateResponse | null>
>(`/org/${orgId}/certificate/${domainId}/${domain}`, {
signal
});
return res.data.data;
} catch (error) {
// the endpoint 404s when the domain has no certificate yet
if (isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
},
retry: (failureCount, error) =>
isAxiosError(error) &&
error.response != null &&
error.response.status < 500
? false
: failureCount < 2,
staleTime: durationToMs(1, "minutes")
}),
getDomain: ({ orgId, domainId }: { orgId: string; domainId: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "DOMAIN", domainId] as const,
@@ -1440,5 +2031,69 @@ export const launcherQueries = {
data: res.data.data
};
}
}),
aiModels: (
orgId: string,
params:
| {
resourceType: "public";
resourceId: number;
}
| {
resourceType: "site";
siteResourceId: number;
}
| null
) =>
queryOptions({
queryKey: ["ORG", orgId, "LAUNCHER", "AI_MODELS", params] as const,
enabled: params != null,
queryFn: async ({ signal, meta }) => {
if (!params) {
throw new Error("Resource params are required");
}
if (params.resourceType === "public") {
const res = await meta!.api.get<
AxiosResponse<ListLauncherAiModelsResponse>
>(
`/org/${orgId}/launcher/resource/${params.resourceId}/ai-models`,
{ signal }
);
return res.data.data;
}
const res = await meta!.api.get<
AxiosResponse<ListLauncherAiModelsResponse>
>(
`/org/${orgId}/launcher/site-resource/${params.siteResourceId}/ai-models`,
{ signal }
);
return res.data.data;
}
}),
myVirtualApiKeys: (orgId: string, resourceGuid: string | null) =>
queryOptions({
queryKey: [
"ORG",
orgId,
"LAUNCHER",
"MY_VIRTUAL_API_KEYS",
resourceGuid
] as const,
enabled: Boolean(resourceGuid),
queryFn: async ({ signal, meta }) => {
if (!resourceGuid) {
throw new Error("resourceGuid is required");
}
const res = await meta!.api.get<
AxiosResponse<ListMyVirtualApiKeysResponse>
>(
`/org/${orgId}/my-virtual-api-keys?resourceGuid=${encodeURIComponent(resourceGuid)}`,
{ signal }
);
return res.data.data;
}
})
};
+25
View File
@@ -0,0 +1,25 @@
export function setClientCookie(
name: string,
value: string,
options: {
days?: number;
path?: string;
secure?: boolean;
sameSite?: "Strict" | "Lax" | "None";
} = {}
): void {
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
if (options.days) {
const date = new Date();
date.setTime(date.getTime() + options.days * 864e5);
cookie += `; expires=${date.toUTCString()}`;
}
cookie += `; path=${options.path ?? "/"}`;
if (options.secure) cookie += "; Secure";
if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
document.cookie = cookie;
}
+63
View File
@@ -0,0 +1,63 @@
export const VIRTUAL_API_KEY_PREFIX = "pangolin-key-";
const VIRTUAL_API_KEY_AUTH_HEADER_NAMES = [
"authorization",
"x-api-key",
"x-goog-api-key",
"cf-aig-authorization"
] as const;
export function formatVirtualApiKeyCredential(
virtualApiKeyId: string,
secret: string
): string {
return `${VIRTUAL_API_KEY_PREFIX}${virtualApiKeyId}.${secret}`;
}
export function formatVirtualApiKeyPreview(
virtualApiKeyId: string,
lastChars: string
): string {
return `${VIRTUAL_API_KEY_PREFIX}${virtualApiKeyId}••••${lastChars}`;
}
export function looksLikeVirtualApiKeyCredential(value: string): boolean {
const trimmed = value.trim();
if (!trimmed.startsWith(VIRTUAL_API_KEY_PREFIX)) {
return false;
}
const withoutPrefix = trimmed.slice(VIRTUAL_API_KEY_PREFIX.length);
const dot = withoutPrefix.indexOf(".");
return dot > 0 && dot < withoutPrefix.length - 1;
}
function headerValueCarriesVirtualApiKey(raw: string): boolean {
const trimmed = raw.trim();
const bearerMatch = trimmed.match(/^(?:Bearer|Splunk)\s+(.+)$/i);
if (bearerMatch) {
return looksLikeVirtualApiKeyCredential(bearerMatch[1]);
}
return looksLikeVirtualApiKeyCredential(trimmed);
}
/**
* Remove client headers that carry a Pangolin virtual API key so they are
* never forwarded to upstream providers (including passthrough auth).
*/
export function stripVirtualApiKeyAuthHeaders(
headers: Record<string, string>
): void {
for (const key of Object.keys(headers)) {
const lower = key.toLowerCase();
if (
!(VIRTUAL_API_KEY_AUTH_HEADER_NAMES as readonly string[]).includes(
lower
)
) {
continue;
}
if (headerValueCarriesVirtualApiKey(headers[key])) {
delete headers[key];
}
}
}