first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

15
ui/lib/utils/array.ts Normal file
View File

@@ -0,0 +1,15 @@
export const parseArrayFromText = (text?: string): string[] => {
if (!text) return [];
return text
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
};
export const isArrayEqual = <T>(array1: T[], array2: T[]): boolean => {
return array1?.length === array2?.length && array1?.every((value, index) => value === array2[index]);
};
export const isArrayOverlapping = <T>(array1: T[], array2: T[]): boolean => {
return array1?.some((value) => array2.includes(value));
};

View File

@@ -0,0 +1,32 @@
const safeStringify = (value: unknown, space: number): string => {
try {
return JSON.stringify(value, null, space);
} catch {
const seen = new WeakSet();
return JSON.stringify(
value,
(_key, val) => {
if (typeof val === "bigint") return val.toString();
if (typeof val === "object" && val !== null) {
if (seen.has(val)) return "[Circular]";
seen.add(val);
}
return val;
},
space
);
}
};
export const downloadAsJson = (data: unknown, filename: string) => {
const json = safeStringify(data, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename.endsWith(".json") ? filename : `${filename}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 0);
};

View File

@@ -0,0 +1,371 @@
/**
* CEL Converter for Routing Rules
* Converts react-querybuilder rules to CEL expressions
*/
import { RuleGroupType, RuleType } from "react-querybuilder";
import { getOperatorCELSyntax } from "@/lib/config/celOperatorsRouting";
/**
* RE2-incompatible constructs (not supported by CEL/RE2).
* Used for syntactic checks so patterns validated here work in CEL regex functions.
*/
const RE2_UNSUPPORTED = {
lookbehindPositive: "(?<=",
lookbehindNegative: "(?<!",
lookaheadPositive: "(?=",
lookaheadNegative: "(?!",
} as const;
/** Matches numeric backreferences (\1, \2, ... \9, \10, etc.) */
const RE2_BACKREF = /\\[0-9]+/;
/**
* Validate regex pattern - checks that it is valid and RE2-compatible (for CEL).
* RE2 does not support lookarounds or backreferences. Returns null if valid,
* error message if invalid or RE2-incompatible.
*/
export function validateRegexPattern(pattern: string): string | null {
if (!pattern || typeof pattern !== "string") {
return null; // Empty patterns are valid
}
// Reject RE2-unsupported constructs
if (pattern.includes(RE2_UNSUPPORTED.lookbehindPositive)) {
return "RE2 incompatible: positive lookbehind (?<=...) is not supported";
}
if (pattern.includes(RE2_UNSUPPORTED.lookbehindNegative)) {
return "RE2 incompatible: negative lookbehind (?<!...) is not supported";
}
if (pattern.includes(RE2_UNSUPPORTED.lookaheadPositive)) {
return "RE2 incompatible: positive lookahead (?=...) is not supported";
}
if (pattern.includes(RE2_UNSUPPORTED.lookaheadNegative)) {
return "RE2 incompatible: negative lookahead (?!...) is not supported";
}
if (RE2_BACKREF.test(pattern)) {
return "RE2 incompatible: numeric backreferences (e.g. \\1, \\2) are not supported";
}
// Basic syntax check via JS RegExp (catches invalid escaping, etc.)
try {
new RegExp(pattern);
return null; // Valid regex
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Invalid regex pattern";
return `Invalid regex: ${errorMessage}`;
}
}
/**
* Parse keyValue pair from string format "key:value"
*/
function parseKeyValue(value: string): { key: string; value: string } | null {
if (!value || typeof value !== "string") {
return null;
}
// Try parsing as JSON array first (for comma-separated values)
if (value.startsWith("[")) {
return null; // This is an array, not a key-value pair
}
// Handle "key" format for existence checks
const colonIndex = value.indexOf(":");
if (colonIndex > 0) {
return {
key: value.substring(0, colonIndex).trim(),
value: value.substring(colonIndex + 1).trim(),
};
}
// If no colon, treat entire string as key (for existence checks)
return {
key: value.trim(),
value: "",
};
}
/**
* Escape special characters in strings
*/
function escapeString(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r");
}
/**
* Format value based on operator type
*/
function formatValue(value: any, operator: string): string {
// Handle array values for 'in' and 'notIn' operators
if (operator === "in" || operator === "notIn") {
let arrayValue: string[];
if (typeof value === "string") {
try {
// Try parsing as JSON array
arrayValue = JSON.parse(value);
if (!Array.isArray(arrayValue)) {
arrayValue = [String(value)];
}
} catch {
// Split by comma if not JSON
arrayValue = value
.split(",")
.map((v) => v.trim())
.filter((v) => v);
}
} else if (Array.isArray(value)) {
arrayValue = value;
} else {
arrayValue = [String(value)];
}
const formattedValues = arrayValue.map((v) => `"${escapeString(String(v))}"`);
return `[${formattedValues.join(", ")}]`;
}
// Handle numbers
if (typeof value === "number") {
return String(value);
}
// Handle booleans
if (typeof value === "boolean") {
return value ? "true" : "false";
}
// Handle string values (regex patterns for matches operator)
if (operator === "matches") {
// For regex, wrap in forward slashes (CEL format) or quotes
return `"${escapeString(String(value))}"`;
}
// Default: treat as string
return `"${escapeString(String(value))}"`;
}
/**
* Convert a single rule to CEL expression
*/
function convertRuleToCEL(rule: RuleType): string {
const { field, operator, value } = rule;
if (!field || !operator) {
return "";
}
const celOperator = getOperatorCELSyntax(operator);
// Handle existence checks (null/notNull)
// Key-value path (headers/params) checks key presence in the map.
// For all other fields (provider, model, etc.) fall through to the simple equality check.
const isKeyValueField = field === "headers" || field === "params";
if (operator === "null") {
if (isKeyValueField) {
const keyValuePair = parseKeyValue(String(value));
if (keyValuePair && keyValuePair.key) {
return `!(${formatValue(keyValuePair.key, "text")} in ${field})`;
}
}
// has() requires a field selection (e.g. has(obj.field)) and cannot be used with bare
// variable names. Plain string variables are always defined; "not set" means empty string.
return `${field} == ""`;
}
if (operator === "notNull") {
if (isKeyValueField) {
const keyValuePair = parseKeyValue(String(value));
if (keyValuePair && keyValuePair.key) {
return `${formatValue(keyValuePair.key, "text")} in ${field}`;
}
}
return `${field} != ""`;
}
// Handle string method operators (startsWith, endsWith, contains, matches)
const stringMethods = ["startsWith", "endsWith", "contains", "matches"];
if (stringMethods.includes(celOperator)) {
const formattedValue = formatValue(value, operator);
// Handle keyValue fields (headers, params)
if (isKeyValueField) {
const keyValuePair = parseKeyValue(String(value));
if (keyValuePair && keyValuePair.key && keyValuePair.value) {
const fieldPath = `${field}[${formatValue(keyValuePair.key, "text")}]`;
const actualValue = formatValue(keyValuePair.value, operator);
return `${fieldPath}.${celOperator}(${actualValue})`;
}
}
// Regular field handling
return `${field}.${celOperator}(${formattedValue})`;
}
// Handle tokens_used, request, and budget_used
// Structure: tokens_used > 80.0 or request >= 75.0 or budget_used > 50.0
// These are simple numeric comparisons against percent_used values from GetBudgetAndRateLimitStatus
// which already returns the max of model+provider, model-only, and provider-only configs
const isRateLimitOrBudgetField = field === "tokens_used" || field === "request" || field === "budget_used";
if (isRateLimitOrBudgetField) {
const thresholdValue = String(value).trim();
if (thresholdValue) {
// Convert to double to match CEL variable type (tokens_used, request, budget_used are all doubles)
const numValue = parseFloat(thresholdValue);
let actualValue: string;
if (!isNaN(numValue)) {
// Format as double with decimal point
actualValue = Number.isInteger(numValue) ? `${numValue}.0` : numValue.toString();
} else {
actualValue = thresholdValue;
}
return `${field} ${celOperator} ${actualValue}`;
}
}
// Handle other keyValue fields (headers, params) for other operators
if (isKeyValueField) {
const keyValuePair = parseKeyValue(String(value));
if (keyValuePair && keyValuePair.key && keyValuePair.value) {
const fieldPath = `${field}[${formatValue(keyValuePair.key, "text")}]`;
const actualValue = formatValue(keyValuePair.value, operator);
// For 'notIn' operator, wrap with negation since CEL has no "not in" infix operator
if (operator === "notIn") {
return `!(${fieldPath} in ${actualValue})`;
}
// For 'in' operator and others, use standard binary syntax
return `${fieldPath} ${celOperator} ${actualValue}`;
}
}
// Regular field handling for binary operators
const formattedValue = formatValue(value, operator);
// For 'notIn' operator, wrap with negation since CEL has no "not in" infix operator
if (operator === "notIn") {
return `!(${field} in ${formattedValue})`;
}
return `${field} ${celOperator} ${formattedValue}`;
}
/**
* Convert rule group (possibly nested) to CEL expression
*/
export function convertRuleGroupToCEL(ruleGroup: RuleGroupType | undefined): string {
if (!ruleGroup || !ruleGroup.rules || ruleGroup.rules.length === 0) {
return "";
}
const combinator = ruleGroup.combinator === "or" ? "||" : "&&";
const expressions: string[] = [];
for (const rule of ruleGroup.rules) {
if ("rules" in rule) {
// It's a nested group
const nestedExpression = convertRuleGroupToCEL(rule as RuleGroupType);
if (nestedExpression) {
expressions.push(`(${nestedExpression})`);
}
} else {
// It's a rule
const ruleExpression = convertRuleToCEL(rule as RuleType);
if (ruleExpression) {
expressions.push(ruleExpression);
}
}
}
if (expressions.length === 0) {
return "";
}
if (expressions.length === 1) {
return expressions[0];
}
return expressions.join(` ${combinator} `);
}
/**
* Validate routing rules for regex pattern errors
* Returns array of error messages, empty if valid
*/
export function validateRoutingRules(ruleGroup: RuleGroupType | undefined): string[] {
const errors: string[] = [];
if (!ruleGroup || !ruleGroup.rules) {
return errors;
}
const validateRule = (rule: RuleType | RuleGroupType) => {
if ("rules" in rule) {
// Nested group - recursively validate
for (const nestedRule of rule.rules) {
validateRule(nestedRule);
}
} else {
// Regular rule - check if it uses matches operator
if (rule.operator === "matches" && rule.value) {
const regexError = validateRegexPattern(String(rule.value));
if (regexError) {
errors.push(`Field "${rule.field}": ${regexError}`);
}
}
}
};
for (const rule of ruleGroup.rules) {
validateRule(rule);
}
return errors;
}
/**
* Validate that rules using rate limits or budgets have a model or provider condition
* Returns array of error messages, empty if valid
*/
export function validateRateLimitAndBudgetRules(ruleGroup: RuleGroupType | undefined): string[] {
const errors: string[] = [];
if (!ruleGroup || !ruleGroup.rules) {
return errors;
}
// Check if rule uses rate limits or budgets
const hasRateLimitOrBudget = (rule: RuleType | RuleGroupType): boolean => {
if ("rules" in rule) {
// Nested group
return rule.rules.some((r) => hasRateLimitOrBudget(r));
}
// Regular rule - check if field is rate limit or budget
return (
(rule as RuleType).field === "tokens_used" || (rule as RuleType).field === "request" || (rule as RuleType).field === "budget_used"
);
};
// Check if rule has model or provider condition
const hasModelOrProviderCondition = (rule: RuleType | RuleGroupType): boolean => {
if ("rules" in rule) {
// Nested group - check all nested rules
return rule.rules.some((r) => hasModelOrProviderCondition(r));
}
// Regular rule - check if field is model or provider
return (rule as RuleType).field === "model" || (rule as RuleType).field === "provider";
};
const ruleHasRateLimitOrBudget = ruleGroup.rules.some((r) => hasRateLimitOrBudget(r));
if (ruleHasRateLimitOrBudget) {
const hasCondition = hasModelOrProviderCondition(ruleGroup);
if (!hasCondition) {
errors.push('Rules using rate limits or budget must have a "model" or "provider" condition');
}
}
return errors;
}

42
ui/lib/utils/csv.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* Reusable CSV export utilities.
*
* Usage:
* const csv = buildCSV(headers, rows);
* downloadCSV(csv, "my-export");
*/
/** Escape a cell value for CSV (RFC 4180). */
function escapeCell(value: unknown): string {
const str = String(value ?? "");
return `"${str.replace(/"/g, '""')}"`;
}
/**
* Build a CSV string from headers and rows.
*
* Each row is an array of cell values (string | number | boolean | null | undefined).
*/
export function buildCSV(headers: string[], rows: unknown[][]): string {
return [headers, ...rows].map((row) => row.map(escapeCell).join(",")).join("\n");
}
/**
* Trigger a browser download of a CSV string.
*
* @param content The CSV string content
* @param filename Base filename without extension (date suffix is appended automatically)
*/
export function downloadCSV(content: string, filename: string): void {
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const now = new Date();
const dateStamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
link.download = `${filename}-${dateStamp}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
}

33
ui/lib/utils/date.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Converts a Date object to an RFC 3339 string with the local time zone offset.
*
* Example: 2025-11-19T12:23:19.421+05:30
*
* @param dateObj The Date object to convert (defaults to new Date() if null/undefined).
* @returns The RFC 3339 formatted string with local offset.
*/
export function dateToRfc3339Local(dateObj?: Date): string {
const now = dateObj instanceof Date ? dateObj : new Date();
// Helper function to pad single digits with a leading zero
const pad = (num: number): string => (num < 10 ? "0" + num : String(num));
const Y = now.getFullYear();
const M = pad(now.getMonth() + 1); // Month is 0-indexed (Jan=0)
const D = pad(now.getDate());
const H = pad(now.getHours());
const m = pad(now.getMinutes());
const S = pad(now.getSeconds());
const ms = String(now.getMilliseconds()).padStart(3, "0");
// getTimezoneOffset() returns the difference in minutes from UTC for the local time.
// The result is positive for time zones west of Greenwich and negative for those east.
// We negate it to get the standard ISO/RFC sign convention (+ for East, - for West).
const timezoneOffsetMinutes = -now.getTimezoneOffset();
const sign = timezoneOffsetMinutes >= 0 ? "+" : "-";
const absoluteOffset = Math.abs(timezoneOffsetMinutes);
const offsetHours = pad(Math.floor(absoluteOffset / 60));
const offsetMinutes = pad(absoluteOffset % 60);
const rfc3339Local = `${Y}-${M}-${D}T${H}:${m}:${S}.${ms}${sign}${offsetHours}:${offsetMinutes}`;
return rfc3339Local;
}

View File

@@ -0,0 +1,98 @@
/**
* Parses a duration string (e.g., "1m", "5m", "1h", "1d", "1w", "1M") into human readable format
*/
export function parseResetPeriod(duration: string): string {
if (!duration) return "Unknown";
const timeValue = parseInt(duration.slice(0, -1));
const timeUnit = duration.slice(-1);
const unitMap: Record<string, { singular: string; plural: string }> = {
s: { singular: "second", plural: "seconds" },
m: { singular: "minute", plural: "minutes" },
h: { singular: "hour", plural: "hours" },
d: { singular: "day", plural: "days" },
w: { singular: "week", plural: "weeks" },
M: { singular: "month", plural: "months" },
y: { singular: "year", plural: "years" },
};
const unit = unitMap[timeUnit];
if (!unit) return duration;
const unitName = timeValue === 1 ? unit.singular : unit.plural;
return `${timeValue} ${unitName}`;
}
export function formatCurrency(dollars: number) {
return `$${dollars.toFixed(2)}`;
}
/**
* Formats a number compactly (e.g. 10000 → "10K", 1500000 → "1.5M").
* Uses Intl.NumberFormat so boundary values promote correctly (999,950 → "1M", not "1000K")
* and trailing zeros are dropped (10,000 → "10K", not "10.0K").
*/
const compactNumberFormatter = new Intl.NumberFormat(undefined, {
notation: "compact",
maximumFractionDigits: 1,
});
export function formatCompactNumber(n: number): string {
if (Math.abs(n) >= 1_000) return compactNumberFormatter.format(n);
return n.toLocaleString();
}
const shortDurationLabels: Record<string, string> = {
"1m": "/min",
"5m": "/5min",
"15m": "/15min",
"30m": "/30min",
"1h": "/hr",
"6h": "/6hr",
"1d": "/day",
"1w": "/wk",
"1M": "/mo",
};
/**
* Formats rate limit into compact display lines.
* e.g. ["10K tokens/hr", "100 req/hr"]
*/
export function formatRateLimitLines(rateLimits: {
token_max_limit?: number | null;
token_reset_duration?: string | null;
request_max_limit?: number | null;
request_reset_duration?: string | null;
} | null | undefined): string[] {
if (!rateLimits) return [];
const lines: string[] = [];
if (rateLimits.token_max_limit != null) {
const duration = rateLimits.token_reset_duration ?? "";
const suffix = shortDurationLabels[duration] ?? (duration ? `/${duration}` : "");
lines.push(`${formatCompactNumber(rateLimits.token_max_limit)} tokens${suffix}`);
}
if (rateLimits.request_max_limit != null) {
const duration = rateLimits.request_reset_duration ?? "";
const suffix = shortDurationLabels[duration] ?? (duration ? `/${duration}` : "");
lines.push(`${formatCompactNumber(rateLimits.request_max_limit)} req${suffix}`);
}
return lines;
}
/**
* Calculates usage percentage for rate limits
*/
export function calculateUsagePercentage(current: number, max: number): number {
if (max === 0) return 0;
return Math.round((current / max) * 100);
}
/**
* Gets the appropriate variant for usage percentage badges
*/
export function getUsageVariant(percentage: number): "default" | "secondary" | "destructive" | "outline" {
if (percentage >= 90) return "destructive";
if (percentage >= 75) return "secondary";
return "default";
}

13
ui/lib/utils/numbers.ts Normal file
View File

@@ -0,0 +1,13 @@
export const COMPACT_NUMBER_FORMAT = {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 2,
} as const;
export function formatCompactNumber(value: number, maximumFractionDigits = 2): string {
if (!Number.isFinite(value)) return "0";
return new Intl.NumberFormat("en-US", {
...COMPACT_NUMBER_FORMAT,
maximumFractionDigits,
}).format(value);
}

179
ui/lib/utils/pdf.ts Normal file
View File

@@ -0,0 +1,179 @@
/**
* Reusable PDF export utility.
*
* Captures an array of DOM sections as images via html2canvas and composes
* them into a multi-page A4 PDF with jsPDF. Libraries are dynamically
* imported so they only load when actually needed.
*
* Usage:
* await generatePdf(
* [{ element: el, label: "Overview" }, ...],
* "dashboard-export",
* );
*/
export interface PdfSection {
/** DOM element to capture */
element: HTMLElement;
/** Optional heading printed above the section in the PDF */
label?: string;
}
export interface PdfBranding {
/** Path to logo image (relative to public dir, e.g. "/bifrost-logo.webp") */
logoSrc: string;
/** Text shown next to the logo */
text?: string;
}
export interface PdfOptions {
/** Canvas scale factor (default 1.5) */
scale?: number;
/** JPEG quality 0-1 (default 0.92) */
quality?: number;
/** Page margin in mm (default 10) */
margin?: number;
/** Page orientation (default "portrait") */
orientation?: "portrait" | "landscape";
/** Branding shown at the bottom-right of every page */
branding?: PdfBranding;
}
/** Load an image and return its data URL + natural dimensions. */
async function loadImage(src: string): Promise<{ dataUrl: string; width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
ctx?.drawImage(img, 0, 0);
resolve({
dataUrl: canvas.toDataURL("image/png"),
width: img.naturalWidth,
height: img.naturalHeight,
});
};
img.onerror = reject;
img.src = src;
});
}
export async function generatePdf(sections: PdfSection[], filename: string, options: PdfOptions = {}): Promise<void> {
const { scale = 1.5, quality = 0.92, margin = 10, orientation = "portrait", branding } = options;
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([import("html2canvas-pro"), import("jspdf")]);
// Pre-load branding logo if configured
let logoData: { dataUrl: string; width: number; height: number } | null = null;
if (branding?.logoSrc) {
try {
logoData = await loadImage(branding.logoSrc);
} catch {
// Logo failed to load — continue without it
}
}
const pdf = new jsPDF({ orientation, unit: "mm", format: "a4" });
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const contentWidth = pageWidth - margin * 2;
let cursorY = margin;
for (let i = 0; i < sections.length; i++) {
const { element, label } = sections[i];
// Yield between sections so the UI stays responsive
await new Promise((r) => setTimeout(r, 0));
const canvas = await html2canvas(element, {
scale,
useCORS: true,
logging: false,
backgroundColor: "#ffffff",
});
const imgHeight = (canvas.height * contentWidth) / canvas.width;
const headingHeight = label ? 10 : 0;
// Start a new page if the heading + a meaningful chunk won't fit
if (cursorY + headingHeight + 20 > pageHeight - margin) {
pdf.addPage();
cursorY = margin;
}
if (label) {
pdf.setFontSize(14);
pdf.setTextColor(30, 30, 30);
pdf.text(label, margin, cursorY + 5);
cursorY += headingHeight;
}
// Slice the captured image into page-sized chunks
let yOffset = 0;
while (yOffset < imgHeight) {
const remainingOnPage = pageHeight - cursorY - margin;
const sliceHeight = Math.min(remainingOnPage, imgHeight - yOffset);
const sourceY = (yOffset / imgHeight) * canvas.height;
const sourceH = (sliceHeight / imgHeight) * canvas.height;
const sliceCanvas = document.createElement("canvas");
sliceCanvas.width = canvas.width;
sliceCanvas.height = Math.round(sourceH);
const ctx = sliceCanvas.getContext("2d");
if (ctx) {
ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceH, 0, 0, canvas.width, Math.round(sourceH));
const sliceImg = sliceCanvas.toDataURL("image/jpeg", quality);
pdf.addImage(sliceImg, "JPEG", margin, cursorY, contentWidth, sliceHeight);
}
cursorY += sliceHeight;
yOffset += sliceHeight;
if (yOffset < imgHeight) {
pdf.addPage();
cursorY = margin;
}
}
// Small gap between sections
cursorY += 4;
}
// Stamp branding on every page
if (branding && (logoData || branding.text)) {
const totalPages = pdf.getNumberOfPages();
const brandingText = branding.text ?? "";
const logoH = 3.5; // logo height in mm
const logoW = logoData ? (logoData.width / logoData.height) * logoH : 0;
const gap = logoData && brandingText ? 1.5 : 0;
pdf.setFontSize(8);
pdf.setTextColor(150, 150, 150);
const textW = brandingText ? pdf.getTextWidth(brandingText) : 0;
const totalW = textW + gap + logoW;
for (let p = 1; p <= totalPages; p++) {
pdf.setPage(p);
const x = pageWidth - margin - totalW;
const y = pageHeight - margin + 2;
if (brandingText) {
pdf.setFontSize(8);
pdf.setTextColor(150, 150, 150);
pdf.text(brandingText, x, y + logoH / 2 + 1);
}
if (logoData) {
pdf.addImage(logoData.dataUrl, "PNG", x + textW + gap, y, logoW, logoH);
}
}
}
const now = new Date();
const dateStamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
pdf.save(`${filename}-${dateStamp}.pdf`);
}

127
ui/lib/utils/port.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* Port and URL utility - single source of truth for Bifrost backend connectivity
*
* This utility handles:
* - Development vs Production environment detection
* - Dynamic port resolution
* - URL generation for API calls and WebSocket connections
* - Automatic protocol detection (http/https, ws/wss)
*/
interface PortConfig {
port: string;
isDevelopment: boolean;
baseUrl: string;
wsUrl: string;
host: string;
}
/**
* Gets the current port configuration based on environment
*/
function getPortConfig(): PortConfig {
const isDevelopment = process.env.NODE_ENV === "development";
if (isDevelopment) {
// Development mode: Vite dev server runs on different port than Go server
const port = process.env.BIFROST_PORT || "8080";
return {
port,
isDevelopment: true,
baseUrl: `http://localhost:${port}`,
wsUrl: `ws://localhost:${port}`,
host: `localhost:${port}`,
};
} else {
// Production mode: UI is served by the same Go server
// Use current window location for automatic port detection
if (typeof window !== "undefined") {
const protocol = window.location.protocol === "https:" ? "https:" : "http:";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return {
port: window.location.port || (window.location.protocol === "https:" ? "443" : "80"),
isDevelopment: false,
baseUrl: `${protocol}//${window.location.host}`,
wsUrl: `${wsProtocol}//${window.location.host}`,
host: window.location.host,
};
} else {
// Server-side rendering fallback - use relative URLs
return {
port: "unknown",
isDevelopment: false,
baseUrl: "",
wsUrl: "",
host: "",
};
}
}
}
/**
* Get the current port number as a string
*/
export function getPort(): string {
return getPortConfig().port;
}
/**
* Get the base URL for API calls (includes protocol and host)
*/
export function getApiBaseUrl(): string {
const config = getPortConfig();
if (config.isDevelopment) {
return `${config.baseUrl}/api`;
} else {
// Production mode: use relative URL for API calls
return "/api";
}
}
/**
* Get the WebSocket URL for real-time connections
*/
export function getWebSocketUrl(path: string = ""): string {
const config = getPortConfig();
const cleanPath = path.startsWith("/") ? path : `/${path}`;
return `${config.wsUrl}${cleanPath}`;
}
/**
* Get the full base URL (for example code snippets)
*/
export function getExampleBaseUrl(): string {
return getPortConfig().baseUrl;
}
/**
* Get the host (hostname:port) for example code
*/
export function getExampleHost(): string {
return getPortConfig().host;
}
/**
* Check if we're in development mode
*/
export function isDevelopmentMode(): boolean {
return getPortConfig().isDevelopment;
}
/**
* Generate a complete URL for a specific endpoint
*/
export function getEndpointUrl(endpoint: string): string {
const config = getPortConfig();
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
if (config.isDevelopment) {
return `${config.baseUrl}${cleanEndpoint}`;
} else {
// Production mode: use relative URLs
return cleanEndpoint;
}
}

