mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-02 17:29:43 +02:00
Merge branch 'dev' into feat/ip-filtering
This commit is contained in:
@@ -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"
|
||||
];
|
||||
@@ -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];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const LAST_USED_IDP_COOKIE_NAME = "p__last_used_idp";
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user