View File

@@ -0,0 +1,167 @@
/**
* Routing Rules Utility Functions
* Helper functions for CEL validation, formatting, and rule management
*/
/**
* Validates if a CEL expression has basic correct syntax
* @param expression - The CEL expression to validate
* @returns true if expression appears syntactically valid
*/
export function isValidCELExpression(expression: string): boolean {
if (!expression) {
return true;
}
const trimmed = expression.trim();
if (trimmed.length === 0 || trimmed === "true" || trimmed === "false") {
return true;
}
// Check for basic syntax issues
if (trimmed.includes(";;")) {
return false;
}
// Check for matching brackets/parentheses
const openBrackets = (trimmed.match(/[[{]/g) || []).length;
const closeBrackets = (trimmed.match(/[\]}]/g) || []).length;
const openParens = (trimmed.match(/\(/g) || []).length;
const closeParens = (trimmed.match(/\)/g) || []).length;
if (openBrackets !== closeBrackets || openParens !== closeParens) {
return false;
}
return true;
}
/**
* Formats a fallback string (provider/model) for display
* @param fallback - The fallback string (e.g., "openai/gpt-4o")
* @returns Formatted fallback string
*/
export function formatFallback(fallback: string): string {
if (!fallback) return "";
const parts = fallback.split("/");
return parts.length === 2 ? `${parts[0].toUpperCase()} - ${parts[1]}` : fallback;
}
/**
* Parses a fallback string into provider and model
* @param fallback - The fallback string (e.g., "openai/gpt-4o")
* @returns Object with provider and model, or null if invalid
*/
export function parseFallback(fallback: string): { provider: string; model: string } | null {
if (!fallback) return null;
const parts = fallback.split("/");
if (parts.length !== 2) return null;
return { provider: parts[0], model: parts[1] };
}
/**
* Converts fallback array to string format for display/editing
* @param fallbacks - Array of fallback strings
* @returns Comma-separated string
*/
export function fallbacksToString(fallbacks?: string[]): string {
if (!fallbacks || fallbacks.length === 0) return "";
return fallbacks.join(", ");
}
/**
* Converts comma-separated string to fallback array
* @param str - Comma-separated fallback string
* @returns Array of fallback strings
*/
export function stringToFallbacks(str: string): string[] {
if (!str || str.trim().length === 0) return [];
return str
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
/**
* Gets a friendly display name for a scope
* @param scope - The scope value (global|team|customer|virtual_key)
* @returns Friendly display name
*/
export function getScopeLabel(scope: string): string {
const labels: Record<string, string> = {
global: "Global",
team: "Team",
customer: "Customer",
virtual_key: "Virtual Key",
};
return labels[scope] || scope;
}
/**
* Truncates CEL expression for table display
* @param expression - The CEL expression
* @param maxLength - Maximum length (default 60)
* @returns Truncated expression with ellipsis if needed
*/
export function truncateCELExpression(expression: string, maxLength: number = 60): string {
if (!expression) return "";
if (expression.length <= maxLength) return expression;
return expression.substring(0, maxLength) + "...";
}
/**
* Validates a provider/model combination
* @param provider - The provider name
* @param model - The model name (optional)
* @returns Error message if invalid, empty string if valid
*/
export function validateProviderModel(provider: string, _model?: string): string {
if (!provider || provider.trim().length === 0) {
return "Provider is required";
}
return "";
}
/**
* Generates a CSS class for priority badge color
* @returns CSS class name for styling
*/
export function getPriorityBadgeClass(): string {
return "bg-primary text-primary-foreground";
}
/**
* Gets a user-friendly CEL operator from the expression
* @param expression - The CEL expression
* @returns Array of detected operators
*/
export function detectCELOperators(expression: string): string[] {
const operators: string[] = [];
if (!expression) return operators;
// Common CEL operators
const operatorPatterns = [
{ regex: /==/, label: "Equals" },
{ regex: /!=/, label: "Not equals" },
{ regex: />=/, label: "Greater than or equal" },
{ regex: /<=/, label: "Less than or equal" },
{ regex: />/, label: "Greater than" },
{ regex: /</, label: "Less than" },
{ regex: /&&/, label: "AND" },
{ regex: /\|\|/, label: "OR" },
{ regex: /!(?!=)/, label: "NOT" },
{ regex: /in\s/, label: "IN" },
{ regex: /.matches\(/, label: "Regex" },
{ regex: /.startsWith\(/, label: "StartsWith" },
{ regex: /.contains\(/, label: "Contains" },
{ regex: /.endsWith\(/, label: "EndsWith" },
];
operatorPatterns.forEach(({ regex, label }) => {
if (regex.test(expression) && !operators.includes(label)) {
operators.push(label);
}
});
return operators;
}

View File

@@ -0,0 +1,204 @@
import { describe, test, expect } from "vitest";
import { cleanNumericInput } from "./strings";
// Simulate what onChange does: clean → Number()
function simulateOnChange(raw: string): { display: string; value: number | undefined } {
const cleaned = cleanNumericInput(raw);
if (cleaned === "" || cleaned === ".") {
return { display: cleaned, value: undefined };
}
const n = Number(cleaned);
return { display: cleaned, value: isNaN(n) ? undefined : n };
}
// Simulate what onBlur does: normalize display string
function simulateOnBlur(displayValue: string): { display: string; value: number | undefined } {
const trimmed = displayValue.trim();
if (trimmed === "" || trimmed === ".") {
return { display: "", value: undefined };
}
const num = Number(trimmed);
if (!isNaN(num)) {
return { display: String(num), value: num };
}
return { display: "", value: undefined };
}
describe("cleanNumericInput", () => {
// Basic valid inputs
test("empty string", () => expect(cleanNumericInput("")).toBe(""));
test("single digit", () => expect(cleanNumericInput("5")).toBe("5"));
test("multiple digits", () => expect(cleanNumericInput("123")).toBe("123"));
test("decimal number", () => expect(cleanNumericInput("1.5")).toBe("1.5"));
test("leading decimal", () => expect(cleanNumericInput(".5")).toBe(".5"));
test("trailing decimal", () => expect(cleanNumericInput("5.")).toBe("5."));
test("zero", () => expect(cleanNumericInput("0")).toBe("0"));
test("decimal zero", () => expect(cleanNumericInput("0.0")).toBe("0.0"));
test("long decimal", () => expect(cleanNumericInput("123.456")).toBe("123.456"));
// Comma-separated (thousands)
test("1,000", () => expect(cleanNumericInput("1,000")).toBe("1000"));
test("1,000,000", () => expect(cleanNumericInput("1,000,000")).toBe("1000000"));
test("1,234.56", () => expect(cleanNumericInput("1,234.56")).toBe("1234.56"));
// Underscore-separated (programming style)
test("1_000", () => expect(cleanNumericInput("1_000")).toBe("1000"));
test("1_000_000", () => expect(cleanNumericInput("1_000_000")).toBe("1000000"));
// Space-separated
test("1 000", () => expect(cleanNumericInput("1 000")).toBe("1000"));
test("1 000 000", () => expect(cleanNumericInput("1 000 000")).toBe("1000000"));
// Alphabetic characters — should stop
test("1abc", () => expect(cleanNumericInput("1abc")).toBe("1"));
test("123abc456", () => expect(cleanNumericInput("123abc456")).toBe("123"));
test("abc123", () => expect(cleanNumericInput("abc123")).toBe(""));
// Multiple consecutive separators — should stop
test("1,,000", () => expect(cleanNumericInput("1,,000")).toBe("1"));
test("1..5", () => expect(cleanNumericInput("1..5")).toBe("1.5"));
test("1 000", () => expect(cleanNumericInput("1 000")).toBe("1"));
// Multiple decimal points — second dot treated as separator
test("12.3.4", () => expect(cleanNumericInput("12.3.4")).toBe("12.34"));
test("1.2.3.4", () => expect(cleanNumericInput("1.2.3.4")).toBe("1.234"));
// Currency symbols and special chars
test("$100", () => expect(cleanNumericInput("$100")).toBe("100"));
test("€1,000", () => expect(cleanNumericInput("€1,000")).toBe("1000"));
test("100%", () => expect(cleanNumericInput("100%")).toBe("100"));
// Trailing separator with no digit after
test("100,", () => expect(cleanNumericInput("100,")).toBe("100"));
test("100.", () => expect(cleanNumericInput("100.")).toBe("100."));
// Just a dot
test(".", () => expect(cleanNumericInput(".")).toBe("."));
// Negative sign (non-alpha, stripped as separator if digit follows)
test("-5", () => expect(cleanNumericInput("-5")).toBe("5"));
test("-1,000", () => expect(cleanNumericInput("-1,000")).toBe("1000"));
// Whitespace
test(" 123 ", () => expect(cleanNumericInput(" 123 ")).toBe("123"));
test("1 0 0", () => expect(cleanNumericInput("1 0 0")).toBe("100"));
test(" 1,000 ", () => expect(cleanNumericInput(" 1,000 ")).toBe("1000"));
test(" .5 ", () => expect(cleanNumericInput(" .5 ")).toBe(".5"));
// Tab and mixed whitespace
test("tab 123", () => expect(cleanNumericInput("\t123\t")).toBe("123"));
test("newline 123", () => expect(cleanNumericInput("\n123\n")).toBe("123"));
// Plus sign
test("+5", () => expect(cleanNumericInput("+5")).toBe("5"));
test("+1,000", () => expect(cleanNumericInput("+1,000")).toBe("1000"));
// Mixed separators
test("1_000,000.50", () => expect(cleanNumericInput("1_000,000.50")).toBe("1000000.50"));
// Parentheses (accounting negative)
test("(100)", () => expect(cleanNumericInput("(100)")).toBe("100"));
// Only separators
test(",", () => expect(cleanNumericInput(",")).toBe(""));
test(",,", () => expect(cleanNumericInput(",,")).toBe(""));
test("_", () => expect(cleanNumericInput("_")).toBe(""));
// Only alpha
test("abc", () => expect(cleanNumericInput("abc")).toBe(""));
test("NaN", () => expect(cleanNumericInput("NaN")).toBe(""));
test("Infinity", () => expect(cleanNumericInput("Infinity")).toBe(""));
// Very large numbers
test("999,999,999.99", () => expect(cleanNumericInput("999,999,999.99")).toBe("999999999.99"));
test("1_000_000_000", () => expect(cleanNumericInput("1_000_000_000")).toBe("1000000000"));
// Pasted from spreadsheet with trailing whitespace/newline
test("1234\\n", () => expect(cleanNumericInput("1234\n")).toBe("1234"));
test("\\t5000\\t", () => expect(cleanNumericInput("\t5000\t")).toBe("5000"));
// Unicode non-breaking space
test("1\u00A0000", () => expect(cleanNumericInput("1\u00A0000")).toBe("1000"));
// Hash, slash, other symbols before digits
test("#100", () => expect(cleanNumericInput("#100")).toBe("100"));
test("/100", () => expect(cleanNumericInput("/100")).toBe("100"));
// Zero edge cases
test("0.00", () => expect(cleanNumericInput("0.00")).toBe("0.00"));
test("00.5", () => expect(cleanNumericInput("00.5")).toBe("00.5"));
test("000", () => expect(cleanNumericInput("000")).toBe("000"));
test("0,000", () => expect(cleanNumericInput("0,000")).toBe("0000"));
});
describe("simulateOnChange (clean + Number)", () => {
test("empty → undefined", () => {
expect(simulateOnChange("")).toEqual({ display: "", value: undefined });
});
test("just dot → undefined", () => {
expect(simulateOnChange(".")).toEqual({ display: ".", value: undefined });
});
test("123 → 123", () => {
expect(simulateOnChange("123")).toEqual({ display: "123", value: 123 });
});
test("1.5 → 1.5", () => {
expect(simulateOnChange("1.5")).toEqual({ display: "1.5", value: 1.5 });
});
test("0.0 → display 0.0, value 0", () => {
expect(simulateOnChange("0.0")).toEqual({ display: "0.0", value: 0 });
});
test("1,000 → 1000", () => {
expect(simulateOnChange("1,000")).toEqual({ display: "1000", value: 1000 });
});
test("1_000 → 1000", () => {
expect(simulateOnChange("1_000")).toEqual({ display: "1000", value: 1000 });
});
test("1abc → 1", () => {
expect(simulateOnChange("1abc")).toEqual({ display: "1", value: 1 });
});
test("$100 → 100", () => {
expect(simulateOnChange("$100")).toEqual({ display: "100", value: 100 });
});
test("1,234.56 → 1234.56", () => {
expect(simulateOnChange("1,234.56")).toEqual({ display: "1234.56", value: 1234.56 });
});
test(" 500 → 500 (trimmed)", () => {
expect(simulateOnChange(" 500 ")).toEqual({ display: "500", value: 500 });
});
test("abc → empty, undefined", () => {
expect(simulateOnChange("abc")).toEqual({ display: "", value: undefined });
});
test("999,999,999.99 → 999999999.99", () => {
expect(simulateOnChange("999,999,999.99")).toEqual({ display: "999999999.99", value: 999999999.99 });
});
test("0.01 → 0.01", () => {
expect(simulateOnChange("0.01")).toEqual({ display: "0.01", value: 0.01 });
});
test("-5 → 5 (negative sign stripped)", () => {
expect(simulateOnChange("-5")).toEqual({ display: "5", value: 5 });
});
});
describe("simulateOnBlur (normalize display)", () => {
test("empty → empty, undefined", () => {
expect(simulateOnBlur("")).toEqual({ display: "", value: undefined });
});
test("dot → empty, undefined", () => {
expect(simulateOnBlur(".")).toEqual({ display: "", value: undefined });
});
test("0. → 0", () => {
expect(simulateOnBlur("0.")).toEqual({ display: "0", value: 0 });
});
test("1.0 → 1 (Number normalizes trailing zero)", () => {
expect(simulateOnBlur("1.0")).toEqual({ display: "1", value: 1 });
});
test("1.50 → 1.5", () => {
expect(simulateOnBlur("1.50")).toEqual({ display: "1.5", value: 1.5 });
});
test("0100 → 100 (leading zero stripped)", () => {
expect(simulateOnBlur("0100")).toEqual({ display: "100", value: 100 });
});
test("1000 → 1000", () => {
expect(simulateOnBlur("1000")).toEqual({ display: "1000", value: 1000 });
});
});

45
ui/lib/utils/strings.ts Normal file
View File

@@ -0,0 +1,45 @@
export function capitalize(name: string) {
return name.charAt(0).toUpperCase() + name.slice(1);
}
// Cleans raw input into a valid numeric string:
// - Single non-alphabetic separator between digits (commas, spaces, underscores) → stripped
// - Alphabetic characters → stop processing
// - 2+ consecutive non-digit characters → stop processing
// - First decimal point preserved, subsequent dots stripped
export function cleanNumericInput(raw: string): string {
raw = raw.trim();
let result = "";
let hasDecimal = false;
let i = 0;
while (i < raw.length) {
const ch = raw[i];
if (/\d/.test(ch)) {
result += ch;
i++;
} else if (ch === "." && !hasDecimal) {
result += ch;
hasDecimal = true;
i++;
} else if (/[a-zA-Z]/.test(ch)) {
break;
} else {
// Non-alphabetic, non-digit character (comma, space, extra dot, etc.)
// Accept only if it's a single separator followed by a digit
if (i + 1 < raw.length && /\d/.test(raw[i + 1])) {
i++; // skip the separator
} else {
break;
}
}
}
return result;
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}

44
ui/lib/utils/timeRange.ts Normal file
View File

@@ -0,0 +1,44 @@
export const TIME_PERIODS = [
{ label: "Last hour", value: "1h" },
{ label: "Last 6 hours", value: "6h" },
{ label: "Last 24 hours", value: "24h" },
{ label: "Last 7 days", value: "7d" },
{ label: "Last 30 days", value: "30d" },
];
export type TimePeriod = (typeof TIME_PERIODS)[number]["value"];
/** Returns a fresh { from, to } Date pair for the given relative period string. */
export function getRangeForPeriod(period: string): { from: Date; to: Date } {
const to = new Date();
const from = new Date(to.getTime());
switch (period) {
case "1h":
from.setHours(from.getHours() - 1);
break;
case "6h":
from.setHours(from.getHours() - 6);
break;
case "24h":
from.setHours(from.getHours() - 24);
break;
case "7d":
from.setDate(from.getDate() - 7);
break;
case "30d":
from.setDate(from.getDate() - 30);
break;
default:
from.setHours(from.getHours() - 1);
}
return { from, to };
}
/** Returns unix timestamps (seconds) for the given relative period string. */
export function getUnixRangeForPeriod(period: string): { start: number; end: number } {
const { from, to } = getRangeForPeriod(period);
return {
start: Math.floor(from.getTime() / 1000),
end: Math.floor(to.getTime() / 1000),
};
}

592
ui/lib/utils/validation.ts Normal file
View File

@@ -0,0 +1,592 @@
import { PROVIDER_SUPPORTED_REQUESTS } from "../constants/config";
import { BaseProvider } from "../types/config";
export interface ValidationRule {
isValid: boolean;
message: string;
}
export interface ValidationConfig {
rules: ValidationRule[];
showAlways?: boolean; // If true, shows tooltip even when field is untouched
}
export interface FieldValidation {
isValid: boolean;
message: string;
showTooltip: boolean;
}
export const validateField = (value: any, config: ValidationConfig, touched: boolean): FieldValidation => {
const invalidRule = config.rules.find((rule) => !rule.isValid);
return {
isValid: !invalidRule,
message: invalidRule?.message || "",
showTooltip: config.showAlways || (touched && !!invalidRule),
};
};
export interface ValidationResult {
isValid: boolean;
errors: string[];
}
export const validateForm = (rules: ValidationRule[]): ValidationResult => {
const invalidRules = rules.filter((rule) => !rule.isValid);
return {
isValid: invalidRules.length === 0,
errors: invalidRules.map((rule) => rule.message),
};
};
export class Validator {
private rules: ValidationRule[];
constructor(rules: ValidationRule[]) {
this.rules = rules.filter((rule) => rule !== undefined);
}
isValid(): boolean {
return !this.rules.some((rule) => !rule.isValid);
}
getErrors(): string[] {
return this.rules.filter((rule) => !rule.isValid).map((rule) => rule.message);
}
getFirstError(): string | undefined {
const firstInvalidRule = this.rules.find((rule) => !rule.isValid);
return firstInvalidRule?.message;
}
// Built-in validators
static required(value: any, message = "This field is required"): ValidationRule {
return {
isValid: value !== undefined && value !== null && value !== "" && value !== 0,
message,
};
}
static minValue(value: number, min: number, message = `Must be at least ${min}`): ValidationRule {
return {
isValid: !isNaN(value) && value >= min,
message,
};
}
static maxValue(value: number, max: number, message = `Must be at most ${max}`): ValidationRule {
return {
isValid: !isNaN(value) && value <= max,
message,
};
}
static pattern(value: string, regex: RegExp, message: string): ValidationRule {
return {
isValid: regex.test(value || ""),
message,
};
}
static email(value: string, message = "Must be a valid email"): ValidationRule {
return this.pattern(value, /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message);
}
static url(value: string, message = "Must be a valid URL"): ValidationRule {
return this.pattern(value, /^https?:\/\/.+/, message);
}
static minLength(value: string, min: number, message = `Must be at least ${min} characters`): ValidationRule {
return {
isValid: (value || "").length >= min,
message,
};
}
static maxLength(value: string, max: number, message = `Must be at most ${max} characters`): ValidationRule {
return {
isValid: (value || "").length <= max,
message,
};
}
static arrayMinLength<T>(array: T[], min: number, message = `Must have at least ${min} items`): ValidationRule {
return {
isValid: array?.length >= min,
message,
};
}
static arrayMaxLength<T>(array: T[], max: number, message = `Must have at most ${max} items`): ValidationRule {
return {
isValid: array?.length <= max,
message,
};
}
static arrayUnique<T>(array: T[], message = "Must have unique items"): ValidationRule {
return {
isValid: array?.length === new Set(array).size,
message,
};
}
static arraysEqual<T>(array1: T[], array2: T[], message = "Must be equal"): ValidationRule {
return {
isValid: array1?.length === array2?.length && array1?.every((value, index) => value === array2[index]),
message,
};
}
static custom(isValid: boolean, message: string): ValidationRule {
return {
isValid,
message,
};
}
// Combine multiple validation rules
static all(rules: ValidationRule[]): ValidationRule {
const invalidRule = rules.find((rule) => !rule.isValid);
return invalidRule || { isValid: true, message: "" };
}
}
// Utility functions for validation and redaction detection
/**
* Checks if a value is redacted based on the backend redaction patterns
* @param value - The value to check
* @returns true if the value is redacted
*/
export function isRedacted(value: string): boolean {
if (!value) {
return false;
}
// Check if it's an environment variable reference
if (value.startsWith("env.")) {
return true;
}
// Check for exact redaction pattern: 4 chars + 24 asterisks + 4 chars (total 32)
if (value.length === 32) {
const middle = value.substring(4, 28);
if (middle === "*".repeat(24)) {
return true;
}
}
// Check for short key redaction (all asterisks, length <= 8)
if (value.length <= 8 && /^\*+$/.test(value)) {
return true;
}
return false;
}
/**
* Checks if a JSON string is valid
* @param value - The JSON string to validate
* @returns true if valid JSON
*/
export function isValidJSON(value: string): boolean {
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}
/**
* Validates Vertex auth credentials
* @param value - The auth credentials value
* @returns true if valid (redacted, env var, or valid service account JSON)
*/
export function isValidVertexAuthCredentials(value: string): boolean {
if (!value || !value.trim()) {
return false;
}
// If redacted, consider it valid (backend has the real value)
if (isRedacted(value)) {
return true;
}
// If environment variable, validate format
if (value.startsWith("env.")) {
return value.length > 4;
}
// Try to parse as service account JSON
try {
const parsed = JSON.parse(value);
return typeof parsed === "object" && parsed !== null && parsed.type === "service_account" && parsed.project_id && parsed.private_key;
} catch {
return false;
}
}
/**
* Validates aliases configuration
* @param value - The aliases value (object or string)
* @returns true if valid (redacted, or valid JSON object)
*/
export function isValidAliases(value: Record<string, string> | string | undefined): boolean {
if (!value) {
return false;
}
// If it's already an object, check if it has entries
if (typeof value === "object") {
return Object.keys(value).length > 0;
}
// If it's a string, check for redaction or valid JSON
if (typeof value === "string") {
// If redacted, consider it valid (backend has the real value)
if (isRedacted(value)) {
return true;
}
// Try to parse as JSON
try {
const parsed = JSON.parse(value);
return typeof parsed === "object" && parsed !== null && Object.keys(parsed).length > 0;
} catch {
return false;
}
}
return false;
}
/**
* Validates if a string is a valid origin URL or wildcard pattern
* @param origin - The origin URL to validate (supports wildcards like https://*.example.com)
* @returns true if valid origin (protocol + hostname + optional port) or valid wildcard pattern
*/
export function isValidOrigin(origin: string): boolean {
if (!origin || !origin.trim()) {
return false;
}
// Allow just "*" to mean allow everything
if (origin.trim() === "*") {
return true;
}
// Handle wildcard patterns
if (origin.includes("*")) {
return isValidWildcardOrigin(origin);
}
try {
const url = new URL(origin);
// Must have protocol and hostname
if (!url.protocol || !url.hostname) {
return false;
}
// Must be http or https
if (!["http:", "https:"].includes(url.protocol)) {
return false;
}
// Must not have path, query, or fragment (origin should be just protocol + hostname + port)
if (url.pathname !== "/" || url.search || url.hash) {
return false;
}
return true;
} catch {
return false;
}
}
/**
* Validates if a string is a valid wildcard origin pattern
* @param origin - The wildcard origin pattern to validate
* @returns true if valid wildcard pattern
*/
function isValidWildcardOrigin(origin: string): boolean {
// Basic validation: must start with protocol
if (!origin.startsWith("http://") && !origin.startsWith("https://")) {
return false;
}
// Extract the part after protocol
const protocolEnd = origin.indexOf("://") + 3;
const hostPart = origin.substring(protocolEnd);
// Must not have path, query, or fragment
if (hostPart.includes("/") || hostPart.includes("?") || hostPart.includes("#")) {
return false;
}
// Handle port if present
let hostname = hostPart;
if (hostPart.includes(":")) {
const parts = hostPart.split(":");
if (parts.length !== 2) return false;
hostname = parts[0];
const port = parts[1];
// Validate port is a number
if (!/^\d+$/.test(port) || parseInt(port) < 1 || parseInt(port) > 65535) {
return false;
}
}
// Validate wildcard patterns
// Only allow wildcards at the beginning of subdomains
if (hostname === "*") {
return true; // Allow just * for any domain
}
// Pattern like *.example.com
if (hostname.startsWith("*.")) {
const domain = hostname.substring(2);
// Domain part after *. must be valid
if (!domain || domain.includes("*") || domain.startsWith(".") || domain.endsWith(".")) {
return false;
}
// Basic domain validation - must have at least one dot and valid characters
return /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(domain);
}
// No other wildcard patterns are allowed
if (hostname.includes("*")) {
return false;
}
return false;
}
/**
* Validates an array of origin URLs
* @param origins - Array of origin URLs to validate
* @returns Object with validation result and invalid origins
*/
export function validateOrigins(origins: string[]): { isValid: boolean; invalidOrigins: string[] } {
if (!origins || origins.length === 0) {
return { isValid: true, invalidOrigins: [] };
}
const invalidOrigins = origins.filter((origin) => !isValidOrigin(origin));
return {
isValid: invalidOrigins.length === 0,
invalidOrigins,
};
}
/**
* Validates if a string is a valid Redis address
* Supports formats:
* - host:port (IPv4)
* - [host]:port (IPv6)
* - redis://host:port
* - rediss://host:port
* @param addr - The Redis address to validate
* @returns true if valid Redis address
*/
export function isValidRedisAddress(addr: string): boolean {
if (!addr) {
return false;
}
// Trim input once before processing
const trimmedAddr = addr.trim();
if (!trimmedAddr) {
return false;
}
try {
// Handle URL schemes (redis:// or rediss://)
if (trimmedAddr.startsWith("redis://") || trimmedAddr.startsWith("rediss://")) {
try {
const url = new URL(trimmedAddr);
const host = url.hostname;
const port = url.port || "6379"; // Default Redis port
// Check if host is IPv6 (contains colons or is bracketed)
const isIPv6Host = host.includes(":") || host.startsWith("[");
const hostToValidate = isIPv6Host ? host.replace(/^\[|\]$/g, "") : host;
const isValidHostResult = isIPv6Host ? isValidIPv6(hostToValidate) : isValidHost(hostToValidate);
return isValidHostResult && isValidPort(port);
} catch {
return false;
}
}
// Handle IPv6 addresses in brackets [host]:port
const ipv6Match = trimmedAddr.match(/^\[([^\]]+)\]:(\d+)$/);
if (ipv6Match) {
const [, host, port] = ipv6Match;
return isValidIPv6(host) && isValidPort(port);
}
// Handle standard host:port format
const colonIndex = trimmedAddr.lastIndexOf(":");
if (colonIndex === -1) {
return false;
}
const host = trimmedAddr.substring(0, colonIndex);
const port = trimmedAddr.substring(colonIndex + 1);
// Validate both host and port
return isValidHost(host) && isValidPort(port);
} catch {
return false;
}
}
/**
* Validates if a string is a valid host (hostname or IP address)
* @param host - The host to validate
* @returns true if valid host
*/
function isValidHost(host: string): boolean {
if (!host || !host.trim()) {
return false;
}
const trimmedHost = host.trim();
// Check if this looks like an IPv6 address (contains colons or is bracketed)
if (trimmedHost.includes(":") || trimmedHost.startsWith("[")) {
// Strip brackets if present and validate as IPv6
const ipv6Host = trimmedHost.replace(/^\[|\]$/g, "");
return isValidIPv6(ipv6Host);
}
// Check for valid hostname/IPv4 patterns
// Allow alphanumeric characters, dots, hyphens, and underscores
const hostPattern = /^[a-zA-Z0-9._-]+$/;
return hostPattern.test(trimmedHost) && trimmedHost.length <= 253;
}
/**
* Validates if a string is a valid port number (strict digit-only validation)
* @param port - The port to validate
* @returns true if valid port
*/
function isValidPort(port: string): boolean {
if (!port) {
return false;
}
const trimmedPort = port.trim();
// Port must consist only of digits (no trailing characters like "6379abc")
if (!/^\d+$/.test(trimmedPort)) {
return false;
}
// Convert to number and check range
const portNum = Number(trimmedPort);
return portNum >= 1 && portNum <= 65535;
}
/**
* Validates if a string is a valid IPv6 address
* @param host - The IPv6 address to validate (without brackets)
* @returns true if valid IPv6 address
*/
function isValidIPv6(host: string): boolean {
if (!host || !host.trim()) {
return false;
}
const trimmedHost = host.trim();
// Basic IPv6 pattern validation
// IPv6 addresses contain colons and hexadecimal characters
const ipv6Pattern =
/^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}$|^::$|^::1$|^([0-9a-fA-F]{0,4}:){0,6}::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$/;
// Check basic pattern
if (!ipv6Pattern.test(trimmedHost)) {
// Also allow IPv6 with embedded IPv4 (e.g., ::ffff:192.168.1.1)
const ipv6WithIpv4Pattern = /^([0-9a-fA-F]{0,4}:){1,6}(\d{1,3}\.){3}\d{1,3}$|^::([0-9a-fA-F]{0,4}:){0,5}(\d{1,3}\.){3}\d{1,3}$/;
if (!ipv6WithIpv4Pattern.test(trimmedHost)) {
return false;
}
}
// Additional validation: check for valid hex groups and proper structure
const parts = trimmedHost.split(":");
// IPv6 should not have more than 8 groups (unless it's compressed with ::)
if (parts.length > 8) {
return false;
}
// Check for valid hexadecimal groups
for (const part of parts) {
if (part !== "" && !/^[0-9a-fA-F]{1,4}$/.test(part)) {
// Allow IPv4 dotted notation in the last part
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(part)) {
return false;
}
}
}
return true;
}
export const isJson = (text: string) => {
try {
JSON.parse(text);
return true;
} catch {
return false;
}
};
export const cleanJson = (text: unknown) => {
try {
if (typeof text === "string") return JSON.parse(text); // parse JSON strings
if (Array.isArray(text)) return text; // keep arrays as-is
if (text !== null && typeof text === "object") return text; // keep objects as-is
if (typeof text === "number" || typeof text === "boolean") return text;
return "Invalid payload";
} catch {
return text;
}
};
/**
* Checks if a request type is disabled for a provider
* @param providerType - The provider type
* @param requestType - The request type
* @returns true if the request type is disabled
*/
export function isRequestTypeDisabled(providerType: BaseProvider | undefined, requestType: string): boolean {
if (!providerType) return false;
const supportedRequests = PROVIDER_SUPPORTED_REQUESTS[providerType];
if (!supportedRequests) return false; // If provider not in base list, allow all
return !supportedRequests.includes(requestType);
}
/**
* Cleans the path overrides by removing empty values
* @param overrides - The path overrides to clean
* @returns The cleaned path overrides
*/
export function cleanPathOverrides(overrides?: Record<string, string | undefined>) {
if (!overrides) return undefined;
const entries = Object.entries(overrides)
.map(([k, v]) => [k, v?.trim()])
.filter(([, v]) => v && v !== "");
return entries.length ? (Object.fromEntries(entries) as Record<string, string>) : undefined;
}