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

View File

@@ -0,0 +1,195 @@
/**
* CEL Fields Configuration for Routing Rules
* Defines available fields for building routing rule expressions
*/
import { getProviderLabel } from "@/lib/constants/logs";
export interface CELFieldDefinition {
name: string;
label: string;
placeholder?: string;
inputType?: "text" | "select" | "keyValue" | "number";
valueEditorType?:
| "text"
| "select"
| "keyValue"
| "number"
| "textarea"
| "budgetNumber"
| ((operator: string) => "text" | "select" | "keyValue" | "number" | "textarea" | "budgetNumber");
operators?: string[];
defaultOperator?: string;
defaultValue?: any;
values?: Array<{ name: string; label: string; disabled?: boolean }>;
metricOptions?: Array<{ name: string; label: string }>; // For budgetNumber type
description?: string; // Helpful note for the user
}
export const baseRoutingFields: CELFieldDefinition[] = [
{
name: "model",
label: "Model",
placeholder: "e.g., gpt-4, claude-3-sonnet",
inputType: "text",
valueEditorType: (operator: string) =>
operator === "=" || operator === "!=" ? "select" : operator === "in" || operator === "notIn" ? "select" : "text",
operators: ["=", "!=", "in", "notIn", "contains", "beginsWith", "endsWith", "matches"],
defaultOperator: "=",
},
{
name: "provider",
label: "Provider",
placeholder: "Select provider",
inputType: "select",
valueEditorType: (operator: string) =>
operator === "matches" ? "text" : operator === "in" || operator === "notIn" ? "select" : "select",
operators: ["=", "!=", "in", "notIn", "matches", "null", "notNull"],
defaultOperator: "=",
},
{
name: "request_type",
label: "Request Type",
placeholder: "Select request type",
inputType: "select",
valueEditorType: (operator: string) =>
operator === "matches" ? "text" : operator === "in" || operator === "notIn" ? "select" : "select",
operators: ["=", "!=", "in", "notIn", "matches"],
defaultOperator: "=",
values: [
{ name: "text_completion", label: "Text Completion" },
{ name: "chat_completion", label: "Chat Completion" },
{ name: "responses", label: "Responses" },
{ name: "embedding", label: "Embeddings" },
{ name: "image_generation", label: "Image Generation" },
{ name: "image_edit", label: "Image Edit" },
{ name: "image_variation", label: "Image Variation" },
{ name: "speech", label: "Speech" },
{ name: "transcription", label: "Transcription" },
{ name: "count_tokens", label: "Count Tokens" },
{ name: "rerank", label: "Rerank" },
{ name: "video_generation", label: "Video Generation" },
],
description: "Filter rules by the type of API request (chat, text, embeddings, images, audio, etc.)",
},
{
name: "headers",
label: "Header",
placeholder: "e.g., authorization, x-custom-header (use lowercase)",
inputType: "keyValue",
valueEditorType: "keyValue",
operators: ["=", "!=", "contains", "beginsWith", "endsWith", "matches", "null", "notNull"],
defaultOperator: "=",
},
{
name: "tokens_used",
label: "Tokens Used (%)",
placeholder: "e.g., 80",
inputType: "text",
valueEditorType: "number",
operators: ["=", "!=", ">", "<", ">=", "<="],
defaultOperator: ">=",
description: "Check token usage as percentage. Checked against max of model and provider configs.",
},
{
name: "request",
label: "Request (%)",
placeholder: "e.g., 80",
inputType: "text",
valueEditorType: "number",
operators: ["=", "!=", ">", "<", ">=", "<="],
defaultOperator: ">=",
description: "Check request usage as percentage. Checked against max of model and provider configs.",
},
{
name: "budget_used",
label: "Budget Used (%)",
placeholder: "e.g., 50",
inputType: "text",
valueEditorType: "number",
operators: ["=", "!=", ">", "<", ">=", "<="],
defaultOperator: ">=",
description: "Check budget usage as percentage. Checked against max of model and provider configs.",
},
{
name: "params",
label: "Query Parameter",
placeholder: "e.g., api_key, user_id",
inputType: "keyValue",
valueEditorType: "keyValue",
operators: ["=", "!=", "contains", "beginsWith", "endsWith", "matches", "null", "notNull"],
defaultOperator: "=",
},
];
/**
* Get routing fields with dynamic providers and models
* Provider field values are populated dynamically from available providers
* Metric options for rate limits and budget are populated from available providers and models
*/
export function getRoutingFields(providers: string[] = [], models: string[] = []): CELFieldDefinition[] {
// Create provider field values
const providerValues =
providers.length > 0
? providers.map((provider) => ({
name: provider,
label: getProviderLabel(provider),
}))
: [{ name: "_no_providers", label: "No providers configured", disabled: true }];
// Create model field values
const modelValues =
models.length > 0
? models.map((model) => ({
name: model,
label: model,
}))
: [];
// Create metric options for scope input: providers + models
const scopeOptions = [
{ name: "", label: "(provider-level)" }, // Empty scope for provider-level
...providers.map((provider) => ({
name: provider,
label: `${provider} (provider)`,
})),
...models.map((model) => ({
name: model,
label: `${model} (model)`,
})),
];
// Update provider field with dynamic values and rate limit/budget fields with scope options
const fieldsWithDynamicValues = baseRoutingFields.map((field) => {
if (field.name === "provider") {
return {
...field,
values: providerValues,
};
}
if (field.name === "model") {
return {
...field,
values: modelValues,
};
}
if (field.name === "tokens_used" || field.name === "request" || field.name === "budget_used") {
return {
...field,
metricOptions: scopeOptions,
};
}
return field;
});
return fieldsWithDynamicValues;
}
export const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
openai: "OpenAI",
anthropic: "Anthropic",
azure: "Azure OpenAI",
gemini: "Google Gemini",
vertex: "Vertex AI",
cohere: "Cohere",
};

View File

@@ -0,0 +1,50 @@
/**
* CEL Operators Configuration for Routing Rules
* Maps UI operators to CEL syntax
*/
export interface CELOperatorDefinition {
name: string;
label: string;
celSyntax: string;
}
export const celOperatorsRouting: CELOperatorDefinition[] = [
// Comparison operators
{ name: "=", label: "equals", celSyntax: "==" },
{ name: "!=", label: "does not equal", celSyntax: "!=" },
{ name: ">", label: "greater than", celSyntax: ">" },
{ name: "<", label: "less than", celSyntax: "<" },
{ name: ">=", label: "greater than or equal", celSyntax: ">=" },
{ name: "<=", label: "less than or equal", celSyntax: "<=" },
// List operators
{ name: "in", label: "is in list", celSyntax: "in" },
{ name: "notIn", label: "is not in list", celSyntax: "!in" },
// String operators
{ name: "contains", label: "contains", celSyntax: "contains" },
{ name: "beginsWith", label: "begins with", celSyntax: "startsWith" },
{ name: "endsWith", label: "ends with", celSyntax: "endsWith" },
{ name: "matches", label: "matches (regex)", celSyntax: "matches" },
// Existence operators
{ name: "null", label: "does not exist", celSyntax: "!has" },
{ name: "notNull", label: "exists", celSyntax: "has" },
];
/**
* Get CEL syntax for a given operator name
*/
export function getOperatorCELSyntax(operatorName: string): string {
const operator = celOperatorsRouting.find((op) => op.name === operatorName);
return operator ? operator.celSyntax : operatorName;
}
/**
* Get operator label for display
*/
export function getOperatorLabel(operatorName: string): string {
const operator = celOperatorsRouting.find((op) => op.name === operatorName);
return operator ? operator.label : operatorName;
}

198
ui/lib/constants/config.ts Normal file
View File

@@ -0,0 +1,198 @@
import { BaseProvider, ConcurrencyAndBufferSize, NetworkConfig } from "@/lib/types/config";
import { ProviderName } from "./logs";
/**
* Parse a date string in YYYY-MM-DD format with strict validation.
* Returns null if the string is empty, malformed, or represents an invalid date.
*/
function parseTrialExpiry(dateStr: string | undefined): Date | null {
if (!dateStr || !dateStr.trim()) return null;
// Strict format check: YYYY-MM-DD
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(dateStr)) return null;
const [year, month, day] = dateStr.split("-").map(Number);
const date = new Date(year, month - 1, day);
// Validate the date components match (catches invalid dates like 2024-02-30)
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return null;
}
return date;
}
// Model placeholders based on provider type
export const ModelPlaceholders = {
default: "e.g. gpt-4, gpt-3.5-turbo. Leave blank for all models.",
anthropic: "e.g. claude-3-haiku, claude-2.1",
azure: "e.g. gpt-4, gpt-35-turbo (must match alias keys)",
bedrock: "e.g. claude-v2, titan-text-express-v1, ai21-j2-mid",
cerebras: "e.g. cerebras-2, cerebras-2-vision",
cohere: "e.g. command-r, command-r-plus",
gemini: "e.g. gemini-1.5-pro, gemini-1.5-flash",
groq: "e.g. llama3-70b-8192, mixtral-8x7b-32768",
huggingface: "e.g. sambanova/meta-llama/Llama-3.1-8B-Instruct, nebius/Qwen/Qwen3-Embedding-8B",
mistral: "e.g. mistral-7b-instruct, mixtral-8x7b",
openrouter: "e.g. openai/gpt-4, anthropic/claude-3-haiku",
sgl: "e.g. sgl-2, sgl-vision",
parasail: "e.g. parasail-2, parasail-vision",
elevenlabs: "e.g. eleven_multilingual_v2, eleven_turbo_v2",
perplexity: "e.g. sonar-pro, sonar-deep-research",
ollama: "e.g. llama3.1, llama2",
openai: "e.g. gpt-4, gpt-4o, gpt-4o-mini, gpt-3.5-turbo",
vertex: "e.g. gemini-1.5-pro, text-bison, chat-bison",
nebius: "e.g. openai/gpt-oss-120b, google/gemma-2-9b-it-fast, Qwen/Qwen2.5-VL-72B-Instruct",
xai: "e.g. grok-4-0709, grok-3-mini, grok-3, grok-2-vision-1212",
replicate: "e.g. meta/llama3-1-8b-instruct, black-forest-labs/flux-dev",
vllm: "e.g. Qwen/Qwen3-0.6B, Qwen/Qwen3-1.5B",
runway: "e.g. gen4_turbo_image_to_video, gen3a_turbo_image_to_video",
fireworks: "e.g. accounts/fireworks/models/deepseek-v3p2",
};
export const isKeyRequiredByProvider: Record<ProviderName, boolean> = {
anthropic: true,
azure: true,
bedrock: true,
cerebras: true,
cohere: true,
gemini: true,
groq: true,
huggingface: true,
mistral: true,
openrouter: true,
sgl: false,
parasail: true,
elevenlabs: true,
ollama: false,
openai: true,
vertex: true,
perplexity: true,
nebius: true,
xai: true,
replicate: true,
runway: true,
vllm: false,
fireworks: true,
};
export const DefaultNetworkConfig = {
base_url: "",
default_request_timeout_in_seconds: 30,
max_retries: 0,
retry_backoff_initial: 1000,
retry_backoff_max: 10000,
insecure_skip_verify: false,
ca_cert_pem: "",
stream_idle_timeout_in_seconds: 60,
max_conns_per_host: 5000,
enforce_http2: false,
} satisfies NetworkConfig;
export const DefaultPerformanceConfig = {
concurrency: 1000,
buffer_size: 5000,
} satisfies ConcurrencyAndBufferSize;
export const MCP_STATUS_COLORS = {
connected: "bg-green-100 text-green-800",
error: "bg-red-100 text-red-800",
disconnected: "bg-gray-100 text-gray-800",
} as const;
// Mapping of what IS supported by each base provider
export const PROVIDER_SUPPORTED_REQUESTS: Record<BaseProvider, string[]> = {
openai: [
"list_models",
"text_completion",
"text_completion_stream",
"chat_completion",
"chat_completion_stream",
"responses",
"responses_stream",
"embedding",
"speech",
"speech_stream",
"transcription",
"transcription_stream",
"image_generation",
"image_generation_stream",
"image_edit",
"image_edit_stream",
"image_variation",
"count_tokens",
"video_generation",
"video_retrieve",
"video_download",
"video_delete",
"video_list",
"video_remix",
],
anthropic: ["list_models", "chat_completion", "chat_completion_stream", "responses", "responses_stream", "count_tokens"],
gemini: [
"list_models",
"chat_completion",
"chat_completion_stream",
"responses",
"responses_stream",
"embedding",
"transcription",
"transcription_stream",
"speech",
"speech_stream",
"image_generation",
"image_edit",
"count_tokens",
"video_generation",
"video_retrieve",
"video_download",
"video_delete",
"video_list",
"video_remix",
],
cohere: ["list_models", "chat_completion", "chat_completion_stream", "responses", "responses_stream", "embedding", "count_tokens"],
bedrock: [
"list_models",
"text_completion",
"chat_completion",
"chat_completion_stream",
"responses",
"responses_stream",
"embedding",
"image_generation",
"image_edit",
"image_variation",
],
replicate: [
"list_models",
"text_completion",
"chat_completion",
"chat_completion_stream",
"responses",
"responses_stream",
"image_generation",
"image_generation_stream",
"image_edit",
"image_edit_stream",
"video_generation",
"video_retrieve",
"video_download",
"video_delete",
"video_list",
"video_remix",
],
fireworks: [
"list_models",
"text_completion",
"text_completion_stream",
"chat_completion",
"chat_completion_stream",
"responses",
"responses_stream",
"embedding",
],
};
export const IS_ENTERPRISE = process.env.BIFROST_IS_ENTERPRISE === "true";
export const TRIAL_EXPIRY = parseTrialExpiry(process.env.BIFROST_ENTERPRISE_TRIAL_EXPIRY);

View File

@@ -0,0 +1,37 @@
// Governance-related constants
export const resetDurationOptions = [
{ label: "Every Minute", value: "1m" },
{ label: "Every 5 Minutes", value: "5m" },
{ label: "Every 15 Minutes", value: "15m" },
{ label: "Every 30 Minutes", value: "30m" },
{ label: "Hourly", value: "1h" },
{ label: "Every 6 Hours", value: "6h" },
{ label: "Daily", value: "1d" },
{ label: "Weekly", value: "1w" },
{ label: "Monthly", value: "1M" },
];
export const budgetDurationOptions = [
{ label: "Hourly", value: "1h" },
{ label: "Daily", value: "1d" },
{ label: "Weekly", value: "1w" },
{ label: "Monthly", value: "1M" },
];
// Durations that support calendar-aligned resets (snap to day/week/month/year boundaries).
// Must stay in sync with IsCalendarAlignableDuration in framework/configstore/tables/utils.go.
export const supportsCalendarAlignment = (duration: string): boolean => duration.length > 0 && /[dwMY]$/.test(duration);
// Map of duration values to short labels for display
export const resetDurationLabels: Record<string, string> = {
"1m": "Every Minute",
"5m": "Every 5 Minutes",
"15m": "Every 15 Minutes",
"30m": "Every 30 Minutes",
"1h": "Hourly",
"6h": "Every 6 Hours",
"1d": "Daily",
"1w": "Weekly",
"1M": "Monthly",
};

783
ui/lib/constants/icons.tsx Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { RequestTypeColors, RequestTypeLabels, RequestTypes } from "./logs";
describe("logs constants", () => {
it("registers realtime turn as a known request type", () => {
expect(RequestTypes).toContain("realtime.turn");
expect(RequestTypeLabels["realtime.turn"]).toBe("Realtime Turn");
expect(RequestTypeColors["realtime.turn"]).toBeTruthy();
});
});

308
ui/lib/constants/logs.ts Normal file
View File

@@ -0,0 +1,308 @@
// Known provider names array - centralized definition
export const KnownProvidersNames = [
"anthropic",
"azure",
"bedrock",
"cerebras",
"cohere",
"gemini",
"groq",
"huggingface",
"mistral",
"ollama",
"openai",
"openrouter",
"parasail",
"elevenlabs",
"perplexity",
"sgl",
"vertex",
"nebius",
"xai",
"replicate",
"vllm",
"runway",
"fireworks",
] as const;
// Local Provider type derived from KNOWN_PROVIDERS constant
export type ProviderName = (typeof KnownProvidersNames)[number];
export const ProviderNames: readonly ProviderName[] = KnownProvidersNames;
export const Statuses = ["success", "error", "processing", "cancelled"] as const;
export const RequestTypes = [
"list_models",
"text_completion",
"text_completion_stream",
"chat_completion",
"chat_completion_stream",
"responses",
"responses_stream",
"embedding",
"rerank",
"speech",
"speech_stream",
"transcription",
"transcription_stream",
"image_generation",
"image_generation_stream",
"image_edit",
"image_edit_stream",
"image_variation",
"ocr",
"ocr_stream",
"video_generation",
"video_retrieve",
"video_download",
"video_delete",
"video_list",
"video_remix",
"count_tokens",
// Container operations
"container_create",
"container_list",
"container_retrieve",
"container_delete",
// Container file operations
"container_file_create",
"container_file_list",
"container_file_retrieve",
"container_file_content",
"container_file_delete",
"passthrough",
"passthrough_stream",
// WebSocket/Realtime operations
"websocket_responses",
"realtime",
"realtime.turn",
] as const;
export const ProviderLabels: Record<ProviderName, string> = {
openai: "OpenAI",
anthropic: "Anthropic",
azure: "Azure",
bedrock: "AWS Bedrock",
cohere: "Cohere",
vertex: "Vertex AI",
mistral: "Mistral AI",
ollama: "Ollama",
groq: "Groq",
parasail: "Parasail",
elevenlabs: "Elevenlabs",
perplexity: "Perplexity",
sgl: "SGLang",
cerebras: "Cerebras",
gemini: "Gemini",
openrouter: "OpenRouter",
huggingface: "HuggingFace",
nebius: "Nebius Token Factory",
xai: "xAI",
replicate: "Replicate",
vllm: "vLLM",
runway: "Runway",
fireworks: "Fireworks AI",
} as const;
// Helper function to get provider label, supporting custom providers
export const getProviderLabel = (provider: string): string => {
// Use hasOwnProperty for safe lookup without checking prototype chain
if (Object.prototype.hasOwnProperty.call(ProviderLabels, provider.toLowerCase().trim() as ProviderName)) {
return ProviderLabels[provider.toLowerCase().trim() as ProviderName];
}
// For custom providers, return the original provider name as is
return provider;
};
export const StatusColors = {
success: "bg-green-100 text-green-800",
error: "bg-red-100 text-red-800",
processing: "bg-blue-100 text-blue-800",
cancelled: "bg-gray-100 text-gray-800",
} as const;
export const StatusBarColors = {
success: "bg-green-500",
error: "bg-red-500",
processing: "bg-blue-500",
cancelled: "bg-gray-400",
} as const;
export const RequestTypeLabels = {
"chat.completion": "Chat",
response: "Responses",
"response.completion.chunk": "Responses Stream",
completion: "Completion",
"text.completion": "Text",
list: "List",
"audio.speech": "Speech",
"audio.transcription": "Transcription",
"chat.completion.chunk": "Chat Stream",
"audio.speech.chunk": "Speech Stream",
"audio.transcription.chunk": "Transcription Stream",
// Request Types
list_models: "List Models",
text_completion: "Text",
text_completion_stream: "Text Stream",
chat_completion: "Chat",
chat_completion_stream: "Chat Stream",
responses: "Responses",
responses_stream: "Responses Stream",
embedding: "Embedding",
rerank: "Rerank",
speech: "Speech",
speech_stream: "Speech Stream",
transcription: "Transcription",
transcription_stream: "Transcription Stream",
image_generation: "Image Generation",
image_generation_stream: "Image Generation Stream",
image_edit: "Image Edit",
image_edit_stream: "Image Edit Stream",
image_variation: "Image Variation",
ocr: "OCR",
ocr_stream: "OCR Stream",
video_generation: "Video Generation",
video_retrieve: "Video Retrieve",
video_download: "Video Download",
video_delete: "Video Delete",
video_list: "Video List",
video_remix: "Video Remix",
count_tokens: "Count Tokens",
batch_create: "Batch Create",
batch_list: "Batch List",
batch_retrieve: "Batch Retrieve",
batch_cancel: "Batch Cancel",
batch_delete: "Batch Delete",
batch_results: "Batch Results",
file_upload: "File Upload",
file_list: "File List",
file_retrieve: "File Retrieve",
file_delete: "File Delete",
file_content: "File Content",
// Container operations
container_create: "Container Create",
container_list: "Container List",
container_retrieve: "Container Retrieve",
container_delete: "Container Delete",
// Container file operations
container_file_create: "Container File Create",
container_file_list: "Container File List",
container_file_retrieve: "Container File Retrieve",
container_file_content: "Container File Content",
container_file_delete: "Container File Delete",
passthrough: "Passthrough",
passthrough_stream: "Passthrough Stream",
// WebSocket operations
websocket_responses: "WebSocket Responses",
realtime: "Realtime",
"realtime.turn": "Realtime Turn",
} as const;
export const RequestTypeColors = {
"chat.completion": "bg-blue-100 text-blue-800",
response: "bg-teal-100 text-teal-800",
"response.completion.chunk": "bg-violet-100 text-violet-800",
"text.completion": "bg-green-100 text-green-800",
list: "bg-red-100 text-red-800",
"audio.speech": "bg-purple-100 text-purple-800",
"audio.transcription": "bg-orange-100 text-orange-800",
"chat.completion.chunk": "bg-yellow-100 text-yellow-800",
"audio.speech.chunk": "bg-pink-100 text-pink-800",
"audio.transcription.chunk": "bg-lime-100 text-lime-800",
completion: "bg-yellow-100 text-yellow-800",
// Request Types
list_models: "bg-green-100 text-green-800",
text_completion: "bg-green-100 text-green-800",
text_completion_stream: "bg-amber-100 text-amber-800",
chat_completion: "bg-blue-100 text-blue-800",
chat_completion_stream: "bg-yellow-100 text-yellow-800",
responses: "bg-teal-100 text-teal-800",
responses_stream: "bg-violet-100 text-violet-800",
embedding: "bg-red-100 text-red-800",
rerank: "bg-fuchsia-100 text-fuchsia-800",
speech: "bg-purple-100 text-purple-800",
speech_stream: "bg-pink-100 text-pink-800",
transcription: "bg-orange-100 text-orange-800",
transcription_stream: "bg-lime-100 text-lime-800",
image_generation: "bg-indigo-100 text-indigo-800",
image_generation_stream: "bg-sky-100 text-sky-800",
image_edit: "bg-emerald-100 text-emerald-800",
image_edit_stream: "bg-teal-100 text-teal-800",
image_variation: "bg-violet-100 text-violet-800",
ocr: "bg-amber-100 text-amber-800",
ocr_stream: "bg-yellow-100 text-yellow-800",
video_generation: "bg-fuchsia-100 text-fuchsia-800",
video_retrieve: "bg-blue-100 text-blue-800",
video_download: "bg-purple-100 text-purple-800",
video_delete: "bg-rose-100 text-rose-800",
video_list: "bg-cyan-100 text-cyan-800",
video_remix: "bg-pink-100 text-pink-800",
count_tokens: "bg-cyan-100 text-cyan-800",
// Container operations
container_create: "bg-emerald-100 text-emerald-800",
container_list: "bg-teal-100 text-teal-800",
container_retrieve: "bg-cyan-100 text-cyan-800",
container_delete: "bg-rose-100 text-rose-800",
// Container file operations
container_file_create: "bg-emerald-100 text-emerald-800",
container_file_list: "bg-teal-100 text-teal-800",
container_file_retrieve: "bg-cyan-100 text-cyan-800",
container_file_content: "bg-sky-100 text-sky-800",
container_file_delete: "bg-rose-100 text-rose-800",
passthrough: "bg-slate-100 text-slate-800",
passthrough_stream: "bg-slate-200 text-slate-800",
batch_create: "bg-green-100 text-green-800",
batch_list: "bg-blue-100 text-blue-800",
batch_retrieve: "bg-red-100 text-red-800",
batch_cancel: "bg-yellow-100 text-yellow-800",
batch_delete: "bg-amber-100 text-amber-800",
batch_results: "bg-purple-100 text-purple-800",
file_upload: "bg-pink-100 text-pink-800",
file_list: "bg-lime-100 text-lime-800",
file_retrieve: "bg-orange-100 text-orange-800",
file_delete: "bg-red-100 text-red-800",
file_content: "bg-blue-100 text-blue-800",
// WebSocket operations
websocket_responses: "bg-teal-100 text-teal-800",
realtime: "bg-indigo-100 text-indigo-800",
"realtime.turn": "bg-cyan-100 text-cyan-800",
} as const;
export const RoutingEngineUsedLabels = {
"routing-rule": "Routing Rule",
governance: "Governance",
loadbalancing: "Loadbalancing",
} as const;
export const RoutingEngineUsedColors = {
"routing-rule": "bg-blue-100 text-blue-800",
governance: "bg-green-100 text-green-800",
loadbalancing: "bg-red-100 text-red-800",
} as const;
export type Status = (typeof Statuses)[number];

View File

@@ -0,0 +1,176 @@
import { createHighlighterCore, type HighlighterCore, type TokensResult } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
// Languages we actually want to highlight in the Bifrost UI.
// Adding a new language requires only adding the dynamic import below.
const langLoaders = {
typescript: () => import("shiki/langs/typescript.mjs"),
javascript: () => import("shiki/langs/javascript.mjs"),
tsx: () => import("shiki/langs/tsx.mjs"),
jsx: () => import("shiki/langs/jsx.mjs"),
json: () => import("shiki/langs/json.mjs"),
python: () => import("shiki/langs/python.mjs"),
go: () => import("shiki/langs/go.mjs"),
bash: () => import("shiki/langs/bash.mjs"),
shell: () => import("shiki/langs/shell.mjs"),
yaml: () => import("shiki/langs/yaml.mjs"),
sql: () => import("shiki/langs/sql.mjs"),
html: () => import("shiki/langs/html.mjs"),
css: () => import("shiki/langs/css.mjs"),
markdown: () => import("shiki/langs/markdown.mjs"),
xml: () => import("shiki/langs/xml.mjs"),
} as const;
const themeLoaders = {
"github-light": () => import("shiki/themes/github-light.mjs"),
"github-dark": () => import("shiki/themes/github-dark.mjs"),
} as const;
type SupportedLang = keyof typeof langLoaders;
type SupportedTheme = keyof typeof themeLoaders;
const langAliases: Record<string, SupportedLang> = {
ts: "typescript",
js: "javascript",
py: "python",
golang: "go",
sh: "bash",
yml: "yaml",
htm: "html",
md: "markdown",
};
const supportedSet = new Set(Object.keys(langLoaders) as SupportedLang[]);
const normalizeLanguage = (lang: string): SupportedLang | "text" => {
const key = lang.trim().toLowerCase();
const alias = langAliases[key];
if (alias) return alias;
if (supportedSet.has(key as SupportedLang)) return key as SupportedLang;
return "text";
};
let highlighterPromise: Promise<HighlighterCore> | null = null;
const loadedLangs = new Set<string>();
const loadedThemes = new Set<string>();
const tokenCache = new Map<string, TokensResult>();
const pendingCallbacks = new Map<string, Set<(result: TokensResult) => void>>();
const pendingHighlights = new Map<string, Promise<void>>();
const MAX_TOKEN_CACHE_ENTRIES = 200;
const rememberTokens = (key: string, result: TokensResult): void => {
if (tokenCache.has(key)) tokenCache.delete(key);
tokenCache.set(key, result);
if (tokenCache.size > MAX_TOKEN_CACHE_ENTRIES) {
const oldestKey = tokenCache.keys().next().value;
if (oldestKey) tokenCache.delete(oldestKey);
}
};
const getHighlighter = async (lang: SupportedLang | "text", themes: [SupportedTheme, SupportedTheme]): Promise<HighlighterCore> => {
if (!highlighterPromise) {
highlighterPromise = createHighlighterCore({
themes: [],
langs: [],
engine: createJavaScriptRegexEngine({ forgiving: true }),
});
}
const highlighter = await highlighterPromise;
for (const theme of themes) {
if (!loadedThemes.has(theme)) {
const mod = await themeLoaders[theme]();
await highlighter.loadTheme(mod.default);
loadedThemes.add(theme);
}
}
if (lang !== "text" && !loadedLangs.has(lang)) {
const mod = await langLoaders[lang]();
await highlighter.loadLanguage(mod.default);
loadedLangs.add(lang);
}
return highlighter;
};
const cacheKey = (code: string, lang: string, themes: [string, string]): string => {
return JSON.stringify([lang, themes[0], themes[1], code]);
};
interface HighlightOptions {
code: string;
language: string;
themes: [string, string];
}
interface CodeHighlighterPlugin {
name: "shiki";
type: "code-highlighter";
getSupportedLanguages: () => string[];
getThemes: () => [string, string];
supportsLanguage: (language: string) => boolean;
highlight: (options: HighlightOptions, callback?: (result: TokensResult) => void) => TokensResult | null;
}
const isSupportedTheme = (theme: string): theme is SupportedTheme => theme in themeLoaders;
interface CodePluginOptions {
themes?: [SupportedTheme, SupportedTheme];
}
export function createCodePlugin(options: CodePluginOptions = {}): CodeHighlighterPlugin {
const themes = options.themes ?? (["github-light", "github-dark"] as [SupportedTheme, SupportedTheme]);
return {
name: "shiki",
type: "code-highlighter",
getSupportedLanguages: () => Array.from(supportedSet),
getThemes: () => themes,
supportsLanguage: (language: string) => normalizeLanguage(language) !== "text",
highlight({ code, language, themes: optThemes }, callback) {
const lang = normalizeLanguage(language);
const themesPair: [SupportedTheme, SupportedTheme] =
isSupportedTheme(optThemes[0]) && isSupportedTheme(optThemes[1]) ? [optThemes[0], optThemes[1]] : themes;
const key = cacheKey(code, lang, themesPair);
const cached = tokenCache.get(key);
if (cached) return cached;
if (callback) {
if (!pendingCallbacks.has(key)) pendingCallbacks.set(key, new Set());
pendingCallbacks.get(key)!.add(callback);
}
if (pendingHighlights.has(key)) return null;
const work = getHighlighter(lang, themesPair)
.then((highlighter) => {
const finalLang = lang === "text" ? "text" : highlighter.getLoadedLanguages().includes(lang) ? lang : "text";
const result = highlighter.codeToTokens(code, {
lang: finalLang,
themes: { light: themesPair[0], dark: themesPair[1] },
});
rememberTokens(key, result);
const callbacks = pendingCallbacks.get(key);
if (callbacks) {
for (const cb of callbacks) cb(result);
pendingCallbacks.delete(key);
}
})
.catch((err) => {
console.error("[Bifrost Code Highlighter] Failed to highlight:", err);
pendingCallbacks.delete(key);
})
.finally(() => {
pendingHighlights.delete(key);
});
pendingHighlights.set(key, work);
return null;
},
};
}
export const code = createCodePlugin();

View File

@@ -0,0 +1,36 @@
/**
* Jinja2 variable utilities for prompt messages.
*
* Extracts `{{ variable_name }}` patterns from message content and provides
* substitution at execution time. Supports basic Jinja2 variable syntax:
* {{ name }}
* {{ user_name }}
* {{ some.nested }} (treated as a flat key "some.nested")
*
* Filters ({{ x | upper }}) and expressions are NOT evaluated — only
* simple variable references are extracted and replaced.
*/
/** Matches {{ variable_name }} with optional whitespace inside braces */
export const JINJA_VAR_REGEX = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g;
/**
* Highlight patterns for Jinja2 variables in rich textareas
*/
export const JINJA_VAR_HIGHLIGHT_PATTERNS = [
{
pattern: /\{\{\s*[a-zA-Z_][a-zA-Z0-9_.]*\s*\}\}/g,
className: "outline-content-brand-light text-sm cursor-pointer bg-green-500/20",
validate: (part: string) => {
return (
part.startsWith?.("{{") &&
part.endsWith?.("}}") &&
!part.slice(2, -2).includes("{") &&
!part.slice(2, -2).includes("}") &&
!part.slice(2, -2).includes("'") &&
!part.slice(2, -2).includes('"')
);
},
enableVariableClickEdit: true,
},
];

27
ui/lib/message/index.ts Normal file
View File

@@ -0,0 +1,27 @@
export { Message } from "./message";
export {
MessageRole,
MessageType,
type APIMessage,
type CompletionRequest,
type CompletionResult,
type CompletionResultChoice,
type CompletionUsage,
type MessageContent,
type MessageError,
type MessageFile,
type MessageImageURL,
type MessageInputAudio,
type SerializedMessage,
type ToolCall,
type ToolCallFunction,
type ToolResult,
} from "./types";
export {
extractVariablesFromText,
extractVariablesFromMessages,
replaceVariablesInText,
replaceVariablesInMessages,
mergeVariables,
type VariableMap,
} from "./variables";

440
ui/lib/message/message.ts Normal file
View File

@@ -0,0 +1,440 @@
import { v4 as uuidv4 } from "uuid";
import {
APIMessage,
CompletionRequest,
CompletionResult,
CompletionUsage,
MessageContent,
MessageError,
MessageRole,
MessageType,
SerializedMessage,
ToolCall,
ToolResult,
} from "./types";
export class Message {
readonly id: string;
readonly index: number;
private readonly originalType: MessageType;
private currentType: MessageType;
private _payload?: CompletionRequest | CompletionResult | ToolResult;
readonly error?: MessageError;
constructor(
id: string,
index: number,
type: MessageType,
payload?: CompletionRequest | CompletionResult | ToolResult,
error?: MessageError,
) {
this.id = id;
this.index = index;
this.originalType = type;
this.currentType = type;
this._payload = payload;
this.error = error;
}
// Convenience factory methods
static system(content: string, index = 0): Message {
return new Message(uuidv4(), index, MessageType.CompletionRequest, {
role: MessageRole.SYSTEM,
content,
} as CompletionRequest);
}
static request(content: string, index = 0, attachments?: MessageContent[]): Message {
if (attachments && attachments.length > 0) {
const parts: MessageContent[] = [{ type: "text", text: content }, ...attachments];
return new Message(uuidv4(), index, MessageType.CompletionRequest, {
role: MessageRole.USER,
content: parts,
} as CompletionRequest);
}
return new Message(uuidv4(), index, MessageType.CompletionRequest, {
role: MessageRole.USER,
content,
} as CompletionRequest);
}
static response(content: string, index = 0, usage?: CompletionUsage): Message {
return new Message(uuidv4(), index, MessageType.CompletionResult, {
id: uuidv4(),
choices: [{ index: 0, message: { role: MessageRole.ASSISTANT, content } }],
usage,
} as CompletionResult);
}
static toolCallResponse(content: string, toolCalls: ToolCall[], index = 0, usage?: CompletionUsage): Message {
return new Message(uuidv4(), index, MessageType.CompletionResult, {
id: uuidv4(),
choices: [{ index: 0, message: { role: MessageRole.ASSISTANT, content, tool_calls: toolCalls }, finish_reason: "tool_calls" }],
usage,
} as CompletionResult);
}
static error(content: string, index = 0): Message {
return new Message(uuidv4(), index, MessageType.CompletionError, undefined, {
code: "error",
message: content,
});
}
public get payload() {
return this._payload;
}
public get type(): MessageType {
return this.currentType;
}
// Serialization
public get serialized(): SerializedMessage {
const s: SerializedMessage = {
id: this.id,
index: this.index,
originalType: this.originalType,
currentType: this.currentType,
payload: this._payload ? JSON.parse(JSON.stringify(this._payload)) : undefined,
};
if (this.error) {
s.error = this.error;
}
return s;
}
public static deserialize(serialized: SerializedMessage): Message {
const message = new Message(
serialized.id,
serialized.index,
serialized.originalType,
serialized.payload ? JSON.parse(JSON.stringify(serialized.payload)) : undefined,
serialized.error,
);
message.currentType = serialized.currentType;
return message;
}
public withIndex(index: number): Message {
if (this.index === index) return this;
const m = new Message(this.id, index, this.originalType, this._payload, this.error);
m.currentType = this.currentType;
return m;
}
public clone(): Message {
return Message.deserialize(this.serialized);
}
// Role
public get role(): MessageRole | undefined {
switch (this.originalType) {
case MessageType.CompletionRequest:
return (this._payload as CompletionRequest)?.role;
case MessageType.CompletionResult:
return (this._payload as CompletionResult)?.choices?.[0]?.message?.role;
case MessageType.CompletionError:
return MessageRole.ASSISTANT;
case MessageType.ToolResult:
return (this._payload as ToolResult)?.role ?? MessageRole.TOOL;
default:
return undefined;
}
}
public set role(role: MessageRole) {
switch (this.originalType) {
case MessageType.CompletionRequest:
(this._payload as CompletionRequest).role = role;
break;
case MessageType.CompletionResult:
(this._payload as CompletionResult).choices?.forEach((choice) => {
choice.message.role = role;
});
break;
case MessageType.ToolResult:
(this._payload as any).role = role;
break;
}
switch (role) {
case MessageRole.ASSISTANT:
this.currentType = MessageType.CompletionResult;
break;
case MessageRole.USER:
case MessageRole.SYSTEM:
case MessageRole.DEVELOPER:
this.currentType = MessageType.CompletionRequest;
break;
case MessageRole.TOOL:
this.currentType = MessageType.ToolResult;
break;
default:
this.currentType = MessageType.CompletionResult;
break;
}
}
// Content
public get content(): string {
switch (this.originalType) {
case MessageType.CompletionRequest: {
const payload = this._payload as CompletionRequest;
if (!payload?.content) return "";
if (typeof payload.content === "string") return payload.content;
const textPart = payload.content.find((c) => c.type === "text");
return textPart?.text || "";
}
case MessageType.CompletionResult:
return (this._payload as CompletionResult)?.choices?.[0]?.message?.content ?? "";
case MessageType.ToolResult:
return (this._payload as ToolResult)?.content ?? "";
default:
return this.error?.message || "";
}
}
public set content(content: string) {
switch (this.originalType) {
case MessageType.CompletionRequest: {
const payload = this._payload as CompletionRequest;
if (typeof payload.content === "string" || payload.content === null) {
this._payload = { ...payload, content } as CompletionRequest;
} else if (Array.isArray(payload.content)) {
const updated = JSON.parse(JSON.stringify(payload.content));
if (updated.length > 0 && updated[0].type === "text") {
updated[0].text = content;
} else {
updated.unshift({ type: "text", text: content });
}
this._payload = { ...payload, content: updated } as CompletionRequest;
}
break;
}
case MessageType.ToolResult:
this._payload = { ...this._payload, content } as ToolResult;
break;
case MessageType.CompletionResult: {
const result = this._payload as CompletionResult;
const choices = result?.choices?.map((c) => ({ ...c })) ?? [];
if (choices[0]) {
choices[0].message = { ...choices[0].message, content };
}
this._payload = { ...result, choices } as CompletionResult;
break;
}
}
}
// Attachments (non-text content parts)
public get attachments(): MessageContent[] {
if (this.originalType !== MessageType.CompletionRequest) return [];
const payload = this._payload as CompletionRequest;
if (!Array.isArray(payload?.content)) return [];
return payload.content.filter((c) => c.type !== "text");
}
public set attachments(parts: MessageContent[]) {
if (this.originalType !== MessageType.CompletionRequest) return;
const payload = this._payload as CompletionRequest;
const text = this.content;
if (parts.length === 0) {
this._payload = { ...payload, content: text } as CompletionRequest;
} else {
this._payload = {
...payload,
content: [{ type: "text", text } as MessageContent, ...parts],
} as CompletionRequest;
}
}
// Tool calls
public get toolCalls(): ToolCall[] | undefined {
if (this.originalType === MessageType.CompletionResult) {
const calls = (this._payload as CompletionResult)?.choices?.map((c) => c.message.tool_calls ?? []).flat();
return calls && calls.length > 0 ? calls : undefined;
}
if (this.originalType === MessageType.CompletionRequest) {
const calls = (this._payload as CompletionRequest)?.tool_calls;
return calls && calls.length > 0 ? calls : undefined;
}
return undefined;
}
public get toolCallId(): string | undefined {
if (this.originalType === MessageType.ToolResult || this.currentType === MessageType.ToolResult) {
return (this._payload as ToolResult)?.tool_call_id;
}
if (this.originalType === MessageType.CompletionRequest) {
return (this._payload as CompletionRequest)?.tool_call_id;
}
return undefined;
}
public set toolCallId(id: string) {
if (this.originalType === MessageType.ToolResult) {
(this._payload as ToolResult).tool_call_id = id;
} else if (this.originalType === MessageType.CompletionRequest) {
(this._payload as CompletionRequest).tool_call_id = id;
}
}
public get isToolCallError(): boolean | undefined {
if (this.originalType === MessageType.ToolResult || this.currentType === MessageType.ToolResult) {
return !!(this._payload as ToolResult)?.isError;
}
return undefined;
}
public setToolCallError(isError: boolean | undefined): void {
if (this.originalType === MessageType.ToolResult || this.currentType === MessageType.ToolResult) {
this._payload = { ...this._payload, isError } as ToolResult;
}
}
public get finishReasons(): string | undefined {
if (this.originalType === MessageType.CompletionResult) {
return (this._payload as CompletionResult)?.choices?.find((c) => c.finish_reason)?.finish_reason;
}
return undefined;
}
// Usage
public get usage(): CompletionUsage | undefined {
if (this.originalType === MessageType.CompletionResult) {
return (this._payload as CompletionResult)?.usage;
}
return undefined;
}
public set usage(usage: CompletionUsage | undefined) {
if (this.originalType === MessageType.CompletionResult) {
(this._payload as CompletionResult).usage = usage;
}
}
// Batch helpers
static serializeAll(messages: Message[]): SerializedMessage[] {
return messages.map((m) => m.serialized);
}
static deserializeAll(data: SerializedMessage[]): Message[] {
return data.map((d) => Message.deserialize(d));
}
/**
* Convert to OpenAI-compatible API format for chat completions.
* Excludes error messages.
*/
static toAPIMessages(messages: Message[]): APIMessage[] {
return messages
.filter((m) => m.type !== MessageType.CompletionError)
.map((m): APIMessage => {
// When role has been changed, currentType differs from originalType —
// fall back to a generic conversion using the public getters.
if (m.currentType !== m.originalType) {
const msg: APIMessage = { role: m.role ?? MessageRole.ASSISTANT, content: m.content };
if (m.toolCalls && m.toolCalls.length > 0) msg.tool_calls = m.toolCalls;
if (m.toolCallId) msg.tool_call_id = m.toolCallId;
return msg;
}
switch (m.originalType) {
case MessageType.CompletionRequest: {
const p = m._payload as CompletionRequest;
const msg: APIMessage = { role: p.role, content: p.content };
if (p.tool_calls && p.tool_calls.length > 0) msg.tool_calls = p.tool_calls;
if (p.tool_call_id) msg.tool_call_id = p.tool_call_id;
return msg;
}
case MessageType.CompletionResult: {
const choice = (m._payload as CompletionResult)?.choices?.[0]?.message;
const msg: APIMessage = {
role: choice?.role ?? MessageRole.ASSISTANT,
content: choice?.content ?? "",
};
if (choice?.tool_calls && choice.tool_calls.length > 0) {
msg.tool_calls = choice.tool_calls;
}
return msg;
}
case MessageType.ToolResult: {
const p = m._payload as ToolResult;
return {
role: MessageRole.TOOL,
content: p.content,
tool_call_id: p.tool_call_id,
};
}
default:
return { role: MessageRole.ASSISTANT, content: m.content };
}
});
}
/**
* Backward-compatible deserialization for old { role, content } format.
* Detects whether data is the new serialized format or old flat format.
*/
static fromLegacy(
data: SerializedMessage | { role: string; content: string | null; tool_calls?: ToolCall[]; tool_call_id?: string },
index: number,
): Message {
// New format has originalType
if ("originalType" in data && data.originalType) {
return Message.deserialize(data as SerializedMessage);
}
// Legacy { role, content } format
const legacy = data as { role: string; content: string | null; tool_calls?: ToolCall[]; tool_call_id?: string };
if (legacy.tool_calls && legacy.tool_calls.length > 0) {
return new Message(uuidv4(), index, MessageType.CompletionResult, {
id: uuidv4(),
choices: [
{
index: 0,
message: {
role: MessageRole.ASSISTANT,
content: (legacy.content as string) ?? "",
tool_calls: legacy.tool_calls,
},
},
],
});
}
if (legacy.role === "tool" && legacy.tool_call_id) {
return new Message(uuidv4(), index, MessageType.ToolResult, {
role: MessageRole.TOOL,
content: (legacy.content as string) ?? "",
tool_call_id: legacy.tool_call_id,
});
}
const role = (legacy.role as MessageRole) ?? MessageRole.USER;
if (role === MessageRole.ASSISTANT) {
return new Message(uuidv4(), index, MessageType.CompletionResult, {
id: uuidv4(),
choices: [{ index: 0, message: { role: MessageRole.ASSISTANT, content: (legacy.content as string) ?? "" } }],
});
}
return new Message(uuidv4(), index, MessageType.CompletionRequest, {
role,
content: legacy.content,
} as CompletionRequest);
}
static fromLegacyAll(data: any[]): Message[] {
return data.map((d, i) => Message.fromLegacy(d, i));
}
}

107
ui/lib/message/types.ts Normal file
View File

@@ -0,0 +1,107 @@
export enum MessageType {
CompletionRequest = "completion_request",
CompletionResult = "completion_result",
CompletionError = "error",
ToolResult = "tool_result",
}
export enum MessageRole {
ASSISTANT = "assistant",
USER = "user",
SYSTEM = "system",
TOOL = "tool",
DEVELOPER = "developer",
}
export type ToolCallFunction = {
name: string;
arguments: string;
};
export type ToolCall = {
type: "function";
id: string;
function: ToolCallFunction;
};
export type MessageContent = {
type: "text" | "image_url" | "input_audio" | "file";
text?: string;
image_url?: MessageImageURL;
input_audio?: MessageInputAudio;
file?: MessageFile;
};
export type MessageImageURL = {
url: string;
detail?: "auto" | "low" | "high";
};
export type MessageInputAudio = {
data: string;
format: string;
};
export type MessageFile = {
file_data?: string;
file_id?: string;
filename?: string;
file_type?: string;
};
export type CompletionRequest = {
role: MessageRole;
content: string | MessageContent[] | null;
tool_call_id?: string;
tool_calls?: ToolCall[];
};
export type CompletionResultChoice = {
index: number;
message: {
role: MessageRole;
content: string;
tool_calls?: ToolCall[];
};
finish_reason?: string;
};
export type CompletionUsage = {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
export type CompletionResult = {
id: string;
choices: CompletionResultChoice[];
usage?: CompletionUsage;
};
export type ToolResult = {
role: MessageRole.TOOL;
content: string;
tool_call_id: string;
isError?: boolean;
};
export type MessageError = {
code: string | number;
message: string;
};
export type SerializedMessage = {
id: string;
index: number;
originalType: MessageType;
currentType: MessageType;
payload?: CompletionRequest | CompletionResult | ToolResult;
error?: MessageError;
};
export type APIMessage = {
role: MessageRole;
content: string | MessageContent[] | null;
tool_calls?: ToolCall[];
tool_call_id?: string;
};

View File

@@ -0,0 +1,363 @@
import { describe, expect, it } from "vitest";
import { Message } from "./message";
import {
extractVariablesFromMessages,
extractVariablesFromText,
mergeVariables,
replaceVariablesInMessages,
replaceVariablesInText,
} from "./variables";
// =============================================================================
// extractVariablesFromText
// =============================================================================
describe("extractVariablesFromText", () => {
it("extracts a single variable", () => {
expect(extractVariablesFromText("Hello {{ name }}")).toEqual(["name"]);
});
it("extracts multiple distinct variables", () => {
expect(extractVariablesFromText("{{ greeting }}, {{ name }}!")).toEqual(["greeting", "name"]);
});
it("deduplicates repeated variables", () => {
expect(extractVariablesFromText("{{ x }} and {{ x }}")).toEqual(["x"]);
});
it("returns empty array when no variables exist", () => {
expect(extractVariablesFromText("Hello world")).toEqual([]);
});
it("returns empty array for empty string", () => {
expect(extractVariablesFromText("")).toEqual([]);
});
it("handles variable with no spaces inside braces", () => {
expect(extractVariablesFromText("{{name}}")).toEqual(["name"]);
});
it("handles variable with extra whitespace inside braces", () => {
expect(extractVariablesFromText("{{ name }}")).toEqual(["name"]);
});
it("handles underscored variable names", () => {
expect(extractVariablesFromText("{{ user_name }}")).toEqual(["user_name"]);
});
it("handles dot-notation variable names", () => {
expect(extractVariablesFromText("{{ user.name }}")).toEqual(["user.name"]);
});
it("handles variables with numbers in name", () => {
expect(extractVariablesFromText("{{ item1 }} {{ item2 }}")).toEqual(["item1", "item2"]);
});
it("handles variable starting with underscore", () => {
expect(extractVariablesFromText("{{ _private }}")).toEqual(["_private"]);
});
it("does not extract variables starting with a number", () => {
expect(extractVariablesFromText("{{ 1abc }}")).toEqual([]);
});
it("does not extract variables with special characters", () => {
expect(extractVariablesFromText("{{ na-me }}")).toEqual([]);
});
it("handles multiline text with variables", () => {
const text = `Line one {{ first }}
Line two {{ second }}
Line three`;
expect(extractVariablesFromText(text)).toEqual(["first", "second"]);
});
it("ignores jinja2 block tags", () => {
expect(extractVariablesFromText("{% if condition %}yes{% endif %}")).toEqual([]);
});
it("ignores jinja2 comments", () => {
expect(extractVariablesFromText("{# this is a comment #}")).toEqual([]);
});
it("extracts variables adjacent to jinja2 block tags", () => {
const text = "{% if show %}{{ name }}{% endif %}";
expect(extractVariablesFromText(text)).toEqual(["name"]);
});
it("handles triple braces (not valid jinja2) gracefully", () => {
// {{{ name }}} — the regex should still find "name" from the inner {{ }}
const result = extractVariablesFromText("{{{ name }}}");
expect(result).toEqual(["name"]);
});
it("handles variables embedded in longer text", () => {
const text = "Dear {{ title }} {{ last_name }}, your order #{{ order_id }} is ready.";
expect(extractVariablesFromText(text)).toEqual(["title", "last_name", "order_id"]);
});
// Regression: calling extractVariablesFromText consecutively should work
// (ensures regex lastIndex is properly reset)
it("works correctly when called multiple times in succession", () => {
expect(extractVariablesFromText("{{ a }}")).toEqual(["a"]);
expect(extractVariablesFromText("{{ b }}")).toEqual(["b"]);
expect(extractVariablesFromText("{{ a }} {{ c }}")).toEqual(["a", "c"]);
});
});
// =============================================================================
// extractVariablesFromMessages
// =============================================================================
describe("extractVariablesFromMessages", () => {
it("extracts variables from a system message", () => {
const messages = [Message.system("You are {{ role }}")];
expect(extractVariablesFromMessages(messages)).toEqual(["role"]);
});
it("extracts variables from a user message", () => {
const messages = [Message.request("Tell me about {{ topic }}")];
expect(extractVariablesFromMessages(messages)).toEqual(["topic"]);
});
it("extracts variables from an assistant message", () => {
const messages = [Message.response("Hello {{ name }}")];
expect(extractVariablesFromMessages(messages)).toEqual([]);
});
it("extracts variables across multiple messages", () => {
const messages = [
Message.system("You are {{ role }}"),
Message.request("Tell me about {{ topic }}"),
Message.response("The {{ topic }} is interesting"),
];
expect(extractVariablesFromMessages(messages)).toEqual(["role", "topic"]);
});
it("deduplicates across messages", () => {
const messages = [Message.system("{{ name }}"), Message.request("{{ name }}")];
expect(extractVariablesFromMessages(messages)).toEqual(["name"]);
});
it("returns empty array when no messages have variables", () => {
const messages = [Message.system("You are a helpful assistant"), Message.request("Hello")];
expect(extractVariablesFromMessages(messages)).toEqual([]);
});
it("returns empty array for empty messages array", () => {
expect(extractVariablesFromMessages([])).toEqual([]);
});
it("handles messages with empty content", () => {
const messages = [Message.system("")];
expect(extractVariablesFromMessages(messages)).toEqual([]);
});
it("handles error messages gracefully", () => {
const messages = [Message.error("Something went wrong")];
expect(extractVariablesFromMessages(messages)).toEqual([]);
});
});
// =============================================================================
// replaceVariablesInText
// =============================================================================
describe("replaceVariablesInText", () => {
it("replaces a single variable", () => {
expect(replaceVariablesInText("Hello {{ name }}", { name: "World" })).toBe("Hello World");
});
it("replaces multiple variables", () => {
const result = replaceVariablesInText("{{ greeting }}, {{ name }}!", {
greeting: "Hi",
name: "Alice",
});
expect(result).toBe("Hi, Alice!");
});
it("replaces all occurrences of the same variable", () => {
expect(replaceVariablesInText("{{ x }} and {{ x }}", { x: "yes" })).toBe("yes and yes");
});
it("preserves leading curly braces that are not part of variables", () => {
expect(replaceVariablesInText("{{{ x }} and {{ x }}", { x: "yes" })).toBe("{yes and yes");
});
it("preserves trailing curly braces that are not part of variables", () => {
expect(replaceVariablesInText("{{ x }} and {{ x }}}}", { x: "yes" })).toBe("yes and yes}}");
});
it("leaves variable untouched when not in map", () => {
expect(replaceVariablesInText("{{ unknown }}", {})).toBe("{{ unknown }}");
});
it("leaves variable untouched when value is empty string", () => {
expect(replaceVariablesInText("{{ name }}", { name: "" })).toBe("{{ name }}");
});
it("returns original text when no variables present", () => {
expect(replaceVariablesInText("Hello world", { name: "test" })).toBe("Hello world");
});
it("returns empty string for empty input", () => {
expect(replaceVariablesInText("", { name: "test" })).toBe("");
});
it("handles replacement value containing special regex characters", () => {
expect(replaceVariablesInText("{{ val }}", { val: "$100.00" })).toBe("$100.00");
});
it("handles replacement value containing curly braces", () => {
expect(replaceVariablesInText("{{ val }}", { val: "{{ nested }}" })).toBe("{{ nested }}");
});
it("handles variable with no spaces in braces", () => {
expect(replaceVariablesInText("{{name}}", { name: "Bob" })).toBe("Bob");
});
it("handles variable with extra whitespace in braces", () => {
expect(replaceVariablesInText("{{ name }}", { name: "Bob" })).toBe("Bob");
});
it("replaces only known variables and leaves others", () => {
const result = replaceVariablesInText("{{ known }} and {{ unknown }}", { known: "yes" });
expect(result).toBe("yes and {{ unknown }}");
});
it("handles multiline text replacement", () => {
const text = `Hello {{ name }},
Your order {{ order_id }} is ready.`;
const result = replaceVariablesInText(text, { name: "Alice", order_id: "12345" });
expect(result).toBe(`Hello Alice,
Your order 12345 is ready.`);
});
it("handles dot-notation variables", () => {
expect(replaceVariablesInText("{{ user.name }}", { "user.name": "Alice" })).toBe("Alice");
});
// Regression: consecutive calls should work (lastIndex reset)
it("works correctly when called multiple times in succession", () => {
expect(replaceVariablesInText("{{ a }}", { a: "1" })).toBe("1");
expect(replaceVariablesInText("{{ b }}", { b: "2" })).toBe("2");
});
});
// =============================================================================
// replaceVariablesInMessages
// =============================================================================
describe("replaceVariablesInMessages", () => {
it("replaces variables in message content", () => {
const messages = [Message.system("You are {{ role }}")];
const result = replaceVariablesInMessages(messages, { role: "a pirate" });
expect(result[0].content).toBe("You are a pirate");
});
it("does not mutate original messages", () => {
const messages = [Message.system("You are {{ role }}")];
replaceVariablesInMessages(messages, { role: "a pirate" });
expect(messages[0].content).toBe("You are {{ role }}");
});
it("returns original messages array when all variable values are empty", () => {
const messages = [Message.system("You are {{ role }}")];
const result = replaceVariablesInMessages(messages, { role: "" });
expect(result).toBe(messages); // same reference — fast path
});
it("returns original messages array when variables map is empty", () => {
const messages = [Message.system("You are {{ role }}")];
const result = replaceVariablesInMessages(messages, {});
expect(result).toBe(messages);
});
it("replaces variables across multiple messages", () => {
const messages = [Message.system("You are {{ role }}"), Message.request("Tell me about {{ topic }}")];
const result = replaceVariablesInMessages(messages, { role: "a teacher", topic: "math" });
expect(result[0].content).toBe("You are a teacher");
expect(result[1].content).toBe("Tell me about math");
});
it("preserves messages without variables unchanged", () => {
const messages = [Message.system("Hello"), Message.request("{{ name }}")];
const result = replaceVariablesInMessages(messages, { name: "Alice" });
expect(result[0].content).toBe("Hello");
expect(result[1].content).toBe("Alice");
});
it("preserves message count", () => {
const messages = [Message.system("{{ a }}"), Message.request("{{ b }}"), Message.response("{{ c }}")];
const result = replaceVariablesInMessages(messages, { a: "1", b: "2", c: "3" });
expect(result).toHaveLength(3);
});
it("handles empty messages array", () => {
const result = replaceVariablesInMessages([], { name: "Alice" });
expect(result).toEqual([]);
});
it("handles messages with empty content", () => {
const messages = [Message.system("")];
const result = replaceVariablesInMessages(messages, { name: "Alice" });
expect(result[0].content).toBe("");
});
});
// =============================================================================
// mergeVariables
// =============================================================================
describe("mergeVariables", () => {
it("creates entries for new variable names with empty values", () => {
expect(mergeVariables({}, ["name", "topic"])).toEqual({
name: "",
topic: "",
});
});
it("preserves existing values for variables that still exist", () => {
const current = { name: "Alice", topic: "math" };
const result = mergeVariables(current, ["name", "topic"]);
expect(result).toEqual({ name: "Alice", topic: "math" });
});
it("drops variables no longer in the new names list", () => {
const current = { name: "Alice", old_var: "value" };
const result = mergeVariables(current, ["name"]);
expect(result).toEqual({ name: "Alice" });
expect(result).not.toHaveProperty("old_var");
});
it("adds new variables while preserving existing ones", () => {
const current = { name: "Alice" };
const result = mergeVariables(current, ["name", "topic"]);
expect(result).toEqual({ name: "Alice", topic: "" });
});
it("handles empty current variables", () => {
expect(mergeVariables({}, ["a", "b"])).toEqual({ a: "", b: "" });
});
it("handles empty new names (returns empty map)", () => {
const current = { name: "Alice", topic: "math" };
expect(mergeVariables(current, [])).toEqual({});
});
it("handles both empty", () => {
expect(mergeVariables({}, [])).toEqual({});
});
it("does not mutate the original variables map", () => {
const current = { name: "Alice" };
mergeVariables(current, ["name", "topic"]);
expect(current).toEqual({ name: "Alice" });
});
it("preserves empty string values for existing variables", () => {
const current = { name: "" };
const result = mergeVariables(current, ["name"]);
expect(result).toEqual({ name: "" });
});
});

View File

@@ -0,0 +1,87 @@
import { JINJA_VAR_REGEX } from "./constant";
import type { Message } from "./message";
import { MessageRole } from "./types";
/** A map of variable name → user-supplied value */
export type VariableMap = Record<string, string>;
/**
* Extract all unique Jinja2 variable names from a single string.
*/
export function extractVariablesFromText(text: string): string[] {
const vars = new Set<string>();
let match: RegExpExecArray | null;
// Reset lastIndex to be safe
JINJA_VAR_REGEX.lastIndex = 0;
while ((match = JINJA_VAR_REGEX.exec(text)) !== null) {
vars.add(match[1]);
}
return Array.from(vars);
}
/**
* Extract all unique Jinja2 variable names from an array of Messages.
* Scans content of every message (system, user, assistant, tool).
*/
export function extractVariablesFromMessages(messages: Message[]): string[] {
const vars = new Set<string>();
for (const msg of messages) {
if (msg.role === MessageRole.ASSISTANT || msg.role === MessageRole.TOOL) continue;
const content = msg.content;
if (!content) continue;
for (const v of extractVariablesFromText(content)) {
vars.add(v);
}
}
return Array.from(vars);
}
/**
* Replace Jinja2 variables in a string with values from the provided map.
* Variables without a value in the map are left untouched.
*/
export function replaceVariablesInText(text: string, variables: VariableMap): string {
return text.replace(JINJA_VAR_REGEX, (fullMatch, varName: string) => {
if (varName in variables && variables[varName] !== "") {
return variables[varName];
}
return fullMatch;
});
}
/**
* Create clones of messages with all Jinja2 variables replaced.
* Original messages are NOT mutated.
*/
export function replaceVariablesInMessages(messages: Message[], variables: VariableMap): Message[] {
// Fast path: nothing to replace
const hasVars = Object.values(variables).some((v) => v !== "");
if (!hasVars) return messages;
return messages.map((msg) => {
const content = msg.content;
if (!content || !JINJA_VAR_REGEX.test(content)) {
// Reset lastIndex after test
JINJA_VAR_REGEX.lastIndex = 0;
return msg;
}
JINJA_VAR_REGEX.lastIndex = 0;
const clone = msg.clone();
clone.content = replaceVariablesInText(content, variables);
return clone;
});
}
/**
* Merge existing variable values with a new set of variable names.
* - Keeps values for variables that still exist
* - Adds empty values for new variables
* - Drops variables that no longer exist in messages
*/
export function mergeVariables(currentVars: VariableMap, newVarNames: string[]): VariableMap {
const merged: VariableMap = {};
for (const name of newVarNames) {
merged[name] = currentVars[name] ?? "";
}
return merged;
}

View File

@@ -0,0 +1,260 @@
import { KnownProvidersNames } from "@/lib/constants/logs";
import { isValidAliases, isValidVertexAuthCredentials } from "@/lib/utils/validation";
import { z } from "zod";
// Base schemas for reusable types
const ProxyTypeSchema = z.enum(["none", "http", "socks5", "environment"]);
const ProxyConfigSchema = z
.object({
type: ProxyTypeSchema,
url: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
})
.superRefine((v, ctx) => {
const needsUrl = v.type === "http" || v.type === "socks5";
if (needsUrl && !(v.url && v.url.trim())) {
ctx.addIssue({ code: "custom", path: ["url"], message: "Proxy URL is required for http/socks5" });
}
const user = v.username?.trim();
const pass = v.password?.trim();
if ((user && !pass) || (pass && !user)) {
ctx.addIssue({
code: "custom",
path: ["password"],
message: "Username and password must both be provided",
});
}
});
const NetworkConfigSchema = z
.object({
base_url: z.string().optional(),
extra_headers: z.record(z.string(), z.string()).optional(),
default_request_timeout_in_seconds: z.number().min(1, "Timeout must be greater than 0 seconds"),
max_retries: z.number().min(0, "Max retries cannot be negative"),
retry_backoff_initial: z.number(),
retry_backoff_max: z.number(),
})
.refine((v) => v.retry_backoff_initial <= v.retry_backoff_max, {
message: "Initial backoff must be <= max backoff",
path: ["retry_backoff_initial"],
});
const ConcurrencyAndBufferSizeSchema = z
.object({
concurrency: z.number().min(1, "Concurrency must be greater than 0"),
buffer_size: z.number().min(1, "Buffer size must be greater than 0"),
})
.refine((data) => data.concurrency <= data.buffer_size, {
message: "Concurrency must be less than or equal to buffer size",
path: ["concurrency"],
});
const AllowedRequestsSchema = z.object({
text_completion: z.boolean(),
chat_completion: z.boolean(),
chat_completion_stream: z.boolean(),
responses: z.boolean(),
responses_stream: z.boolean(),
embedding: z.boolean(),
speech: z.boolean(),
speech_stream: z.boolean(),
transcription: z.boolean(),
transcription_stream: z.boolean(),
});
// Key configuration schemas
const AzureKeyConfigSchema = z.object({
endpoint: z.string().min(1, "Endpoint is required for Azure keys"),
api_version: z.string().optional(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
tenant_id: z.string().optional(),
});
const VertexKeyConfigSchema = z.object({
project_id: z.string().min(1, "Project ID is required for Vertex AI keys"),
project_number: z.string().optional(),
region: z.string().min(1, "Region is required for Vertex AI keys"),
auth_credentials: z
.string()
.optional()
.refine((value) => !value || isValidVertexAuthCredentials(value), {
message: "Auth Credentials must be a valid JSON object or env.VAR format when provided",
}),
});
// S3 bucket configuration for Bedrock batch operations
const S3BucketConfigSchema = z.object({
bucket_name: z.string().min(1, "Bucket name is required"),
prefix: z.string().optional(),
is_default: z.boolean().optional(),
});
const BatchS3ConfigSchema = z.object({
buckets: z.array(S3BucketConfigSchema).optional(),
});
const BedrockKeyConfigSchema = z
.object({
access_key: z.string(),
secret_key: z.string(),
session_token: z.string().optional(),
region: z.string().min(1, "Region is required for Bedrock keys"),
role_arn: z.string().optional(),
external_id: z.string().optional(),
session_name: z.string().optional(),
arn: z.string().optional(),
batch_s3_config: BatchS3ConfigSchema.optional(),
})
.refine(
(data) => {
const accessKey = data.access_key?.trim() || "";
const secretKey = data.secret_key?.trim() || "";
const bothEmpty = accessKey === "" && secretKey === "";
const bothProvided = accessKey !== "" && secretKey !== "";
// Either both empty (IAM role auth) or both provided (explicit credentials)
if (!bothEmpty && !bothProvided) {
return false;
}
// Check for session token when using IAM role path (both keys empty)
const sessionToken = data.session_token?.trim() || "";
if (bothEmpty && sessionToken !== "") {
return false;
}
return true;
},
{
message: "For Bedrock: either provide both Access Key and Secret Key, or leave both empty for IAM role authentication",
path: ["access_key"],
},
);
const ReplicateKeyConfigSchema = z.object({
use_deployments_endpoint: z.boolean(),
});
const KeySchema = z.object({
id: z.string(),
name: z.string().min(1, "Name is required for the key"),
value: z.string(),
models: z.array(z.string()),
weight: z.number().min(0.1, "Key weights must be between 0.1 and 1").max(1, "Key weights must be between 0.1 and 1"),
aliases: z
.union([z.record(z.string(), z.string()), z.string()])
.optional()
.refine((value) => !value || isValidAliases(value), { message: "Aliases must be a valid JSON object" }),
azure_key_config: AzureKeyConfigSchema.optional(),
vertex_key_config: VertexKeyConfigSchema.optional(),
bedrock_key_config: BedrockKeyConfigSchema.optional(),
replicate_key_config: ReplicateKeyConfigSchema.optional(),
use_for_batch_api: z.boolean().optional(),
});
// Main provider form schema
export const ProviderFormSchema = z
.object({
selectedProvider: z.string().min(1, "Please select a provider"),
customProviderName: z.string().optional(),
baseProviderType: z.enum([...KnownProvidersNames, ""]).optional(),
keys: z.array(KeySchema),
networkConfig: NetworkConfigSchema.optional(),
performanceConfig: ConcurrencyAndBufferSizeSchema.optional(),
proxyConfig: ProxyConfigSchema.optional(),
sendBackRawResponse: z.boolean().default(false),
allowedRequests: AllowedRequestsSchema.optional(),
isDirty: z.boolean(),
})
.superRefine((data, ctx) => {
// Custom provider validation
const isCustomProvider =
data.selectedProvider === "custom" ||
!!data.customProviderName ||
!!data.baseProviderType ||
!KnownProvidersNames.includes(data.selectedProvider as (typeof KnownProvidersNames)[number]);
if (isCustomProvider) {
if (!data.customProviderName?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Custom provider name is required",
path: ["customProviderName"],
});
}
if (!/^[a-z0-9_-]+$/.test(data.customProviderName?.trim() || "")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Custom provider name must be lowercase alphanumeric and may include '-' or '_' (no spaces)",
path: ["customProviderName"],
});
}
if (!data.baseProviderType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Base provider type is required for custom providers",
path: ["baseProviderType"],
});
}
if (KnownProvidersNames.includes(data.customProviderName?.trim() as (typeof KnownProvidersNames)[number])) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Custom provider name cannot be the same as a standard provider name",
path: ["customProviderName"],
});
}
}
// Base URL validation for specific providers
const baseURLRequired = isCustomProvider;
if (baseURLRequired) {
if (!data.networkConfig?.base_url) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Base URL is required for this provider",
path: ["networkConfig", "base_url"],
});
}
if (data.networkConfig?.base_url && !/^https?:\/\/.+/.test(data.networkConfig.base_url)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Base URL must start with http:// or https://",
path: ["networkConfig", "base_url"],
});
}
}
// Keys validation
const keysRequired = data.selectedProvider === "custom" || !["ollama", "sgl"].includes(data.selectedProvider);
if (keysRequired) {
if (data.keys.length < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one API key is required",
path: ["keys"],
});
}
// Validate individual key values based on provider type
const effectiveProviderType = data.baseProviderType || data.selectedProvider;
data.keys.forEach((key, index) => {
if (effectiveProviderType !== "vertex" && effectiveProviderType !== "bedrock" && !key.value.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "API key value cannot be empty",
path: ["keys", index, "value"],
});
}
});
}
});
export type ProviderFormData = z.infer<typeof ProviderFormSchema>;

View File

@@ -0,0 +1,198 @@
import { IS_ENTERPRISE } from "@/lib/constants/config";
import { BifrostErrorResponse } from "@/lib/types/config";
import { getApiBaseUrl } from "@/lib/utils/port";
import { createBaseQueryWithRefresh } from "@enterprise/lib/store/utils/baseQueryWithRefresh";
import { clearOAuthStorage } from "@enterprise/lib/store/utils/tokenManager";
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
// Auth tokens are now stored in HTTP-only cookies (set by server)
// No client-side token needed — handled by credentials: "include"
export const getTokenFromStorage = (): Promise<string | null> => {
return Promise.resolve(null);
};
// Helper function to set auth token
// Non-enterprise: no-op — auth relies on HTTPOnly cookies set by the server
// Enterprise: handled separately via tokenManager
export const setAuthToken = (_token: string | null) => {
// Non-enterprise auth is cookie-based; no client-side token storage needed.
// Enterprise token management is handled by the tokenManager module.
};
// Helper function to clear all auth-related storage
export const clearAuthStorage = () => {
if (typeof window === "undefined") {
return;
}
try {
// Clear traditional auth token
localStorage.removeItem("bifrost-auth-token");
// Clear enterprise OAuth tokens using tokenManager
if (IS_ENTERPRISE) {
clearOAuthStorage();
}
} catch (error) {
console.error("Error clearing auth storage:", error);
}
};
// Define the base query with authentication headers
const baseQuery = fetchBaseQuery({
baseUrl: getApiBaseUrl(),
credentials: "include",
prepareHeaders: async (headers) => {
headers.set("Content-Type", "application/json");
// Automatically include token from localStorage in Authorization header
const token = await getTokenFromStorage();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
return headers;
},
});
// Wrap base query with enterprise refresh logic (or passthrough for non-enterprise)
const baseQueryWithRefresh = createBaseQueryWithRefresh(baseQuery);
// Enhanced base query with error handling
const baseQueryWithErrorHandling: typeof baseQueryWithRefresh = async (args: any, api: any, extraOptions: any) => {
// First apply refresh logic (enterprise-specific, handles 401)
const result = await baseQueryWithRefresh(args, api, extraOptions);
// Then handle other error types
if (result.error) {
const error = result.error as any;
// Handle 401 for non-enterprise (no refresh available)
if (error?.status === 401 && !IS_ENTERPRISE) {
clearAuthStorage();
if (typeof window !== "undefined" && !window.location.pathname.includes("/login")) {
window.location.href = "/login";
}
return result;
}
// Handle specific error types
if (error?.status === "FETCH_ERROR") {
// Network error
return {
...result,
error: {
...error,
data: {
error: {
message: "Network error: Unable to connect to the server",
},
},
},
};
}
// Handle other errors with proper BifrostErrorResponse format
if (error?.data) {
const errorData = error.data as BifrostErrorResponse;
if (errorData.error?.message) {
return result;
}
}
// Fallback error message
return {
...result,
error: {
...error,
data: {
error: {
message: "An unexpected error occurred",
},
},
},
};
}
return result;
};
// Create the base API
export const baseApi = createApi({
reducerPath: "api",
baseQuery: baseQueryWithErrorHandling,
tagTypes: [
"Logs",
"MCPLogs",
"Providers",
"MCPClients",
"Config",
"CacheConfig",
"VirtualKeys",
"Teams",
"Customers",
"Budgets",
"RateLimits",
"UsageStats",
"DebugStats",
"HealthCheck",
"DBKeys",
"ProviderKeys",
"Models",
"BaseModels",
"ModelConfigs",
"ProviderGovernance",
"Plugins",
"SCIMProviders",
"User",
"Guardrails",
"ClusterNodes",
"Users",
"GuardrailRules",
"Roles",
"Resources",
"Operations",
"Permissions",
"APIKeys",
"OAuth2Config",
"RoutingRules",
"PricingOverrides",
"MCPToolGroups",
"AuditLogs",
"UserGovernance",
"LargePayloadConfig",
"Folders",
"Prompts",
"Versions",
"Sessions",
"AccessProfiles",
"BusinessUnits",
"PromptDeployments",
],
endpoints: () => ({}),
});
// Helper function to extract error message from RTK Query error
export const getErrorMessage = (error: unknown): string => {
if (error === undefined || error === null) {
return "An unexpected error occurred";
}
if (error instanceof Error) {
return error.message;
}
if (
typeof error === "object" &&
error &&
"data" in error &&
error.data &&
typeof error.data === "object" &&
"error" in error.data &&
error.data.error &&
typeof error.data.error === "object" &&
"message" in error.data.error &&
typeof error.data.error.message === "string"
) {
return error.data.error.message.charAt(0).toUpperCase() + error.data.error.message.slice(1);
}
if (typeof error === "object" && error && "message" in error && typeof error.message === "string") {
return error.message;
}
return "An unexpected error occurred";
};

View File

@@ -0,0 +1,110 @@
import { IS_ENTERPRISE } from "@/lib/constants/config";
import { BifrostConfig, GlobalProxyConfig, LatestReleaseResponse } from "@/lib/types/config";
import axios from "axios";
import { baseApi } from "./baseApi";
export const configApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
// Get core configuration
getCoreConfig: builder.query<BifrostConfig, { fromDB?: boolean }>({
query: ({ fromDB = false } = {}) => ({
url: "/config",
params: { from_db: fromDB },
}),
providesTags: ["Config"],
}),
// Get version information
getVersion: builder.query<string, void>({
query: () => ({
url: "/version",
}),
}),
// Get latest release from public site
getLatestRelease: builder.query<LatestReleaseResponse, void>({
queryFn: async (_arg, { signal }) => {
try {
const response = await axios.get("https://getbifrost.ai/latest-release", {
timeout: 3000, // 3 second timeout
signal,
headers: {
Accept: "application/json",
},
maxRedirects: 5,
validateStatus: (status) => status >= 200 && status < 300,
});
const data = response.data as any;
const normalized: LatestReleaseResponse = {
name: data.name ?? data.tag ?? data.version ?? "",
changelogUrl: data.changelogUrl ?? data.changelog_url ?? "",
};
return { data: normalized };
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") {
console.warn("Latest release fetch timed out after 3s");
return {
error: {
status: "TIMEOUT_ERROR",
error: "Request timeout",
data: { error: { message: "Request timeout" } },
},
};
}
console.error("Latest release fetch error:", error.message);
} else {
console.error("Latest release fetch error:", error);
}
return {
error: {
status: "FETCH_ERROR",
error: String(error),
data: { error: { message: "Network error" } },
},
};
}
},
keepUnusedDataFor: 300, // Cache for 5 minutes (seconds)
}),
// Update core configuration
updateCoreConfig: builder.mutation<null, BifrostConfig>({
query: (data) => ({
url: "/config",
method: "PUT",
body: IS_ENTERPRISE ? { ...data, auth_config: undefined } : data,
}),
invalidatesTags: ["Config"],
}),
// Update proxy configuration
updateProxyConfig: builder.mutation<null, GlobalProxyConfig>({
query: (data) => ({
url: "/proxy-config",
method: "PUT",
body: data,
}),
invalidatesTags: ["Config"],
}),
// Force a pricing sync immediately
forcePricingSync: builder.mutation<null, void>({
query: () => ({
url: "/pricing/force-sync",
method: "POST",
}),
invalidatesTags: ["Config"],
}),
}),
});
export const {
useGetVersionQuery,
useGetCoreConfigQuery,
useUpdateCoreConfigMutation,
useUpdateProxyConfigMutation,
useForcePricingSyncMutation,
useLazyGetCoreConfigQuery,
useGetLatestReleaseQuery,
useLazyGetLatestReleaseQuery,
} = configApi;

109
ui/lib/store/apis/devApi.ts Normal file
View File

@@ -0,0 +1,109 @@
import { baseApi } from './baseApi'
// Memory statistics at a point in time
export interface MemoryStats {
alloc: number
total_alloc: number
heap_inuse: number
heap_objects: number
sys: number
}
// CPU statistics
export interface CPUStats {
usage_percent: number
user_time: number
system_time: number
}
// Runtime statistics
export interface RuntimeStats {
num_goroutine: number
num_gc: number
gc_pause_ns: number
num_cpu: number
gomaxprocs: number
}
// Allocation info for top allocations
export interface AllocationInfo {
function: string
file: string
line: number
bytes: number
count: number
stack: string[]
}
// Single point in the metrics history
export interface HistoryPoint {
timestamp: string
alloc: number
heap_inuse: number
goroutines: number
gc_pause_ns: number
cpu_percent: number
}
// Complete pprof data response
export interface PprofData {
timestamp: string
memory: MemoryStats
cpu: CPUStats
runtime: RuntimeStats
top_allocations: AllocationInfo[]
inuse_allocations: AllocationInfo[]
history: HistoryPoint[]
}
// Goroutine group representing goroutines with same stack trace
export interface GoroutineGroup {
count: number
state: string
wait_reason?: string
wait_minutes?: number
top_func: string
stack: string[]
category: 'background' | 'per-request' | 'unknown'
}
// Goroutine health summary
export interface GoroutineSummary {
background: number
per_request: number
long_waiting: number
potentially_stuck: number
}
// Goroutine profile response
export interface GoroutineProfile {
timestamp: string
total_goroutines: number
groups: GoroutineGroup[]
summary: GoroutineSummary
}
export const devApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
// Get dev pprof data - polls every 10 seconds
getDevPprof: builder.query<PprofData, void>({
query: () => ({
url: '/dev/pprof',
}),
}),
// Get goroutine profile for leak detection
getDevGoroutines: builder.query<GoroutineProfile, void>({
query: () => ({
url: '/dev/pprof/goroutines',
}),
}),
}),
})
export const {
useGetDevPprofQuery,
useLazyGetDevPprofQuery,
useGetDevGoroutinesQuery,
useLazyGetDevGoroutinesQuery,
} = devApi

View File

@@ -0,0 +1,858 @@
import {
Budget,
CreateCustomerRequest,
CreateModelConfigRequest,
CreatePricingOverrideRequest,
UpdatePricingOverrideRequest,
CreateTeamRequest,
CreateVirtualKeyRequest,
Customer,
DebugStatsResponse,
GetBudgetsResponse,
GetCustomersParams,
GetCustomersResponse,
GetModelConfigsParams,
GetModelConfigsResponse,
GetPricingOverridesResponse,
GetProviderGovernanceResponse,
GetRateLimitsResponse,
GetTeamsParams,
GetTeamsResponse,
GetUsageStatsResponse,
GetVirtualKeysParams,
GetVirtualKeysResponse,
HealthCheckResponse,
ModelConfig,
ProviderGovernance,
PricingOverride,
RateLimit,
ResetUsageRequest,
Team,
UpdateBudgetRequest,
UpdateCustomerRequest,
UpdateModelConfigRequest,
UpdateProviderGovernanceRequest,
UpdateRateLimitRequest,
UpdateTeamRequest,
UpdateVirtualKeyRequest,
VirtualKey,
} from "@/lib/types/governance";
import { baseApi } from "./baseApi";
type PricingOverrideQueryArgs = {
scopeKind?: string;
virtualKeyID?: string;
providerID?: string;
providerKeyID?: string;
limit?: number;
offset?: number;
search?: string;
};
export const governanceApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
// Virtual Keys
getVirtualKeys: builder.query<GetVirtualKeysResponse, GetVirtualKeysParams | void>({
query: (params) => ({
url: "/governance/virtual-keys",
params: {
...(params?.limit && { limit: params.limit }),
...(params?.offset !== undefined && { offset: params.offset }),
...(params?.search && { search: params.search }),
...(params?.customer_id && { customer_id: params.customer_id }),
...(params?.team_id && { team_id: params.team_id }),
...(params?.exclude_access_profile_managed_virtual === true && {
exclude_access_profile_managed_virtual: "true",
}),
...(params?.sort_by && { sort_by: params.sort_by }),
...(params?.order && { order: params.order }),
...(params?.export && { export: "true" }),
},
}),
providesTags: ["VirtualKeys"],
}),
getVirtualKey: builder.query<{ virtual_key: VirtualKey }, string>({
query: (vkId) => `/governance/virtual-keys/${vkId}`,
providesTags: (result, error, vkId) => [{ type: "VirtualKeys", id: vkId }],
}),
createVirtualKey: builder.mutation<{ message: string; virtual_key: VirtualKey }, CreateVirtualKeyRequest>({
query: (data) => ({
url: "/governance/virtual-keys",
method: "POST",
body: data,
}),
invalidatesTags: ["VirtualKeys"],
}),
updateVirtualKey: builder.mutation<{ message: string; virtual_key: VirtualKey }, { vkId: string; data: UpdateVirtualKeyRequest }>({
query: ({ vkId, data }) => ({
url: `/governance/virtual-keys/${vkId}`,
method: "PUT",
body: data,
}),
invalidatesTags: ["VirtualKeys"],
}),
deleteVirtualKey: builder.mutation<{ message: string }, string>({
query: (vkId) => ({
url: `/governance/virtual-keys/${vkId}`,
method: "DELETE",
}),
invalidatesTags: ["VirtualKeys"],
}),
// Teams
getTeams: builder.query<GetTeamsResponse, GetTeamsParams | void>({
query: (params) => ({
url: "/governance/teams",
params: {
...(params?.limit && { limit: params.limit }),
...(params?.offset !== undefined && { offset: params.offset }),
...(params?.search && { search: params.search }),
...(params?.customer_id && { customer_id: params.customer_id }),
},
}),
providesTags: ["Teams"],
}),
getTeam: builder.query<{ team: Team }, string>({
query: (teamId) => `/governance/teams/${teamId}`,
providesTags: (result, error, teamId) => [{ type: "Teams", id: teamId }],
}),
createTeam: builder.mutation<{ message: string; team: Team }, CreateTeamRequest>({
query: (data) => ({
url: "/governance/teams",
method: "POST",
body: data,
}),
async onQueryStarted(arg, { dispatch, getState, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getTeams" || entry?.status !== "fulfilled") continue;
const search = entry.originalArgs?.search as string | undefined;
if (search && !data.team.name.toLowerCase().includes(search.toLowerCase())) continue;
dispatch(
governanceApi.util.updateQueryData("getTeams", entry.originalArgs, (draft) => {
if (!draft.teams) draft.teams = [];
draft.teams.unshift(data.team);
draft.count = (draft.count || 0) + 1;
draft.total_count = (draft.total_count || 0) + 1;
}),
);
}
} catch {
// Mutation failed
}
},
}),
updateTeam: builder.mutation<{ message: string; team: Team }, { teamId: string; data: UpdateTeamRequest }>({
query: ({ teamId, data }) => ({
url: `/governance/teams/${teamId}`,
method: "PUT",
body: data,
}),
async onQueryStarted({ teamId }, { dispatch, getState, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getTeams" || entry?.status !== "fulfilled") continue;
dispatch(
governanceApi.util.updateQueryData("getTeams", entry.originalArgs, (draft) => {
if (!draft.teams) return;
const index = draft.teams.findIndex((t) => t.id === teamId);
if (index !== -1) {
draft.teams[index] = data.team;
}
}),
);
}
dispatch(
governanceApi.util.updateQueryData("getTeam", teamId, (draft) => {
draft.team = data.team;
}),
);
} catch {
// Mutation failed
}
},
}),
deleteTeam: builder.mutation<{ message: string }, string>({
query: (teamId) => ({
url: `/governance/teams/${teamId}`,
method: "DELETE",
}),
async onQueryStarted(teamId, { dispatch, getState, queryFulfilled }) {
try {
await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getTeams" || entry?.status !== "fulfilled") continue;
dispatch(
governanceApi.util.updateQueryData("getTeams", entry.originalArgs, (draft) => {
if (!draft.teams) return;
const before = draft.teams.length;
draft.teams = draft.teams.filter((t) => t.id !== teamId);
if (draft.teams.length < before) {
draft.count = Math.max(0, (draft.count || 0) - 1);
draft.total_count = Math.max(0, (draft.total_count || 0) - 1);
}
}),
);
}
} catch {
// Mutation failed
}
},
}),
// Customers
getCustomers: builder.query<GetCustomersResponse, GetCustomersParams | void>({
query: (params) => ({
url: "/governance/customers",
params: {
...(params?.limit && { limit: params.limit }),
...(params?.offset !== undefined && { offset: params.offset }),
...(params?.search && { search: params.search }),
},
}),
providesTags: ["Customers"],
}),
getCustomer: builder.query<{ customer: Customer }, string>({
query: (customerId) => `/governance/customers/${customerId}`,
providesTags: (result, error, customerId) => [{ type: "Customers", id: customerId }],
}),
createCustomer: builder.mutation<{ message: string; customer: Customer }, CreateCustomerRequest>({
query: (data) => ({
url: "/governance/customers",
method: "POST",
body: data,
}),
async onQueryStarted(arg, { dispatch, getState, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getCustomers" || entry?.status !== "fulfilled") continue;
const search = entry.originalArgs?.search as string | undefined;
if (search && !data.customer.name.toLowerCase().includes(search.toLowerCase())) continue;
dispatch(
governanceApi.util.updateQueryData("getCustomers", entry.originalArgs, (draft) => {
if (!draft.customers) draft.customers = [];
draft.customers.unshift(data.customer);
draft.count = (draft.count || 0) + 1;
draft.total_count = (draft.total_count || 0) + 1;
}),
);
}
} catch {
// Mutation failed
}
},
}),
updateCustomer: builder.mutation<{ message: string; customer: Customer }, { customerId: string; data: UpdateCustomerRequest }>({
query: ({ customerId, data }) => ({
url: `/governance/customers/${customerId}`,
method: "PUT",
body: data,
}),
async onQueryStarted({ customerId }, { dispatch, getState, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getCustomers" || entry?.status !== "fulfilled") continue;
dispatch(
governanceApi.util.updateQueryData("getCustomers", entry.originalArgs, (draft) => {
if (!draft.customers) return;
const index = draft.customers.findIndex((c) => c.id === customerId);
if (index !== -1) {
draft.customers[index] = data.customer;
}
}),
);
}
dispatch(
governanceApi.util.updateQueryData("getCustomer", customerId, (draft) => {
draft.customer = data.customer;
}),
);
} catch {
// Mutation failed
}
},
}),
deleteCustomer: builder.mutation<{ message: string }, string>({
query: (customerId) => ({
url: `/governance/customers/${customerId}`,
method: "DELETE",
}),
async onQueryStarted(customerId, { dispatch, getState, queryFulfilled }) {
try {
await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getCustomers" || entry?.status !== "fulfilled") continue;
dispatch(
governanceApi.util.updateQueryData("getCustomers", entry.originalArgs, (draft) => {
if (!draft.customers) return;
const before = draft.customers.length;
draft.customers = draft.customers.filter((c) => c.id !== customerId);
if (draft.customers.length < before) {
draft.count = Math.max(0, (draft.count || 0) - 1);
draft.total_count = Math.max(0, (draft.total_count || 0) - 1);
}
}),
);
}
} catch {
// Mutation failed
}
},
}),
// Budgets
getBudgets: builder.query<GetBudgetsResponse, void>({
query: () => "/governance/budgets",
providesTags: ["Budgets"],
}),
getBudget: builder.query<{ budget: Budget }, string>({
query: (budgetId) => `/governance/budgets/${budgetId}`,
providesTags: (result, error, budgetId) => [{ type: "Budgets", id: budgetId }],
}),
updateBudget: builder.mutation<{ message: string; budget: Budget }, { budgetId: string; data: UpdateBudgetRequest }>({
query: ({ budgetId, data }) => ({
url: `/governance/budgets/${budgetId}`,
method: "PUT",
body: data,
}),
async onQueryStarted({ budgetId }, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
dispatch(
governanceApi.util.updateQueryData("getBudgets", undefined, (draft) => {
if (!draft.budgets) return;
const index = draft.budgets.findIndex((b) => b.id === budgetId);
if (index !== -1) {
draft.budgets[index] = data.budget;
}
}),
);
dispatch(
governanceApi.util.updateQueryData("getBudget", budgetId, (draft) => {
draft.budget = data.budget;
}),
);
} catch {
// Mutation failed
}
},
}),
deleteBudget: builder.mutation<{ message: string }, string>({
query: (budgetId) => ({
url: `/governance/budgets/${budgetId}`,
method: "DELETE",
}),
async onQueryStarted(budgetId, { dispatch, queryFulfilled }) {
try {
await queryFulfilled;
dispatch(
governanceApi.util.updateQueryData("getBudgets", undefined, (draft) => {
if (!draft.budgets) return;
draft.budgets = draft.budgets.filter((b) => b.id !== budgetId);
draft.count = Math.max(0, (draft.count || 0) - 1);
}),
);
} catch {
// Mutation failed
}
},
}),
// Rate Limits
getRateLimits: builder.query<GetRateLimitsResponse, void>({
query: () => "/governance/rate-limits",
providesTags: ["RateLimits"],
}),
getRateLimit: builder.query<{ rate_limit: RateLimit }, string>({
query: (rateLimitId) => `/governance/rate-limits/${rateLimitId}`,
providesTags: (result, error, rateLimitId) => [{ type: "RateLimits", id: rateLimitId }],
}),
updateRateLimit: builder.mutation<{ message: string; rate_limit: RateLimit }, { rateLimitId: string; data: UpdateRateLimitRequest }>({
query: ({ rateLimitId, data }) => ({
url: `/governance/rate-limits/${rateLimitId}`,
method: "PUT",
body: data,
}),
async onQueryStarted({ rateLimitId }, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
dispatch(
governanceApi.util.updateQueryData("getRateLimits", undefined, (draft) => {
if (!draft.rate_limits) return;
const index = draft.rate_limits.findIndex((r) => r.id === rateLimitId);
if (index !== -1) {
draft.rate_limits[index] = data.rate_limit;
}
}),
);
dispatch(
governanceApi.util.updateQueryData("getRateLimit", rateLimitId, (draft) => {
draft.rate_limit = data.rate_limit;
}),
);
} catch {
// Mutation failed
}
},
}),
deleteRateLimit: builder.mutation<{ message: string }, string>({
query: (rateLimitId) => ({
url: `/governance/rate-limits/${rateLimitId}`,
method: "DELETE",
}),
async onQueryStarted(rateLimitId, { dispatch, queryFulfilled }) {
try {
await queryFulfilled;
dispatch(
governanceApi.util.updateQueryData("getRateLimits", undefined, (draft) => {
if (!draft.rate_limits) return;
draft.rate_limits = draft.rate_limits.filter((r) => r.id !== rateLimitId);
draft.count = Math.max(0, (draft.count || 0) - 1);
}),
);
} catch {
// Mutation failed
}
},
}),
// Usage Stats
getUsageStats: builder.query<GetUsageStatsResponse, { virtualKeyId?: string }>({
query: ({ virtualKeyId } = {}) => ({
url: "/governance/usage-stats",
params: virtualKeyId ? { virtual_key_id: virtualKeyId } : {},
}),
providesTags: ["UsageStats"],
}),
resetUsage: builder.mutation<{ message: string }, ResetUsageRequest>({
query: (data) => ({
url: "/governance/usage-reset",
method: "POST",
body: data,
}),
invalidatesTags: ["UsageStats"],
}),
// Debug endpoints
getGovernanceDebugStats: builder.query<DebugStatsResponse, void>({
query: () => "/governance/debug/stats",
providesTags: ["DebugStats"],
}),
getGovernanceHealth: builder.query<HealthCheckResponse, void>({
query: () => "/governance/debug/health",
providesTags: ["HealthCheck"],
}),
// Model Configs
getModelConfigs: builder.query<GetModelConfigsResponse, GetModelConfigsParams | void>({
query: (params) => ({
url: "/governance/model-configs",
params: {
...(params?.limit && { limit: params.limit }),
...(params?.offset !== undefined && { offset: params.offset }),
...(params?.search && { search: params.search }),
},
}),
providesTags: ["ModelConfigs"],
}),
getModelConfig: builder.query<{ model_config: ModelConfig }, string>({
query: (id) => `/governance/model-configs/${id}`,
providesTags: (result, error, id) => [{ type: "ModelConfigs", id }],
}),
createModelConfig: builder.mutation<{ message: string; model_config: ModelConfig }, CreateModelConfigRequest>({
query: (data) => ({
url: "/governance/model-configs",
method: "POST",
body: data,
}),
async onQueryStarted(arg, { dispatch, getState, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getModelConfigs" || entry?.status !== "fulfilled") continue;
const search = entry.originalArgs?.search as string | undefined;
if (search && !data.model_config.model_name.toLowerCase().includes(search.toLowerCase())) continue;
dispatch(
governanceApi.util.updateQueryData("getModelConfigs", entry.originalArgs, (draft) => {
if (!draft.model_configs) draft.model_configs = [];
draft.model_configs.unshift(data.model_config);
draft.count = (draft.count || 0) + 1;
draft.total_count = (draft.total_count || 0) + 1;
}),
);
}
} catch {
// Mutation failed, do nothing - error handling bubbled up
}
},
}),
updateModelConfig: builder.mutation<{ message: string; model_config: ModelConfig }, { id: string; data: UpdateModelConfigRequest }>({
query: ({ id, data }) => ({
url: `/governance/model-configs/${id}`,
method: "PUT",
body: data,
}),
async onQueryStarted({ id }, { dispatch, getState, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getModelConfigs" || entry?.status !== "fulfilled") continue;
dispatch(
governanceApi.util.updateQueryData("getModelConfigs", entry.originalArgs, (draft) => {
if (!draft.model_configs) return;
const index = draft.model_configs.findIndex((mc) => mc.id === id);
if (index !== -1) {
draft.model_configs[index] = data.model_config;
}
}),
);
}
dispatch(
governanceApi.util.updateQueryData("getModelConfig", id, (draft) => {
draft.model_config = data.model_config;
}),
);
} catch {
// Mutation failed
}
},
}),
deleteModelConfig: builder.mutation<{ message: string }, string>({
query: (id) => ({
url: `/governance/model-configs/${id}`,
method: "DELETE",
}),
async onQueryStarted(id, { dispatch, getState, queryFulfilled }) {
try {
await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getModelConfigs" || entry?.status !== "fulfilled") continue;
dispatch(
governanceApi.util.updateQueryData("getModelConfigs", entry.originalArgs, (draft) => {
if (!draft.model_configs) return;
const before = draft.model_configs.length;
draft.model_configs = draft.model_configs.filter((mc) => mc.id !== id);
if (draft.model_configs.length < before) {
draft.count = Math.max(0, (draft.count || 0) - 1);
draft.total_count = Math.max(0, (draft.total_count || 0) - 1);
}
}),
);
}
} catch {
// Mutation failed
}
},
}),
getPricingOverrides: builder.query<GetPricingOverridesResponse, PricingOverrideQueryArgs | void>({
query: (params) => ({
url: "/governance/pricing-overrides",
params: {
scope_kind: params?.scopeKind,
virtual_key_id: params?.virtualKeyID,
provider_id: params?.providerID,
provider_key_id: params?.providerKeyID,
...(params?.limit !== undefined && { limit: params.limit }),
...(params?.offset !== undefined && { offset: params.offset }),
...(params?.search && { search: params.search }),
},
}),
providesTags: ["PricingOverrides"],
}),
createPricingOverride: builder.mutation<{ message: string; pricing_override: PricingOverride }, CreatePricingOverrideRequest>({
query: (data) => ({
url: "/governance/pricing-overrides",
method: "POST",
body: data,
}),
async onQueryStarted(_arg, { dispatch, getState, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
const created = data.pricing_override;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getPricingOverrides" || entry?.status !== "fulfilled") continue;
const args: PricingOverrideQueryArgs = entry.originalArgs ?? {};
const matchesQuery =
(!args.scopeKind || args.scopeKind === created.scope_kind) &&
(!args.virtualKeyID || args.virtualKeyID === created.virtual_key_id) &&
(!args.providerID || args.providerID === created.provider_id) &&
(!args.providerKeyID || args.providerKeyID === created.provider_key_id) &&
(!args.search || created.name?.toLowerCase().includes(args.search.toLowerCase()));
if (!matchesQuery) continue;
dispatch(
governanceApi.util.updateQueryData("getPricingOverrides", entry.originalArgs, (draft) => {
if (!draft.pricing_overrides) draft.pricing_overrides = [];
if (!args.offset || args.offset === 0) {
draft.pricing_overrides.unshift(created);
draft.count = (draft.count || 0) + 1;
draft.total_count = (draft.total_count || 0) + 1;
} else {
draft.total_count = (draft.total_count || 0) + 1;
}
}),
);
}
} catch {
// Mutation failed
}
},
}),
updatePricingOverride: builder.mutation<
{ message: string; pricing_override: PricingOverride },
{ id: string; data: UpdatePricingOverrideRequest }
>({
query: ({ id, data }) => ({
url: `/governance/pricing-overrides/${id}`,
method: "PUT",
body: data,
}),
async onQueryStarted({ id }, { dispatch, getState, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
const updated = data.pricing_override;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getPricingOverrides" || entry?.status !== "fulfilled") continue;
const args: PricingOverrideQueryArgs = entry.originalArgs ?? {};
const matchesQuery =
(!args.scopeKind || args.scopeKind === updated.scope_kind) &&
(!args.virtualKeyID || args.virtualKeyID === updated.virtual_key_id) &&
(!args.providerID || args.providerID === updated.provider_id) &&
(!args.providerKeyID || args.providerKeyID === updated.provider_key_id);
dispatch(
governanceApi.util.updateQueryData("getPricingOverrides", entry.originalArgs, (draft) => {
if (!draft.pricing_overrides) return;
const index = draft.pricing_overrides.findIndex((o) => o.id === id);
if (index === -1) return;
if (matchesQuery) {
draft.pricing_overrides[index] = updated;
} else {
// Override no longer belongs in this filtered list
draft.pricing_overrides.splice(index, 1);
draft.count = Math.max(0, (draft.count || 0) - 1);
draft.total_count = Math.max(0, (draft.total_count || 0) - 1);
}
}),
);
}
} catch {
// Mutation failed
}
},
}),
deletePricingOverride: builder.mutation<{ message: string }, string>({
query: (id) => ({
url: `/governance/pricing-overrides/${id}`,
method: "DELETE",
}),
async onQueryStarted(id, { dispatch, getState, queryFulfilled }) {
try {
await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getPricingOverrides" || entry?.status !== "fulfilled") continue;
dispatch(
governanceApi.util.updateQueryData("getPricingOverrides", entry.originalArgs, (draft) => {
if (!draft.pricing_overrides) return;
const before = draft.pricing_overrides.length;
draft.pricing_overrides = draft.pricing_overrides.filter((o) => o.id !== id);
const removed = before - draft.pricing_overrides.length;
if (removed > 0) {
draft.count = Math.max(0, (draft.count || 0) - removed);
draft.total_count = Math.max(0, (draft.total_count || 0) - removed);
}
}),
);
}
} catch {
// Mutation failed
}
},
}),
// Provider Governance
getProviderGovernance: builder.query<GetProviderGovernanceResponse, { fromMemory?: boolean } | void>({
query: (params) => ({
url: "/governance/providers",
params: { from_memory: params?.fromMemory ?? false },
}),
providesTags: ["ProviderGovernance"],
}),
updateProviderGovernance: builder.mutation<
{ message: string; provider: ProviderGovernance },
{ provider: string; data: UpdateProviderGovernanceRequest }
>({
query: ({ provider, data }) => ({
url: `/governance/providers/${encodeURIComponent(provider)}`,
method: "PUT",
body: data,
}),
async onQueryStarted({ provider }, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
const variants = [undefined, { fromMemory: true }] as const;
for (const variant of variants) {
dispatch(
governanceApi.util.updateQueryData("getProviderGovernance", variant, (draft) => {
if (!draft.providers) draft.providers = [];
const index = draft.providers.findIndex((p) => p.provider === provider);
if (index !== -1) {
draft.providers[index] = data.provider;
} else {
// New provider governance - add to list
draft.providers.push(data.provider);
draft.count = (draft.count || 0) + 1;
}
}),
);
}
} catch {
// Mutation failed
}
},
}),
deleteProviderGovernance: builder.mutation<{ message: string }, string>({
query: (provider) => ({
url: `/governance/providers/${encodeURIComponent(provider)}`,
method: "DELETE",
}),
async onQueryStarted(provider, { dispatch, queryFulfilled }) {
try {
await queryFulfilled;
const variants = [undefined, { fromMemory: true }] as const;
for (const variant of variants) {
dispatch(
governanceApi.util.updateQueryData("getProviderGovernance", variant, (draft) => {
if (!draft.providers) return;
draft.providers = draft.providers.filter((p) => p.provider !== provider);
draft.count = Math.max(0, (draft.count || 0) - 1);
}),
);
}
} catch {
// Mutation failed
}
},
}),
}),
});
export const {
// Virtual Keys
useGetVirtualKeysQuery,
useGetVirtualKeyQuery,
useCreateVirtualKeyMutation,
useUpdateVirtualKeyMutation,
useDeleteVirtualKeyMutation,
// Teams
useGetTeamsQuery,
useGetTeamQuery,
useCreateTeamMutation,
useUpdateTeamMutation,
useDeleteTeamMutation,
// Customers
useGetCustomersQuery,
useGetCustomerQuery,
useCreateCustomerMutation,
useUpdateCustomerMutation,
useDeleteCustomerMutation,
// Budgets
useGetBudgetsQuery,
useGetBudgetQuery,
useUpdateBudgetMutation,
useDeleteBudgetMutation,
// Rate Limits
useGetRateLimitsQuery,
useGetRateLimitQuery,
useUpdateRateLimitMutation,
useDeleteRateLimitMutation,
// Usage Stats
useGetUsageStatsQuery,
useResetUsageMutation,
// Debug
useGetGovernanceDebugStatsQuery,
useGetGovernanceHealthQuery,
// Model Configs
useGetModelConfigsQuery,
useGetModelConfigQuery,
useCreateModelConfigMutation,
useUpdateModelConfigMutation,
useDeleteModelConfigMutation,
useGetPricingOverridesQuery,
useCreatePricingOverrideMutation,
useUpdatePricingOverrideMutation,
useDeletePricingOverrideMutation,
// Provider Governance
useGetProviderGovernanceQuery,
useUpdateProviderGovernanceMutation,
useDeleteProviderGovernanceMutation,
// Lazy queries
useLazyGetVirtualKeysQuery,
useLazyGetVirtualKeyQuery,
useLazyGetTeamsQuery,
useLazyGetTeamQuery,
useLazyGetCustomersQuery,
useLazyGetCustomerQuery,
useLazyGetBudgetsQuery,
useLazyGetBudgetQuery,
useLazyGetRateLimitsQuery,
useLazyGetRateLimitQuery,
useLazyGetUsageStatsQuery,
useLazyGetGovernanceDebugStatsQuery,
useLazyGetGovernanceHealthQuery,
useLazyGetModelConfigsQuery,
useLazyGetProviderGovernanceQuery,
} = governanceApi;

View File

@@ -0,0 +1,14 @@
// Base API
export { baseApi, clearAuthStorage, getErrorMessage, setAuthToken } from "./baseApi";
// API slices and hooks
export * from "./configApi";
export * from "./devApi";
export * from "./governanceApi";
export * from "./logsApi";
export * from "./mcpApi";
export * from "./mcpLogsApi";
export * from "./pluginsApi";
export * from "./providersApi";
export * from "./promptsApi";
export * from "./sessionApi";

View File

@@ -0,0 +1,361 @@
import { RedactedDBKey, VirtualKey } from "@/lib/types/governance";
import {
CostHistogramResponse,
LatencyHistogramResponse,
LogEntry,
LogFilters,
LogSessionDetailResponse,
LogSessionSummaryResponse,
LogsHistogramResponse,
LogStats,
ModelHistogramResponse,
ModelRankingsResponse,
Pagination,
ProviderCostHistogramResponse,
ProviderLatencyHistogramResponse,
ProviderTokenHistogramResponse,
RecalculateCostResponse,
TokenHistogramResponse,
} from "@/lib/types/logs";
import { baseApi } from "./baseApi";
import { RoutingRule } from "@/lib/types/routingRules";
// Helper function to build filter params
function buildFilterParams(filters: LogFilters): Record<string, string | number> {
const params: Record<string, string | number> = {};
if (filters.parent_request_id) {
params.parent_request_id = filters.parent_request_id;
}
if (filters.providers && filters.providers.length > 0) {
params.providers = filters.providers.join(",");
}
if (filters.models && filters.models.length > 0) {
params.models = filters.models.join(",");
}
if (filters.aliases && filters.aliases.length > 0) {
params.aliases = filters.aliases.join(",");
}
if (filters.status && filters.status.length > 0) {
params.status = filters.status.join(",");
}
if (filters.objects && filters.objects.length > 0) {
params.objects = filters.objects.join(",");
}
if (filters.selected_key_ids && filters.selected_key_ids.length > 0) {
params.selected_key_ids = filters.selected_key_ids.join(",");
}
if (filters.virtual_key_ids && filters.virtual_key_ids.length > 0) {
params.virtual_key_ids = filters.virtual_key_ids.join(",");
}
if (filters.routing_rule_ids && filters.routing_rule_ids.length > 0) {
params.routing_rule_ids = filters.routing_rule_ids.join(",");
}
if (filters.routing_engine_used && filters.routing_engine_used.length > 0) {
params.routing_engine_used = filters.routing_engine_used.join(",");
}
if (filters.start_time) params.start_time = filters.start_time;
if (filters.end_time) params.end_time = filters.end_time;
if (filters.min_latency !== undefined) params.min_latency = filters.min_latency;
if (filters.max_latency !== undefined) params.max_latency = filters.max_latency;
if (filters.min_tokens !== undefined) params.min_tokens = filters.min_tokens;
if (filters.max_tokens !== undefined) params.max_tokens = filters.max_tokens;
if (filters.missing_cost_only) params.missing_cost_only = "true";
if (filters.content_search) params.content_search = filters.content_search;
if (filters.user_ids && filters.user_ids.length > 0) {
params.user_ids = filters.user_ids.join(",");
}
if (filters.team_ids && filters.team_ids.length > 0) {
params.team_ids = filters.team_ids.join(",");
}
if (filters.customer_ids && filters.customer_ids.length > 0) {
params.customer_ids = filters.customer_ids.join(",");
}
if (filters.business_unit_ids && filters.business_unit_ids.length > 0) {
params.business_unit_ids = filters.business_unit_ids.join(",");
}
if (filters.metadata_filters) {
for (const [key, value] of Object.entries(filters.metadata_filters)) {
params[`metadata_${key}`] = value;
}
}
return params;
}
export const logsApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
// Get logs with filters and pagination
getLogs: builder.query<
{
logs: LogEntry[];
pagination: Pagination;
stats: LogStats;
has_logs: boolean;
},
{
filters: LogFilters;
pagination: Pagination;
}
>({
query: ({ filters, pagination }) => ({
url: "/logs",
params: {
limit: pagination.limit,
offset: pagination.offset,
sort_by: pagination.sort_by,
order: pagination.order,
...buildFilterParams(filters),
},
}),
providesTags: ["Logs"],
}),
getLogSessionById: builder.query<
LogSessionDetailResponse,
{
sessionId: string;
pagination: Pick<Pagination, "limit" | "offset" | "order">;
}
>({
query: ({ sessionId, pagination }) => ({
url: `/logs/sessions/${encodeURIComponent(sessionId)}`,
params: {
limit: pagination.limit,
offset: pagination.offset,
order: pagination.order,
},
}),
providesTags: ["Logs"],
}),
getLogSessionSummaryById: builder.query<LogSessionSummaryResponse, string>({
query: (sessionId) => ({
url: `/logs/sessions/${encodeURIComponent(sessionId)}/summary`,
}),
providesTags: ["Logs"],
}),
// Get logs statistics with filters
getLogsStats: builder.query<
LogStats,
{
filters: LogFilters;
}
>({
query: ({ filters }) => ({
url: "/logs/stats",
params: buildFilterParams(filters),
}),
providesTags: ["Logs"],
}),
// Get logs histogram with filters
getLogsHistogram: builder.query<
LogsHistogramResponse,
{
filters: LogFilters;
}
>({
query: ({ filters }) => ({
url: "/logs/histogram",
params: buildFilterParams(filters),
}),
providesTags: ["Logs"],
}),
// Get token usage histogram with filters
getLogsTokenHistogram: builder.query<
TokenHistogramResponse,
{
filters: LogFilters;
}
>({
query: ({ filters }) => ({
url: "/logs/histogram/tokens",
params: buildFilterParams(filters),
}),
providesTags: ["Logs"],
}),
// Get cost histogram with filters and model breakdown
getLogsCostHistogram: builder.query<
CostHistogramResponse,
{
filters: LogFilters;
}
>({
query: ({ filters }) => ({
url: "/logs/histogram/cost",
params: buildFilterParams(filters),
}),
providesTags: ["Logs"],
}),
// Get model usage histogram with filters
getLogsModelHistogram: builder.query<
ModelHistogramResponse,
{
filters: LogFilters;
}
>({
query: ({ filters }) => ({
url: "/logs/histogram/models",
params: buildFilterParams(filters),
}),
providesTags: ["Logs"],
}),
// Get latency histogram with percentiles
getLogsLatencyHistogram: builder.query<
LatencyHistogramResponse,
{
filters: LogFilters;
}
>({
query: ({ filters }) => ({
url: "/logs/histogram/latency",
params: buildFilterParams(filters),
}),
providesTags: ["Logs"],
}),
// Get provider cost histogram with provider breakdown
getLogsProviderCostHistogram: builder.query<
ProviderCostHistogramResponse,
{
filters: LogFilters;
}
>({
query: ({ filters }) => ({
url: "/logs/histogram/cost/by-provider",
params: buildFilterParams(filters),
}),
providesTags: ["Logs"],
}),
// Get provider token histogram with provider breakdown
getLogsProviderTokenHistogram: builder.query<
ProviderTokenHistogramResponse,
{
filters: LogFilters;
}
>({
query: ({ filters }) => ({
url: "/logs/histogram/tokens/by-provider",
params: buildFilterParams(filters),
}),
providesTags: ["Logs"],
}),
// Get provider latency histogram with provider breakdown
getLogsProviderLatencyHistogram: builder.query<
ProviderLatencyHistogramResponse,
{
filters: LogFilters;
}
>({
query: ({ filters }) => ({
url: "/logs/histogram/latency/by-provider",
params: buildFilterParams(filters),
}),
providesTags: ["Logs"],
}),
// Get model rankings with trends
getModelRankings: builder.query<
ModelRankingsResponse,
{
filters: LogFilters;
}
>({
query: ({ filters }) => ({
url: "/logs/rankings",
params: buildFilterParams(filters),
}),
providesTags: ["Logs"],
}),
// Get dropped requests count
getDroppedRequests: builder.query<{ dropped_requests: number }, void>({
query: () => "/logs/dropped",
providesTags: ["Logs"],
}),
// Get available models
getAvailableFilterData: builder.query<
{
models: string[];
aliases: string[];
selected_keys: RedactedDBKey[];
virtual_keys: VirtualKey[];
routing_rules: RoutingRule[];
routing_engines: string[];
metadata_keys: Record<string, string[]>;
},
void
>({
query: () => "/logs/filterdata",
providesTags: ["Logs"],
}),
// Delete logs by their IDs
deleteLogs: builder.mutation<void, { ids: string[] }>({
query: ({ ids }) => ({
url: "/logs",
method: "DELETE",
body: { ids },
}),
invalidatesTags: ["Logs"],
}),
recalculateLogCosts: builder.mutation<RecalculateCostResponse, { filters: LogFilters; limit?: number }>({
query: ({ filters, limit }) => ({
url: "/logs/recalculate-cost",
method: "POST",
body: { filters, limit },
}),
invalidatesTags: ["Logs"],
}),
// Get a single log entry by ID (includes raw_request and raw_response)
getLogById: builder.query<LogEntry, string>({
query: (id) => `/logs/${encodeURIComponent(id)}`,
providesTags: (result, error, id) => [{ type: "Logs", id }],
}),
}),
});
export const {
useGetLogsQuery,
useGetLogsStatsQuery,
useGetLogsHistogramQuery,
useGetLogsTokenHistogramQuery,
useGetLogsCostHistogramQuery,
useGetLogsModelHistogramQuery,
useGetLogsLatencyHistogramQuery,
useGetLogsProviderCostHistogramQuery,
useGetLogsProviderTokenHistogramQuery,
useGetLogsProviderLatencyHistogramQuery,
useGetLogSessionSummaryByIdQuery,
useGetDroppedRequestsQuery,
useGetAvailableFilterDataQuery,
useLazyGetLogSessionByIdQuery,
useLazyGetLogsQuery,
useLazyGetLogsStatsQuery,
useLazyGetLogsHistogramQuery,
useLazyGetLogsTokenHistogramQuery,
useLazyGetLogsCostHistogramQuery,
useLazyGetLogsModelHistogramQuery,
useLazyGetLogsLatencyHistogramQuery,
useLazyGetLogsProviderCostHistogramQuery,
useLazyGetLogsProviderTokenHistogramQuery,
useLazyGetLogsProviderLatencyHistogramQuery,
useLazyGetModelRankingsQuery,
useLazyGetDroppedRequestsQuery,
useLazyGetAvailableFilterDataQuery,
useDeleteLogsMutation,
useRecalculateLogCostsMutation,
useLazyGetLogByIdQuery,
useGetLogByIdQuery,
} = logsApi;

150
ui/lib/store/apis/mcpApi.ts Normal file
View File

@@ -0,0 +1,150 @@
import {
CreateMCPClientRequest,
GetMCPClientsParams,
GetMCPClientsResponse,
MCPClient,
OAuthFlowResponse,
OAuthStatusResponse,
UpdateMCPClientRequest,
} from "@/lib/types/mcp";
import { baseApi } from "./baseApi";
type CreateMCPClientResponse = { status: "success"; message: string } | OAuthFlowResponse;
export const mcpApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
// Get MCP clients with pagination
getMCPClients: builder.query<GetMCPClientsResponse, GetMCPClientsParams | void>({
query: (params) => ({
url: "/mcp/clients",
params: {
...(params?.limit && { limit: params.limit }),
...(params?.offset !== undefined && { offset: params.offset }),
...(params?.search && { search: params.search }),
},
}),
providesTags: ["MCPClients"],
}),
// Create new MCP client
createMCPClient: builder.mutation<CreateMCPClientResponse, CreateMCPClientRequest>({
query: (data) => ({
url: "/mcp/client",
method: "POST",
body: data,
}),
async onQueryStarted(arg, { dispatch, getState, queryFulfilled }) {
try {
await queryFulfilled;
// MCP create may return an OAuth flow response, so we can't optimistically
// add the client — just invalidate to refetch
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getMCPClients" || entry?.status !== "fulfilled") continue;
dispatch(mcpApi.util.invalidateTags(["MCPClients"]));
break;
}
} catch {}
},
}),
// Update existing MCP client
updateMCPClient: builder.mutation<any, { id: string; data: UpdateMCPClientRequest }>({
query: ({ id, data }) => ({
url: `/mcp/client/${id}`,
method: "PUT",
body: data,
}),
async onQueryStarted({ id, data }, { dispatch, getState, queryFulfilled }) {
try {
await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getMCPClients" || entry?.status !== "fulfilled") continue;
dispatch(
mcpApi.util.updateQueryData("getMCPClients", entry.originalArgs, (draft) => {
if (!draft.clients) return;
const index = draft.clients.findIndex((c) => c.config.client_id === id);
if (index !== -1) {
// Merge the updated fields into the existing client
if (data.name !== undefined) draft.clients[index].config.name = data.name;
if (data.is_code_mode_client !== undefined) draft.clients[index].config.is_code_mode_client = data.is_code_mode_client;
if (data.headers !== undefined) draft.clients[index].config.headers = data.headers;
if (data.tools_to_execute !== undefined) draft.clients[index].config.tools_to_execute = data.tools_to_execute;
if (data.tools_to_auto_execute !== undefined)
draft.clients[index].config.tools_to_auto_execute = data.tools_to_auto_execute;
if (data.is_ping_available !== undefined) draft.clients[index].config.is_ping_available = data.is_ping_available;
if (data.tool_pricing !== undefined) draft.clients[index].config.tool_pricing = data.tool_pricing;
if (data.tool_sync_interval !== undefined) draft.clients[index].config.tool_sync_interval = data.tool_sync_interval;
}
}),
);
}
} catch {}
},
}),
// Delete MCP client
deleteMCPClient: builder.mutation<any, string>({
query: (id) => ({
url: `/mcp/client/${id}`,
method: "DELETE",
}),
async onQueryStarted(id, { dispatch, getState, queryFulfilled }) {
try {
await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getMCPClients" || entry?.status !== "fulfilled") continue;
dispatch(
mcpApi.util.updateQueryData("getMCPClients", entry.originalArgs, (draft) => {
if (!draft.clients) return;
const before = draft.clients.length;
draft.clients = draft.clients.filter((c) => c.config.client_id !== id);
if (draft.clients.length < before) {
draft.count = Math.max(0, (draft.count || 0) - 1);
draft.total_count = Math.max(0, (draft.total_count || 0) - 1);
}
}),
);
}
} catch {}
},
}),
// Reconnect MCP client
reconnectMCPClient: builder.mutation<any, string>({
query: (id) => ({
url: `/mcp/client/${id}/reconnect`,
method: "POST",
}),
invalidatesTags: ["MCPClients"],
}),
// Get OAuth config status (for polling)
getOAuthConfigStatus: builder.query<OAuthStatusResponse, string>({
query: (oauthConfigId) => `/oauth/config/${oauthConfigId}/status`,
providesTags: (result, error, id) => [{ type: "OAuth2Config", id }],
}),
// Complete OAuth flow for MCP client
completeOAuthFlow: builder.mutation<{ status: string; message: string }, string>({
query: (oauthConfigId) => ({
url: `/mcp/client/${oauthConfigId}/complete-oauth`,
method: "POST",
}),
invalidatesTags: ["MCPClients"],
}),
}),
});
export const {
useGetMCPClientsQuery,
useCreateMCPClientMutation,
useUpdateMCPClientMutation,
useDeleteMCPClientMutation,
useReconnectMCPClientMutation,
useLazyGetMCPClientsQuery,
useLazyGetOAuthConfigStatusQuery,
useCompleteOAuthFlowMutation,
} = mcpApi;

View File

@@ -0,0 +1,189 @@
import {
MCPToolLogEntry,
MCPToolLogFilters,
MCPToolLogStats,
MCPToolLogFilterData,
MCPHistogramResponse,
MCPCostHistogramResponse,
MCPTopToolsResponse,
Pagination,
} from "@/lib/types/logs";
import { baseApi } from "./baseApi";
// Helper function to build MCP histogram filter params
function buildMCPFilterParams(filters: MCPToolLogFilters): Record<string, string | number> {
const params: Record<string, string | number> = {};
if (filters.tool_names && filters.tool_names.length > 0) {
params.tool_names = filters.tool_names.join(",");
}
if (filters.server_labels && filters.server_labels.length > 0) {
params.server_labels = filters.server_labels.join(",");
}
if (filters.status && filters.status.length > 0) {
params.status = filters.status.join(",");
}
if (filters.virtual_key_ids && filters.virtual_key_ids.length > 0) {
params.virtual_key_ids = filters.virtual_key_ids.join(",");
}
if (filters.llm_request_ids && filters.llm_request_ids.length > 0) {
params.llm_request_ids = filters.llm_request_ids.join(",");
}
if (filters.start_time) params.start_time = filters.start_time;
if (filters.end_time) params.end_time = filters.end_time;
if (filters.min_latency !== undefined) params.min_latency = filters.min_latency;
if (filters.max_latency !== undefined) params.max_latency = filters.max_latency;
if (filters.content_search) params.content_search = filters.content_search;
return params;
}
export const mcpLogsApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
// Get MCP tool logs with filters and pagination
getMCPLogs: builder.query<
{
logs: MCPToolLogEntry[];
pagination: Pagination;
stats: MCPToolLogStats;
has_logs: boolean;
},
{
filters: MCPToolLogFilters;
pagination: Pagination;
}
>({
query: ({ filters, pagination }) => {
const params: Record<string, string | number> = {
limit: pagination.limit,
offset: pagination.offset,
sort_by: pagination.sort_by,
order: pagination.order,
};
// Add filters to params if they exist
if (filters.tool_names && filters.tool_names.length > 0) {
params.tool_names = filters.tool_names.join(",");
}
if (filters.server_labels && filters.server_labels.length > 0) {
params.server_labels = filters.server_labels.join(",");
}
if (filters.status && filters.status.length > 0) {
params.status = filters.status.join(",");
}
if (filters.virtual_key_ids && filters.virtual_key_ids.length > 0) {
params.virtual_key_ids = filters.virtual_key_ids.join(",");
}
if (filters.llm_request_ids && filters.llm_request_ids.length > 0) {
params.llm_request_ids = filters.llm_request_ids.join(",");
}
if (filters.start_time) params.start_time = filters.start_time;
if (filters.end_time) params.end_time = filters.end_time;
if (filters.min_latency) params.min_latency = filters.min_latency;
if (filters.max_latency) params.max_latency = filters.max_latency;
if (filters.content_search) params.content_search = filters.content_search;
return {
url: "/mcp-logs",
params,
};
},
providesTags: ["MCPLogs"],
}),
// Get MCP tool logs statistics with filters
getMCPLogsStats: builder.query<
MCPToolLogStats,
{
filters: MCPToolLogFilters;
}
>({
query: ({ filters }) => {
const params: Record<string, string | number> = {};
// Add filters to params if they exist
if (filters.tool_names && filters.tool_names.length > 0) {
params.tool_names = filters.tool_names.join(",");
}
if (filters.server_labels && filters.server_labels.length > 0) {
params.server_labels = filters.server_labels.join(",");
}
if (filters.status && filters.status.length > 0) {
params.status = filters.status.join(",");
}
if (filters.virtual_key_ids && filters.virtual_key_ids.length > 0) {
params.virtual_key_ids = filters.virtual_key_ids.join(",");
}
if (filters.llm_request_ids && filters.llm_request_ids.length > 0) {
params.llm_request_ids = filters.llm_request_ids.join(",");
}
if (filters.start_time) params.start_time = filters.start_time;
if (filters.end_time) params.end_time = filters.end_time;
if (filters.min_latency) params.min_latency = filters.min_latency;
if (filters.max_latency) params.max_latency = filters.max_latency;
if (filters.content_search) params.content_search = filters.content_search;
return {
url: "/mcp-logs/stats",
params,
};
},
providesTags: ["MCPLogs"],
}),
// Get available filter data (tool names, server labels)
getMCPAvailableFilterData: builder.query<MCPToolLogFilterData, void>({
query: () => "/mcp-logs/filterdata",
providesTags: ["MCPLogs"],
}),
// Get MCP tool call volume histogram
getMCPHistogram: builder.query<MCPHistogramResponse, { filters: MCPToolLogFilters }>({
query: ({ filters }) => ({
url: "/mcp-logs/histogram",
params: buildMCPFilterParams(filters),
}),
providesTags: ["MCPLogs"],
}),
// Get MCP cost histogram
getMCPCostHistogram: builder.query<MCPCostHistogramResponse, { filters: MCPToolLogFilters }>({
query: ({ filters }) => ({
url: "/mcp-logs/histogram/cost",
params: buildMCPFilterParams(filters),
}),
providesTags: ["MCPLogs"],
}),
// Get top MCP tools by call count
getMCPTopTools: builder.query<MCPTopToolsResponse, { filters: MCPToolLogFilters }>({
query: ({ filters }) => ({
url: "/mcp-logs/histogram/top-tools",
params: buildMCPFilterParams(filters),
}),
providesTags: ["MCPLogs"],
}),
// Delete MCP tool logs by their IDs
deleteMCPLogs: builder.mutation<void, { ids: string[] }>({
query: ({ ids }) => ({
url: "/mcp-logs",
method: "DELETE",
body: { ids },
}),
invalidatesTags: ["MCPLogs"],
}),
}),
});
export const {
useGetMCPLogsQuery,
useGetMCPLogsStatsQuery,
useGetMCPAvailableFilterDataQuery,
useGetMCPAvailableFilterDataQuery: useGetMCPLogsFilterDataQuery,
useLazyGetMCPLogsQuery,
useLazyGetMCPLogsStatsQuery,
useLazyGetMCPAvailableFilterDataQuery,
useLazyGetMCPHistogramQuery,
useLazyGetMCPCostHistogramQuery,
useLazyGetMCPTopToolsQuery,
useDeleteMCPLogsMutation,
} = mcpLogsApi;

View File

@@ -0,0 +1,96 @@
import { CreatePluginRequest, Plugin, PluginsResponse, UpdatePluginRequest } from "@/lib/types/plugins";
import { baseApi } from "./baseApi";
export const pluginsApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
// Get all plugins
getPlugins: builder.query<Plugin[], void>({
query: () => "/plugins",
providesTags: ["Plugins"],
transformResponse: (response: PluginsResponse) => response.plugins || [],
}),
// Get a single plugin
getPlugin: builder.query<Plugin, string>({
query: (name) => `/plugins/${name}`,
providesTags: (result, error, name) => [{ type: "Plugins", id: name }],
}),
// Create new plugin
createPlugin: builder.mutation<Plugin, CreatePluginRequest>({
query: (data) => ({
url: "/plugins",
method: "POST",
body: data,
}),
transformResponse: (response: { message: string; plugin: Plugin }) => response.plugin,
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
try {
const { data: newPlugin } = await queryFulfilled;
dispatch(
pluginsApi.util.updateQueryData("getPlugins", undefined, (draft) => {
draft.push(newPlugin);
}),
);
// Also update the individual plugin cache
dispatch(pluginsApi.util.updateQueryData("getPlugin", newPlugin.name, () => newPlugin));
} catch {}
},
}),
// Update existing plugin
updatePlugin: builder.mutation<Plugin, { name: string; data: UpdatePluginRequest }>({
query: ({ name, data }) => ({
url: `/plugins/${name}`,
method: "PUT",
body: data,
}),
transformResponse: (response: { message: string; plugin: Plugin }) => response.plugin,
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
try {
const { data: updatedPlugin } = await queryFulfilled;
dispatch(
pluginsApi.util.updateQueryData("getPlugins", undefined, (draft) => {
const index = draft.findIndex((p) => p.name === arg.name);
if (index !== -1) {
draft[index] = updatedPlugin;
}
}),
);
// Also update the individual plugin cache
dispatch(pluginsApi.util.updateQueryData("getPlugin", arg.name, () => updatedPlugin));
} catch {}
},
}),
// Delete plugin
deletePlugin: builder.mutation<Plugin, string>({
query: (name) => ({
url: `/plugins/${name}`,
method: "DELETE",
}),
async onQueryStarted(pluginName, { dispatch, queryFulfilled }) {
try {
await queryFulfilled;
dispatch(
pluginsApi.util.updateQueryData("getPlugins", undefined, (draft) => {
const index = draft.findIndex((p) => p.name === pluginName);
if (index !== -1) {
draft.splice(index, 1);
}
}),
);
} catch {}
},
}),
}),
});
export const {
useGetPluginsQuery,
useGetPluginQuery,
useCreatePluginMutation,
useUpdatePluginMutation,
useDeletePluginMutation,
useLazyGetPluginsQuery,
} = pluginsApi;

View File

@@ -0,0 +1,276 @@
import { baseApi } from "@/lib/store/apis/baseApi";
import {
CommitSessionRequest,
CommitSessionResponse,
CreateFolderRequest,
CreateFolderResponse,
CreatePromptRequest,
CreatePromptResponse,
CreateSessionRequest,
CreateSessionResponse,
CreateVersionRequest,
CreateVersionResponse,
DeleteFolderResponse,
DeletePromptResponse,
DeleteSessionResponse,
DeleteVersionResponse,
GetFolderResponse,
GetFoldersResponse,
GetPromptResponse,
GetPromptsResponse,
GetSessionResponse,
GetSessionsResponse,
GetVersionResponse,
GetVersionsResponse,
UpdateFolderRequest,
UpdateFolderResponse,
UpdatePromptRequest,
UpdatePromptResponse,
RenameSessionRequest,
RenameSessionResponse,
UpdateSessionRequest,
UpdateSessionResponse,
} from "@/lib/types/prompts";
// Inject Prompt Repository endpoints into baseApi
export const promptsApi = baseApi.injectEndpoints({
overrideExisting: true,
endpoints: (builder) => ({
// Get all folders
getFolders: builder.query<GetFoldersResponse, void>({
query: () => "/prompt-repo/folders",
providesTags: ["Folders"],
}),
// Get single folder
getFolder: builder.query<GetFolderResponse, string>({
query: (id) => `/prompt-repo/folders/${id}`,
providesTags: (result, error, id) => [{ type: "Folders", id }],
}),
// Create folder
createFolder: builder.mutation<CreateFolderResponse, CreateFolderRequest>({
query: (data) => ({
url: "/prompt-repo/folders",
method: "POST",
body: data,
}),
invalidatesTags: ["Folders"],
}),
// Update folder
updateFolder: builder.mutation<UpdateFolderResponse, { id: string; data: UpdateFolderRequest }>({
query: ({ id, data }) => ({
url: `/prompt-repo/folders/${id}`,
method: "PUT",
body: data,
}),
invalidatesTags: (result, error, { id }) => ["Folders", { type: "Folders", id }],
}),
// Delete folder
deleteFolder: builder.mutation<DeleteFolderResponse, string>({
query: (id) => ({
url: `/prompt-repo/folders/${id}`,
method: "DELETE",
}),
invalidatesTags: (result, error, id) => ["Folders", { type: "Folders", id }, "Prompts"],
}),
// Get all prompts (optionally filtered by folder)
getPrompts: builder.query<GetPromptsResponse, { folderId?: string } | void>({
query: (params) => {
const queryParams = params && params.folderId ? `?folder_id=${params.folderId}` : "";
return `/prompt-repo/prompts${queryParams}`;
},
providesTags: ["Prompts"],
}),
// Get single prompt
getPrompt: builder.query<GetPromptResponse, string>({
query: (id) => `/prompt-repo/prompts/${id}`,
providesTags: (result, error, id) => [{ type: "Prompts", id }],
}),
// Create prompt
createPrompt: builder.mutation<CreatePromptResponse, CreatePromptRequest>({
query: (data) => ({
url: "/prompt-repo/prompts",
method: "POST",
body: data,
}),
invalidatesTags: ["Prompts", "Folders"],
}),
// Update prompt
updatePrompt: builder.mutation<UpdatePromptResponse, { id: string; data: UpdatePromptRequest }>({
query: ({ id, data }) => ({
url: `/prompt-repo/prompts/${id}`,
method: "PUT",
body: data,
}),
invalidatesTags: (result, error, { id }) => ["Prompts", { type: "Prompts", id }],
async onQueryStarted({ id, data }, { dispatch, queryFulfilled }) {
// Optimistic update on the prompts list cache
const patchResult = dispatch(
promptsApi.util.updateQueryData("getPrompts", undefined, (draft) => {
const prompt = draft.prompts.find((p) => p.id === id);
if (prompt) {
if (data.name !== undefined) prompt.name = data.name;
if ("folder_id" in data) prompt.folder_id = data.folder_id ?? undefined;
}
}),
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
}),
// Delete prompt
deletePrompt: builder.mutation<DeletePromptResponse, string>({
query: (id) => ({
url: `/prompt-repo/prompts/${id}`,
method: "DELETE",
}),
invalidatesTags: (result, error, id) => ["Prompts", { type: "Prompts", id }, "Folders", "Versions", "Sessions"],
}),
// Get all versions for a prompt
getVersions: builder.query<GetVersionsResponse, string>({
query: (promptId) => `/prompt-repo/prompts/${promptId}/versions`,
providesTags: (result, error, promptId) => [{ type: "Versions", id: promptId }],
}),
// Get single version
getPromptVersion: builder.query<GetVersionResponse, number>({
query: (id) => `/prompt-repo/versions/${id}`,
providesTags: (result, error, id) => [{ type: "Versions", id }],
}),
// Create version
createVersion: builder.mutation<CreateVersionResponse, { promptId: string; data: CreateVersionRequest }>({
query: ({ promptId, data }) => ({
url: `/prompt-repo/prompts/${promptId}/versions`,
method: "POST",
body: data,
}),
invalidatesTags: (result, error, { promptId }) => ["Prompts", { type: "Prompts", id: promptId }, { type: "Versions", id: promptId }],
}),
// Delete version
deleteVersion: builder.mutation<DeleteVersionResponse, { id: number; promptId: string }>({
query: ({ id }) => ({
url: `/prompt-repo/versions/${id}`,
method: "DELETE",
}),
invalidatesTags: (result, error, { id, promptId }) => [
"Prompts",
{ type: "Prompts", id: promptId },
{ type: "Versions", id: promptId },
{ type: "Versions", id },
],
}),
// Get all sessions for a prompt
getSessions: builder.query<GetSessionsResponse, string>({
query: (promptId) => `/prompt-repo/prompts/${promptId}/sessions`,
providesTags: (result, error, promptId) => [{ type: "Sessions", id: promptId }],
}),
// Get single session
getSession: builder.query<GetSessionResponse, number>({
query: (id) => `/prompt-repo/sessions/${id}`,
providesTags: (result, error, id) => [{ type: "Sessions", id }],
}),
// Create session
createSession: builder.mutation<CreateSessionResponse, { promptId: string; data: CreateSessionRequest }>({
query: ({ promptId, data }) => ({
url: `/prompt-repo/prompts/${promptId}/sessions`,
method: "POST",
body: data,
}),
invalidatesTags: (result, error, { promptId }) => [{ type: "Sessions", id: promptId }],
}),
// Update session
updateSession: builder.mutation<UpdateSessionResponse, { id: number; promptId: string; data: UpdateSessionRequest }>({
query: ({ id, data }) => ({
url: `/prompt-repo/sessions/${id}`,
method: "PUT",
body: data,
}),
invalidatesTags: (result, error, { id, promptId }) => [
{ type: "Sessions", id },
{ type: "Sessions", id: promptId },
],
}),
// Delete session
deleteSession: builder.mutation<DeleteSessionResponse, { id: number; promptId: string }>({
query: ({ id }) => ({
url: `/prompt-repo/sessions/${id}`,
method: "DELETE",
}),
invalidatesTags: (result, error, { id, promptId }) => [
{ type: "Sessions", id: promptId },
{ type: "Sessions", id },
],
}),
// Rename session
renameSession: builder.mutation<RenameSessionResponse, { id: number; promptId: string; data: RenameSessionRequest }>({
query: ({ id, data }) => ({
url: `/prompt-repo/sessions/${id}/rename`,
method: "PUT",
body: data,
}),
invalidatesTags: (result, error, { id, promptId }) => [
{ type: "Sessions", id: promptId },
{ type: "Sessions", id },
],
}),
// Commit session as new version
commitSession: builder.mutation<CommitSessionResponse, { id: number; promptId: string; data: CommitSessionRequest }>({
query: ({ id, data }) => ({
url: `/prompt-repo/sessions/${id}/commit`,
method: "POST",
body: data,
}),
invalidatesTags: (result, error, { promptId }) => ["Prompts", { type: "Prompts", id: promptId }, { type: "Versions", id: promptId }],
}),
}),
});
export const {
// Folders
useGetFoldersQuery,
useGetFolderQuery,
useCreateFolderMutation,
useUpdateFolderMutation,
useDeleteFolderMutation,
// Prompts
useGetPromptsQuery,
useGetPromptQuery,
useCreatePromptMutation,
useUpdatePromptMutation,
useDeletePromptMutation,
// Versions
useGetVersionsQuery,
useGetPromptVersionQuery,
useLazyGetPromptVersionQuery,
useCreateVersionMutation,
useDeleteVersionMutation,
// Sessions
useGetSessionsQuery,
useGetSessionQuery,
useCreateSessionMutation,
useUpdateSessionMutation,
useDeleteSessionMutation,
useRenameSessionMutation,
useCommitSessionMutation,
} = promptsApi;

View File

@@ -0,0 +1,375 @@
import {
AddProviderRequest,
CreateProviderKeyRequest,
ListProviderKeysResponse,
ListProvidersResponse,
ModelProvider,
ModelProviderKey,
ModelProviderName,
UpdateProviderRequest,
UpdateProviderKeyRequest,
} from "@/lib/types/config";
import { DBKey } from "@/lib/types/governance";
import { baseApi } from "./baseApi";
function sortProviders(a: ModelProvider, b: ModelProvider) {
const aIsCustom = !!a.custom_provider_config;
const bIsCustom = !!b.custom_provider_config;
if (aIsCustom !== bIsCustom) return aIsCustom ? 1 : -1;
return a.name.localeCompare(b.name);
}
// Types for models API
export interface ModelResponse {
name: string;
provider: string;
accessible_by_keys?: string[];
}
export interface ListModelsResponse {
models: ModelResponse[];
total: number;
}
export interface GetModelsRequest {
query?: string;
provider?: string;
keys?: string[];
vks?: string[];
limit?: number;
unfiltered?: boolean;
}
export interface GetBaseModelsRequest {
query?: string;
limit?: number;
}
export interface ModelDatasheetParameter {
id: string;
label: string;
helpText?: string;
type: string;
accesorKey?: string;
default?: any;
multiple?: boolean;
range?: { min: number; max: number; step?: number };
array?: { type: string; maxElements?: number; minElements?: number };
options?: { label: string; value: string; subFields?: ModelDatasheetParameter[] }[];
}
export interface ModelDatasheetResponse {
model_parameters?: ModelDatasheetParameter[];
max_input_tokens?: number;
max_output_tokens?: number;
max_tokens?: number;
mode?: string;
provider?: string;
base_model?: string;
supports_vision?: boolean;
[key: string]: any;
}
export interface ListBaseModelsResponse {
models: string[];
total: number;
}
type UpdateProviderMutationArg = UpdateProviderRequest & { name: ModelProviderName };
const DEFAULT_MODEL_PARAMETERS: ModelDatasheetResponse = {
mode: "chat",
base_model: "default",
model_parameters: [
{
id: "temperature",
label: "Temperature",
helpText:
"What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.",
type: "number",
range: { min: 0, max: 2, step: 0.01 },
},
{
id: "max_tokens",
label: "Max Tokens",
helpText: "The maximum number of tokens that can be generated in the Result.",
type: "number",
range: { min: 1, max: 8192, step: 1 },
},
{
id: "stream",
label: "Stream",
helpText:
"The stream parameter in the API controls whether the response is sent in incremental updates, like tokenized data as it's generated, or as a complete result in one go.",
type: "boolean",
},
],
};
export const providersApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
// Get all providers
getProviders: builder.query<ModelProvider[], void>({
query: () => "/providers",
transformResponse: (response: ListProvidersResponse): ModelProvider[] => (response.providers ?? []).sort(sortProviders),
providesTags: ["Providers"],
}),
// Get single provider
getProvider: builder.query<ModelProvider, string>({
query: (provider) => `/providers/${encodeURIComponent(provider)}`,
providesTags: (result, error, provider) => [{ type: "Providers", id: provider }],
}),
getProviderKeys: builder.query<ModelProviderKey[], string>({
query: (provider) => `/providers/${encodeURIComponent(provider)}/keys`,
transformResponse: (response: ListProviderKeysResponse) => response.keys ?? [],
providesTags: (result, error, provider) => [{ type: "ProviderKeys", id: provider }],
}),
getProviderKey: builder.query<ModelProviderKey, { provider: string; keyId: string }>({
query: ({ provider, keyId }) => `/providers/${encodeURIComponent(provider)}/keys/${encodeURIComponent(keyId)}`,
providesTags: (result, error, { provider }) => [{ type: "ProviderKeys", id: provider }],
}),
// Create new provider
createProvider: builder.mutation<ModelProvider, AddProviderRequest>({
query: (data) => ({
url: "/providers",
method: "POST",
body: data,
}),
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
try {
const { data: newProvider } = await queryFulfilled;
dispatch(
providersApi.util.updateQueryData("getProviders", undefined, (draft) => {
draft.push(newProvider);
draft.sort(sortProviders);
}),
);
} catch {}
},
}),
// Update existing provider
updateProvider: builder.mutation<ModelProvider, UpdateProviderMutationArg>({
query: ({ name, ...body }) => ({
url: `/providers/${encodeURIComponent(name)}`,
method: "PUT",
body,
}),
invalidatesTags: (result, error, arg) => [{ type: "ProviderKeys", id: arg.name }, "DBKeys", "VirtualKeys"],
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
try {
const { data: updatedProvider } = await queryFulfilled;
dispatch(
providersApi.util.updateQueryData("getProviders", undefined, (draft) => {
const index = draft.findIndex((p) => p.name === arg.name);
if (index !== -1) {
draft[index] = updatedProvider;
}
}),
);
dispatch(providersApi.util.updateQueryData("getProvider", arg.name, () => updatedProvider));
} catch {}
},
}),
createProviderKey: builder.mutation<ModelProviderKey, { provider: string; key: CreateProviderKeyRequest }>({
query: ({ provider, key }) => ({
url: `/providers/${encodeURIComponent(provider)}/keys`,
method: "POST",
body: key,
}),
async onQueryStarted({ provider }, { dispatch, queryFulfilled }) {
try {
const { data: newKey } = await queryFulfilled;
dispatch(
providersApi.util.updateQueryData("getProviderKeys", provider, (draft) => {
const exists = draft.some((k) => k.id === newKey.id);
if (!exists) {
draft.push(newKey);
}
}),
);
dispatch(
providersApi.util.updateQueryData("getAllKeys", undefined, (draft) => {
const exists = draft.some((k) => k.key_id === newKey.id);
if (!exists) {
draft.push({
key_id: newKey.id,
name: newKey.name,
provider_id: "",
models: newKey.models ?? [],
provider: provider as ModelProviderName,
});
}
}),
);
} catch {}
},
}),
updateProviderKey: builder.mutation<ModelProviderKey, { provider: string; keyId: string; key: UpdateProviderKeyRequest }>({
query: ({ provider, keyId, key }) => ({
url: `/providers/${encodeURIComponent(provider)}/keys/${encodeURIComponent(keyId)}`,
method: "PUT",
body: key,
}),
async onQueryStarted({ provider, keyId }, { dispatch, queryFulfilled }) {
try {
const { data: updatedKey } = await queryFulfilled;
dispatch(
providersApi.util.updateQueryData("getProviderKeys", provider, (draft) => {
const index = draft.findIndex((key) => key.id === keyId);
if (index !== -1) {
draft[index] = updatedKey;
}
}),
);
dispatch(providersApi.util.updateQueryData("getProviderKey", { provider, keyId }, () => updatedKey));
dispatch(
providersApi.util.updateQueryData("getAllKeys", undefined, (draft) => {
const index = draft.findIndex((k) => k.key_id === keyId);
if (index !== -1) {
draft[index] = { ...draft[index], name: updatedKey.name, models: updatedKey.models ?? [] };
}
}),
);
} catch {}
},
}),
deleteProviderKey: builder.mutation<ModelProviderKey, { provider: string; keyId: string }>({
query: ({ provider, keyId }) => ({
url: `/providers/${encodeURIComponent(provider)}/keys/${encodeURIComponent(keyId)}`,
method: "DELETE",
}),
async onQueryStarted({ provider, keyId }, { dispatch, queryFulfilled }) {
try {
await queryFulfilled;
dispatch(
providersApi.util.updateQueryData("getProviderKeys", provider, (draft) => {
const index = draft.findIndex((key) => key.id === keyId);
if (index !== -1) {
draft.splice(index, 1);
}
}),
);
dispatch(
providersApi.util.updateQueryData("getAllKeys", undefined, (draft) => {
const index = draft.findIndex((k) => k.key_id === keyId);
if (index !== -1) {
draft.splice(index, 1);
}
}),
);
} catch {}
},
}),
// Delete provider
deleteProvider: builder.mutation<ModelProviderName, string>({
query: (provider) => ({
url: `/providers/${encodeURIComponent(provider)}`,
method: "DELETE",
}),
invalidatesTags: ["VirtualKeys"],
async onQueryStarted(providerName, { dispatch, queryFulfilled }) {
try {
await queryFulfilled;
dispatch(
providersApi.util.updateQueryData("getProviders", undefined, (draft) => {
const index = draft.findIndex((p) => p.name === providerName);
if (index !== -1) {
draft.splice(index, 1);
}
}),
);
dispatch(
providersApi.util.updateQueryData("getProviderKeys", providerName, (draft) => {
draft.splice(0, draft.length);
}),
);
dispatch(
providersApi.util.updateQueryData("getAllKeys", undefined, (draft) => draft.filter((key) => key.provider !== providerName)),
);
} catch {}
},
}),
// Get all available keys from all providers for governance selection
getAllKeys: builder.query<DBKey[], void>({
query: () => "/keys",
providesTags: ["DBKeys"],
}),
// Get models with optional filtering
getModels: builder.query<ListModelsResponse, GetModelsRequest>({
query: ({ query, provider, keys, vks, limit, unfiltered }) => {
const params = new URLSearchParams();
if (query) params.append("query", query);
if (provider) params.append("provider", provider);
if (keys && keys.length > 0) params.append("keys", keys.join(","));
if (vks && vks.length > 0) params.append("vks", vks.join(","));
if (limit !== undefined) params.append("limit", limit.toString());
if (unfiltered !== undefined) params.append("unfiltered", unfiltered.toString());
return `/models?${params.toString()}`;
},
providesTags: ["Models"],
}),
// Get distinct base model names from the catalog
getBaseModels: builder.query<ListBaseModelsResponse, GetBaseModelsRequest>({
query: ({ query, limit }) => {
const params = new URLSearchParams();
if (query) params.append("query", query);
if (limit !== undefined) params.append("limit", limit.toString());
return `/models/base?${params.toString()}`;
},
providesTags: ["BaseModels"],
}),
// Get model parameters (parameters, capabilities) from local API
// Falls back to default parameters if the API returns an error (e.g. model not found)
getModelParameters: builder.query<ModelDatasheetResponse, string>({
queryFn: async (model, _queryApi, _extraOptions, baseQuery) => {
const result = await baseQuery(`/models/parameters?model=${encodeURIComponent(model)}`);
if (result.error) {
// If the model is not found, return the default parameters
if ((result.error as any)?.status === 404) {
return { data: DEFAULT_MODEL_PARAMETERS };
}
return { error: result.error };
}
return { data: result.data as ModelDatasheetResponse };
},
}),
}),
});
export const {
useGetProvidersQuery,
useGetProviderQuery,
useGetProviderKeysQuery,
useGetProviderKeyQuery,
useCreateProviderMutation,
useUpdateProviderMutation,
useCreateProviderKeyMutation,
useUpdateProviderKeyMutation,
useDeleteProviderKeyMutation,
useDeleteProviderMutation,
useGetAllKeysQuery,
useGetModelsQuery,
useGetBaseModelsQuery,
useLazyGetProvidersQuery,
useLazyGetProviderQuery,
useLazyGetProviderKeysQuery,
useLazyGetProviderKeyQuery,
useLazyGetAllKeysQuery,
useLazyGetModelsQuery,
useLazyGetBaseModelsQuery,
useGetModelParametersQuery,
useLazyGetModelParametersQuery,
} = providersApi;

View File

@@ -0,0 +1,136 @@
/**
* Routing Rules RTK Query API
* Handles all API communication for routing rules CRUD operations
*/
import {
RoutingRule,
GetRoutingRulesResponse,
GetRoutingRulesParams,
CreateRoutingRuleRequest,
UpdateRoutingRuleRequest,
} from "@/lib/types/routingRules";
import { baseApi } from "./baseApi";
export const routingRulesApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
// Get routing rules with pagination
getRoutingRules: builder.query<GetRoutingRulesResponse, GetRoutingRulesParams | void>({
query: (params) => ({
url: "/governance/routing-rules",
params: {
...(params?.limit && { limit: params.limit }),
...(params?.offset !== undefined && { offset: params.offset }),
...(params?.search && { search: params.search }),
},
}),
providesTags: ["RoutingRules"],
}),
// Get a single routing rule
getRoutingRule: builder.query<RoutingRule, string>({
query: (id) => ({
url: `/governance/routing-rules/${id}`,
method: "GET",
}),
transformResponse: (response: { rule: RoutingRule }) => response.rule,
providesTags: (result, error, arg) => [{ type: "RoutingRules", id: arg }],
}),
// Create a new routing rule
createRoutingRule: builder.mutation<RoutingRule, CreateRoutingRuleRequest>({
query: (body) => ({
url: `/governance/routing-rules`,
method: "POST",
body,
}),
transformResponse: (response: { rule: RoutingRule }) => response.rule,
async onQueryStarted(arg, { dispatch, getState, queryFulfilled }) {
try {
const { data: newRule } = await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getRoutingRules" || entry?.status !== "fulfilled") continue;
const search = entry.originalArgs?.search as string | undefined;
if (search && !newRule.name.toLowerCase().includes(search.toLowerCase())) continue;
dispatch(
routingRulesApi.util.updateQueryData("getRoutingRules", entry.originalArgs, (draft) => {
if (!draft.rules) draft.rules = [];
draft.rules.unshift(newRule);
draft.count = (draft.count || 0) + 1;
draft.total_count = (draft.total_count || 0) + 1;
}),
);
}
dispatch(routingRulesApi.util.updateQueryData("getRoutingRule", newRule.id, () => newRule));
} catch {}
},
}),
// Update an existing routing rule
updateRoutingRule: builder.mutation<RoutingRule, { id: string; data: UpdateRoutingRuleRequest }>({
query: ({ id, data }) => ({
url: `/governance/routing-rules/${id}`,
method: "PUT",
body: data,
}),
transformResponse: (response: { rule: RoutingRule }) => response.rule,
async onQueryStarted({ id }, { dispatch, getState, queryFulfilled }) {
try {
const { data: updatedRule } = await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getRoutingRules" || entry?.status !== "fulfilled") continue;
dispatch(
routingRulesApi.util.updateQueryData("getRoutingRules", entry.originalArgs, (draft) => {
if (!draft.rules) return;
const index = draft.rules.findIndex((r) => r.id === id);
if (index !== -1) {
draft.rules[index] = updatedRule;
}
}),
);
}
dispatch(routingRulesApi.util.updateQueryData("getRoutingRule", updatedRule.id, () => updatedRule));
} catch {}
},
}),
// Delete a routing rule
deleteRoutingRule: builder.mutation<void, string>({
query: (id) => ({
url: `/governance/routing-rules/${id}`,
method: "DELETE",
}),
async onQueryStarted(ruleId, { dispatch, getState, queryFulfilled }) {
try {
await queryFulfilled;
const queries = (getState() as any).api.queries;
for (const entry of Object.values(queries) as any[]) {
if (entry?.endpointName !== "getRoutingRules" || entry?.status !== "fulfilled") continue;
dispatch(
routingRulesApi.util.updateQueryData("getRoutingRules", entry.originalArgs, (draft) => {
if (!draft.rules) return;
const before = draft.rules.length;
draft.rules = draft.rules.filter((r) => r.id !== ruleId);
if (draft.rules.length < before) {
draft.count = Math.max(0, (draft.count || 0) - 1);
draft.total_count = Math.max(0, (draft.total_count || 0) - 1);
}
}),
);
}
} catch {}
},
}),
}),
});
export const {
useGetRoutingRulesQuery,
useGetRoutingRuleQuery,
useCreateRoutingRuleMutation,
useUpdateRoutingRuleMutation,
useDeleteRoutingRuleMutation,
useLazyGetRoutingRulesQuery,
} = routingRulesApi;

View File

@@ -0,0 +1,61 @@
import { baseApi, clearAuthStorage } from "./baseApi";
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
message: string;
}
export interface IsAuthEnabledResponse {
is_auth_enabled: boolean;
has_valid_token: boolean;
}
export interface LogoutResponse {
message: string;
}
export const sessionApi = baseApi.injectEndpoints({
overrideExisting: false,
endpoints: (builder) => ({
// Check if auth is enabled
isAuthEnabled: builder.query<IsAuthEnabledResponse, void>({
query: () => ({
url: "/session/is-auth-enabled",
method: "GET",
}),
}),
// Login endpoint
login: builder.mutation<LoginResponse, LoginRequest>({
query: (credentials) => ({
url: "/session/login",
method: "POST",
body: credentials,
}),
invalidatesTags: [],
}),
// Logout endpoint
logout: builder.mutation<LogoutResponse, void>({
query: () => ({
url: "/session/logout",
method: "POST",
}),
// After logout, clear token and all cached data
async onQueryStarted(arg, { queryFulfilled }) {
try {
await queryFulfilled;
} catch {
} finally {
clearAuthStorage();
}
},
invalidatesTags: ["Config", "Providers", "Logs", "VirtualKeys", "Teams", "Customers", "Budgets", "RateLimits"],
}),
}),
});
export const { useIsAuthEnabledQuery, useLoginMutation, useLogoutMutation } = sessionApi;

6
ui/lib/store/hooks.ts Normal file
View File

@@ -0,0 +1,6 @@
import { useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store";
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

14
ui/lib/store/index.ts Normal file
View File

@@ -0,0 +1,14 @@
// Store configuration
export { store, type AppDispatch, type RootState } from "./store";
// Redux Provider
export { ReduxProvider } from "./provider";
// Typed hooks
export { useAppDispatch, useAppSelector } from "./hooks";
// App state slice
export * from "./slices";
// APIs
export * from "./apis";

11
ui/lib/store/provider.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { type ReactNode } from "react";
import { Provider } from "react-redux";
import { store } from "./store";
interface ReduxProviderProps {
children: ReactNode;
}
export function ReduxProvider({ children }: ReduxProviderProps) {
return <Provider store={store}>{children}</Provider>;
}

View File

@@ -0,0 +1,223 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
// Define the shape of our app state
export interface AppState {
// UI State
sidebarCollapsed: boolean;
theme: "light" | "dark" | "system";
// Loading states for global operations
isInitializing: boolean;
isOnline: boolean;
// Current user/session info
currentUser: {
id?: string;
name?: string;
email?: string;
} | null;
// Global notifications/toasts
notifications: {
id: string;
type: "success" | "error" | "warning" | "info";
title: string;
message: string;
timestamp: number;
read: boolean;
}[];
// Application settings
settings: {
autoRefresh: boolean;
refreshInterval: number; // in seconds
maxLogEntries: number;
defaultPageSize: number;
};
// Global error state
globalError: {
message: string;
code?: string;
timestamp: number;
} | null;
// Selected items
selectedItems: {
providers: string[];
virtualKeys: string[];
teams: string[];
customers: string[];
logs: string[];
};
// Feature flags
features: {
enableMCP: boolean;
enableCaching: boolean;
enableLogging: boolean;
};
}
// Define initial state
const initialState: AppState = {
sidebarCollapsed: false,
theme: "system",
isInitializing: true,
isOnline: true,
currentUser: null,
notifications: [],
settings: {
autoRefresh: false,
refreshInterval: 30,
maxLogEntries: 1000,
defaultPageSize: 50,
},
globalError: null,
features: {
enableMCP: false,
enableCaching: false,
enableLogging: false,
},
selectedItems: {
providers: [],
virtualKeys: [],
teams: [],
customers: [],
logs: [],
},
};
// Create the slice
const appSlice = createSlice({
name: "app",
initialState,
reducers: {
// UI Actions
toggleSidebar: (state) => {
state.sidebarCollapsed = !state.sidebarCollapsed;
},
setSidebarCollapsed: (state, action: PayloadAction<boolean>) => {
state.sidebarCollapsed = action.payload;
},
setTheme: (state, action: PayloadAction<"light" | "dark" | "system">) => {
state.theme = action.payload;
},
// App State Actions
setInitializing: (state, action: PayloadAction<boolean>) => {
state.isInitializing = action.payload;
},
setOnlineStatus: (state, action: PayloadAction<boolean>) => {
state.isOnline = action.payload;
},
// Notification Actions
addNotification: (state, action: PayloadAction<Omit<AppState["notifications"][0], "id" | "timestamp" | "read">>) => {
const notification = {
...action.payload,
id: Date.now().toString(),
timestamp: Date.now(),
read: false,
};
state.notifications.unshift(notification);
// Keep only last 50 notifications
if (state.notifications.length > 50) {
state.notifications = state.notifications.slice(0, 50);
}
},
markNotificationRead: (state, action: PayloadAction<string>) => {
const notification = state.notifications.find((n) => n.id === action.payload);
if (notification) {
notification.read = true;
}
},
removeNotification: (state, action: PayloadAction<string>) => {
state.notifications = state.notifications.filter((n) => n.id !== action.payload);
},
clearAllNotifications: (state) => {
state.notifications = [];
},
markAllNotificationsRead: (state) => {
state.notifications.forEach((notification) => {
notification.read = true;
});
},
// Settings Actions
updateSettings: (state, action: PayloadAction<Partial<AppState["settings"]>>) => {
state.settings = { ...state.settings, ...action.payload };
},
// Error Actions
setGlobalError: (state, action: PayloadAction<AppState["globalError"]>) => {
state.globalError = action.payload;
},
clearGlobalError: (state) => {
state.globalError = null;
},
// Feature Flags Actions
updateFeatures: (state, action: PayloadAction<Partial<AppState["features"]>>) => {
state.features = { ...state.features, ...action.payload };
},
// Reset app state (useful for logout)
resetAppState: () => initialState,
},
});
// Export actions
export const {
// UI Actions
toggleSidebar,
setSidebarCollapsed,
setTheme,
// App State Actions
setInitializing,
setOnlineStatus,
// Notification Actions
addNotification,
markNotificationRead,
removeNotification,
clearAllNotifications,
markAllNotificationsRead,
// Settings Actions
updateSettings,
// Error Actions
setGlobalError,
clearGlobalError,
// Feature Flags Actions
updateFeatures,
// Reset
resetAppState,
} = appSlice.actions;
// Export reducer
export default appSlice.reducer;
// Selectors
export const selectSidebarCollapsed = (state: { app: AppState }) => state.app.sidebarCollapsed;
export const selectTheme = (state: { app: AppState }) => state.app.theme;
export const selectIsInitializing = (state: { app: AppState }) => state.app.isInitializing;
export const selectIsOnline = (state: { app: AppState }) => state.app.isOnline;
export const selectCurrentUser = (state: { app: AppState }) => state.app.currentUser;
export const selectNotifications = (state: { app: AppState }) => state.app.notifications;
export const selectUnreadNotificationsCount = (state: { app: AppState }) => state.app.notifications.filter((n) => !n.read).length;
export const selectSettings = (state: { app: AppState }) => state.app.settings;
export const selectGlobalError = (state: { app: AppState }) => state.app.globalError;
export const selectFeatures = (state: { app: AppState }) => state.app.features;

View File

@@ -0,0 +1,14 @@
// App slice exports
export * from "./appSlice";
export { default as appReducer } from "./appSlice";
// Provider slice exports
export * from "./providerSlice";
export { default as providerReducer } from "./providerSlice";
// Plugin slice exports
export * from "./pluginSlice";
export { default as pluginReducer } from "./pluginSlice";
// Enterprise slice exports
export * from "@enterprise/lib/store/slices";

View File

@@ -0,0 +1,70 @@
import { Plugin } from "@/lib/types/plugins";
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { pluginsApi } from "../apis";
interface PluginState {
selectedPlugin?: Plugin;
isDirty: boolean;
}
const initialState: PluginState = {
selectedPlugin: undefined,
isDirty: false,
};
const pluginSlice = createSlice({
name: "plugin",
initialState,
reducers: {
setPluginFormDirtyState: (state, action: PayloadAction<boolean>) => {
state.isDirty = action.payload;
},
setSelectedPlugin: (state, action: PayloadAction<Plugin | undefined>) => {
state.selectedPlugin = action.payload;
},
},
extraReducers: (builder) => {
// Listen to getPlugins fulfilled to update selected plugin if it has changed
builder.addMatcher(pluginsApi.endpoints.getPlugins.matchFulfilled, (state, action) => {
const updatedPlugins = action.payload;
// If we have a selected plugin, check if it has been updated
if (state.selectedPlugin && updatedPlugins) {
const updatedSelectedPlugin = updatedPlugins.find((plugin) => plugin.name === state.selectedPlugin!.name);
// If the selected plugin exists in the updated list, update it
if (updatedSelectedPlugin) {
state.selectedPlugin = updatedSelectedPlugin;
}
}
});
// Listen to updatePlugin fulfilled to update selected plugin if it's the same one
builder.addMatcher(pluginsApi.endpoints.updatePlugin.matchFulfilled, (state, action) => {
const updatedPluginName = action.meta.arg.originalArgs.name;
// If the updated plugin is the currently selected one, update it
if (state.selectedPlugin && updatedPluginName === state.selectedPlugin.name) {
// Update the selected plugin with the new data
const updatedPlugin = action.payload;
state.selectedPlugin = updatedPlugin;
}
});
// Listen to deletePlugin fulfilled to remove the plugin from the list
builder.addMatcher(pluginsApi.endpoints.deletePlugin.matchFulfilled, (state, action) => {
const deletedPluginName = action.meta.arg.originalArgs;
// If the deleted plugin was selected, clear the selection
if (state.selectedPlugin && state.selectedPlugin.name === deletedPluginName) {
state.selectedPlugin = undefined;
}
});
},
});
export const { setPluginFormDirtyState, setSelectedPlugin } = pluginSlice.actions;
export default pluginSlice.reducer;
// Selectors
export const selectSelectedPlugin = (state: { plugin: PluginState }) => state.plugin.selectedPlugin;
export const selectPluginFormIsDirty = (state: { plugin: PluginState }) => state.plugin.isDirty;

View File

@@ -0,0 +1,80 @@
import { ModelProvider } from "@/lib/types/config";
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { providersApi } from "../apis";
interface ProviderState {
selectedProvider: ModelProvider | null;
isConfigureDialogOpen: boolean;
providers: ModelProvider[];
isDirty: boolean;
}
const initialState: ProviderState = {
selectedProvider: null,
isConfigureDialogOpen: false,
providers: [],
isDirty: false,
};
const providerSlice = createSlice({
name: "provider",
initialState,
reducers: {
setProviderFormDirtyState: (state, action: PayloadAction<boolean>) => {
state.isDirty = action.payload;
},
setSelectedProvider: (state, action: PayloadAction<ModelProvider | null>) => {
state.selectedProvider = action.payload;
},
setIsConfigureDialogOpen: (state, action: PayloadAction<boolean>) => {
state.isConfigureDialogOpen = action.payload;
},
setProviders: (state, action: PayloadAction<ModelProvider[]>) => {
state.providers = action.payload;
},
openConfigureDialog: (state, action: PayloadAction<ModelProvider | null>) => {
state.selectedProvider = action.payload;
state.isConfigureDialogOpen = true;
},
closeConfigureDialog: (state) => {
state.selectedProvider = null;
state.isConfigureDialogOpen = false;
},
},
extraReducers: (builder) => {
// Listen to getProviders fulfilled to update selected provider if it has changed
builder.addMatcher(providersApi.endpoints.getProviders.matchFulfilled, (state, action) => {
const updatedProviders = action.payload;
// If we have a selected provider, check if it has been updated
if (state.selectedProvider && updatedProviders) {
const updatedSelectedProvider = updatedProviders.find((provider) => provider.name === state.selectedProvider!.name);
// If the selected provider exists in the updated list, update it
if (updatedSelectedProvider) {
// Check if the provider has actually changed
state.selectedProvider = updatedSelectedProvider;
}
}
});
// Listen to updateProvider fulfilled to update selected provider if it's the same one
builder.addMatcher(providersApi.endpoints.updateProvider.matchFulfilled, (state, action) => {
const updatedProvider = action.payload;
// If the updated provider is the currently selected one, update it
if (state.selectedProvider && updatedProvider.name === state.selectedProvider.name) {
state.selectedProvider = updatedProvider;
}
});
},
});
export const {
setProviderFormDirtyState,
setSelectedProvider,
setIsConfigureDialogOpen,
setProviders,
openConfigureDialog,
closeConfigureDialog,
} = providerSlice.actions;
export default providerSlice.reducer;

46
ui/lib/store/store.ts Normal file
View File

@@ -0,0 +1,46 @@
import { configureStore } from "@reduxjs/toolkit";
import { baseApi } from "./apis/baseApi";
import { appReducer, pluginReducer, providerReducer } from "./slices";
import { reducers as enterpriseReducers, type EnterpriseState } from "@enterprise/lib/store/slices";
// Importing enterprise APIs triggers their self-injection into baseApi
import "@enterprise/lib/store/apis";
export const store = configureStore({
reducer: {
// RTK Query API
[baseApi.reducerPath]: baseApi.reducer,
// App state slice
app: appReducer,
// Provider state slice
provider: providerReducer,
// Plugin state slice
plugin: pluginReducer,
// Enterprise reducers (if available)
...enterpriseReducers,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
// Ignore these action types for RTK Query
ignoredActions: [
"persist/PERSIST",
"persist/REHYDRATE",
"api/executeQuery/pending",
"api/executeQuery/fulfilled",
"api/executeQuery/rejected",
"api/executeMutation/pending",
"api/executeMutation/fulfilled",
"api/executeMutation/rejected",
],
// Ignore these field paths in all actions
ignoredActionsPaths: ["meta.arg", "payload.timestamp"],
// Ignore these paths in the state
ignoredPaths: ["api.queries", "api.mutations"],
},
}).concat(baseApi.middleware),
devTools: process.env.NODE_ENV !== "production",
});
export type RootState = ReturnType<typeof store.getState> & EnterpriseState;
export type AppDispatch = typeof store.dispatch;

593
ui/lib/types/config.ts Normal file
View File

@@ -0,0 +1,593 @@
// Configuration types that match the Go backend structures
import { KnownProvidersNames } from "@/lib/constants/logs";
import { EnvVar } from "./schemas";
// Known provider names - all supported standard providers
export type KnownProvider = (typeof KnownProvidersNames)[number];
// Base provider names - all supported base providers
export type BaseProvider = "openai" | "anthropic" | "cohere" | "gemini" | "bedrock" | "replicate" | "fireworks";
// Branded type for custom provider names to prevent collision with known providers
export type CustomProviderName = string & { readonly __brand: "CustomProviderName" };
// ModelProvider union - either known providers or branded custom providers
export type ModelProviderName = KnownProvider | CustomProviderName;
// Helper function to check if a provider name is a known provider
export const isKnownProvider = (provider: string): provider is KnownProvider => {
return KnownProvidersNames.includes(provider.toLowerCase() as KnownProvider);
};
// AzureKeyConfig matching Go's schemas.AzureKeyConfig
export interface AzureKeyConfig {
endpoint: EnvVar;
api_version?: EnvVar;
client_id?: EnvVar;
client_secret?: EnvVar;
tenant_id?: EnvVar;
scopes?: string[];
}
export const DefaultAzureKeyConfig: AzureKeyConfig = {
endpoint: { value: "", env_var: "", from_env: false },
api_version: { value: "2024-02-01", env_var: "", from_env: false },
client_id: { value: "", env_var: "", from_env: false },
client_secret: { value: "", env_var: "", from_env: false },
tenant_id: { value: "", env_var: "", from_env: false },
scopes: [],
} as const satisfies Required<AzureKeyConfig>;
// VertexKeyConfig matching Go's schemas.VertexKeyConfig
export interface VertexKeyConfig {
project_id: EnvVar;
project_number?: EnvVar;
region: EnvVar;
auth_credentials?: EnvVar;
}
export const DefaultVertexKeyConfig: VertexKeyConfig = {
project_id: { value: "", env_var: "", from_env: false },
project_number: { value: "", env_var: "", from_env: false },
region: { value: "", env_var: "", from_env: false },
auth_credentials: { value: "", env_var: "", from_env: false },
} as const satisfies Required<VertexKeyConfig>;
export interface S3BucketConfig {
bucket_name: string;
prefix?: string;
is_default?: boolean;
}
export interface BatchS3Config {
buckets?: S3BucketConfig[];
}
// BedrockKeyConfig matching Go's schemas.BedrockKeyConfig
export interface BedrockKeyConfig {
access_key?: EnvVar;
secret_key?: EnvVar;
session_token?: EnvVar;
region?: EnvVar;
arn?: EnvVar;
batch_s3_config?: BatchS3Config;
}
// Default BedrockKeyConfig
export const DefaultBedrockKeyConfig: BedrockKeyConfig = {
access_key: { value: "", env_var: "", from_env: false },
secret_key: { value: "", env_var: "", from_env: false },
session_token: undefined as unknown as EnvVar,
region: { value: "us-east-1", env_var: "", from_env: false },
arn: { value: "", env_var: "", from_env: false },
batch_s3_config: undefined as unknown as BatchS3Config,
} as const satisfies Required<BedrockKeyConfig>;
// VLLMKeyConfig matching Go's schemas.VLLMKeyConfig
export interface VLLMKeyConfig {
url: EnvVar;
model_name: string;
}
// Default VLLMKeyConfig
export const DefaultVLLMKeyConfig: VLLMKeyConfig = {
url: { value: "", env_var: "", from_env: false },
model_name: "",
} as const satisfies Required<VLLMKeyConfig>;
// ReplicateKeyConfig matching Go's schemas.ReplicateKeyConfig
export interface ReplicateKeyConfig {
use_deployments_endpoint: boolean;
}
// Default ReplicateKeyConfig
export const DefaultReplicateKeyConfig: ReplicateKeyConfig = {
use_deployments_endpoint: false,
} as const satisfies Required<ReplicateKeyConfig>;
// OllamaKeyConfig matching Go's schemas.OllamaKeyConfig
export interface OllamaKeyConfig {
url: EnvVar;
}
// Default OllamaKeyConfig
export const DefaultOllamaKeyConfig: OllamaKeyConfig = {
url: { value: "", env_var: "", from_env: false },
} as const satisfies Required<OllamaKeyConfig>;
// SGLKeyConfig matching Go's schemas.SGLKeyConfig
export interface SGLKeyConfig {
url: EnvVar;
}
// Default SGLKeyConfig
export const DefaultSGLKeyConfig: SGLKeyConfig = {
url: { value: "", env_var: "", from_env: false },
} as const satisfies Required<SGLKeyConfig>;
// Key structure matching Go's schemas.Key
export interface ModelProviderKey {
id: string;
name: string;
value?: EnvVar;
models?: string[];
blacklisted_models?: string[];
weight: number;
enabled?: boolean;
use_for_batch_api?: boolean;
aliases?: Record<string, string>;
azure_key_config?: AzureKeyConfig;
vertex_key_config?: VertexKeyConfig;
bedrock_key_config?: BedrockKeyConfig;
vllm_key_config?: VLLMKeyConfig;
replicate_key_config?: ReplicateKeyConfig;
ollama_key_config?: OllamaKeyConfig;
sgl_key_config?: SGLKeyConfig;
config_hash?: string; // Present when config is synced from config.json
status?: "unknown" | "success" | "list_models_failed";
description?: string;
}
// Default ModelProviderKey
export const DefaultModelProviderKey: ModelProviderKey = {
id: "",
name: "",
value: {
value: "",
env_var: "",
from_env: false,
},
models: [],
blacklisted_models: [],
weight: 1.0,
enabled: true,
};
// NetworkConfig matching Go's schemas.NetworkConfig
export interface NetworkConfig {
base_url?: string;
is_key_less?: boolean;
extra_headers?: Record<string, string>;
default_request_timeout_in_seconds: number;
max_retries: number;
retry_backoff_initial: number; // Duration in milliseconds
retry_backoff_max: number; // Duration in milliseconds
insecure_skip_verify?: boolean;
ca_cert_pem?: string;
stream_idle_timeout_in_seconds?: number;
max_conns_per_host?: number;
enforce_http2?: boolean;
beta_header_overrides?: Record<string, boolean>;
}
// ConcurrencyAndBufferSize matching Go's schemas.ConcurrencyAndBufferSize
export interface ConcurrencyAndBufferSize {
concurrency: number;
buffer_size: number;
}
// Proxy types matching Go's schemas.ProxyType
export type ProxyType = "none" | "http" | "socks5" | "environment";
// ProxyConfig matching Go's schemas.ProxyConfig
export interface ProxyConfig {
type: ProxyType;
url?: string;
username?: string;
password?: string;
ca_cert_pem?: string;
}
// Request types matching Go's schemas.RequestType
export type RequestType =
| "list_models"
| "text_completion"
| "text_completion_stream"
| "chat_completion"
| "chat_completion_stream"
| "responses"
| "responses_stream"
| "embedding"
| "rerank"
| "speech"
| "speech_stream"
| "transcription"
| "transcription_stream"
| "image_generation"
| "image_generation_stream"
| "image_edit"
| "image_edit_stream"
| "image_variation"
| "ocr"
| "ocr_stream"
| "video_generation"
| "video_retrieve"
| "video_download"
| "video_delete"
| "video_list"
| "video_remix"
| "count_tokens"
| "batch_create"
| "batch_list"
| "batch_retrieve"
| "batch_cancel"
| "batch_results"
| "file_upload"
| "file_list"
| "file_retrieve"
| "file_delete"
| "file_content"
| "mcp_tool_execution"
| "container_create"
| "container_list"
| "container_retrieve"
| "container_delete"
| "container_file_create"
| "container_file_list"
| "container_file_retrieve"
| "container_file_content"
| "container_file_delete"
| "websocket_responses"
| "realtime";
// AllowedRequests matching Go's schemas.AllowedRequests
export interface AllowedRequests {
text_completion: boolean;
text_completion_stream: boolean;
chat_completion: boolean;
chat_completion_stream: boolean;
responses: boolean;
responses_stream: boolean;
embedding: boolean;
speech: boolean;
speech_stream: boolean;
transcription: boolean;
transcription_stream: boolean;
image_generation: boolean;
image_generation_stream: boolean;
image_edit: boolean;
image_edit_stream: boolean;
image_variation: boolean;
ocr: boolean;
ocr_stream: boolean;
count_tokens: boolean;
list_models: boolean;
rerank: boolean;
video_generation: boolean;
video_retrieve: boolean;
video_download: boolean;
video_delete: boolean;
video_list: boolean;
video_remix: boolean;
websocket_responses: boolean;
realtime: boolean;
}
// CustomProviderConfig matching Go's schemas.CustomProviderConfig
export interface CustomProviderConfig {
base_provider_type: KnownProvider;
is_key_less?: boolean;
allowed_requests?: AllowedRequests;
request_path_overrides?: Record<string, string>;
}
// OpenAIConfig holds OpenAI-specific provider configuration.
export interface OpenAIConfig {
disable_store?: boolean;
}
// ProviderConfig matching Go's lib.ProviderConfig
export interface ModelProviderConfig {
network_config?: NetworkConfig;
concurrency_and_buffer_size?: ConcurrencyAndBufferSize;
proxy_config?: ProxyConfig;
send_back_raw_request?: boolean;
send_back_raw_response?: boolean;
store_raw_request_response?: boolean;
custom_provider_config?: CustomProviderConfig;
openai_config?: OpenAIConfig;
status?: "unknown" | "success" | "list_models_failed";
description?: string;
}
// ProviderResponse matching Go's ProviderResponse
export interface ModelProvider extends ModelProviderConfig {
name: ModelProviderName;
provider_status: ProviderStatus;
config_hash?: string; // Present when config is synced from config.json
}
// ListProvidersResponse matching Go's ListProvidersResponse
export interface ListProvidersResponse {
providers?: ModelProvider[];
total: number;
}
// AddProviderRequest matching Go's AddProviderRequest
export interface AddProviderRequest {
provider: ModelProviderName;
network_config?: NetworkConfig;
concurrency_and_buffer_size?: ConcurrencyAndBufferSize;
proxy_config?: ProxyConfig;
send_back_raw_request?: boolean;
send_back_raw_response?: boolean;
store_raw_request_response?: boolean;
custom_provider_config?: CustomProviderConfig;
openai_config?: OpenAIConfig;
}
// UpdateProviderRequest matching Go's UpdateProviderRequest
export interface UpdateProviderRequest {
network_config: NetworkConfig;
concurrency_and_buffer_size: ConcurrencyAndBufferSize;
proxy_config?: ProxyConfig;
send_back_raw_request?: boolean;
send_back_raw_response?: boolean;
store_raw_request_response?: boolean;
custom_provider_config?: CustomProviderConfig;
openai_config?: OpenAIConfig;
}
export interface CreateProviderKeyRequest extends ModelProviderKey {}
export interface UpdateProviderKeyRequest extends ModelProviderKey {}
export interface ListProviderKeysResponse {
keys: ModelProviderKey[];
total: number;
}
// BifrostErrorResponse matching Go's schemas.BifrostError
export interface BifrostErrorResponse {
event_id?: string;
type?: string;
is_bifrost_error: boolean;
status_code?: number;
error: {
message: string;
type?: string;
code?: string;
param?: string;
};
}
// LatestReleaseResponse matching Go's LatestReleaseResponse
export interface LatestReleaseResponse {
name: string;
changelogUrl: string;
}
export interface FrameworkConfig {
id: number;
pricing_url: string;
pricing_sync_interval: number;
}
// Auth config
export interface AuthConfig {
admin_username: EnvVar;
admin_password: EnvVar;
is_enabled: boolean;
disable_auth_on_inference?: boolean;
}
// Global proxy type (for global proxy configuration, not per-provider)
export type GlobalProxyType = "http" | "socks5" | "tcp";
// Global proxy configuration matching Go's tables.GlobalProxyConfig
export interface GlobalProxyConfig {
enabled: boolean;
type: GlobalProxyType;
url: string;
username?: string;
password?: string;
ca_cert_pem?: string;
no_proxy?: string;
timeout?: number;
skip_tls_verify?: boolean;
enable_for_scim: boolean;
enable_for_inference: boolean;
enable_for_api: boolean;
}
// Default GlobalProxyConfig
export const DefaultGlobalProxyConfig: GlobalProxyConfig = {
enabled: false,
type: "http",
url: "",
username: "",
password: "",
no_proxy: "",
timeout: 30,
skip_tls_verify: false,
enable_for_scim: false,
enable_for_inference: false,
enable_for_api: false,
};
// Global header filter configuration matching Go's tables.GlobalHeaderFilterConfig
// Controls which headers with the x-bf-eh-* prefix are forwarded to LLM providers
export interface GlobalHeaderFilterConfig {
allowlist?: string[]; // If non-empty, only these headers are allowed
denylist?: string[]; // Headers to always block
}
// Default GlobalHeaderFilterConfig
export const DefaultGlobalHeaderFilterConfig: GlobalHeaderFilterConfig = {
allowlist: [],
denylist: [],
};
// Restart required configuration
export interface RestartRequiredConfig {
required: boolean;
reason?: string;
}
// Bifrost Config
export interface BifrostConfig {
client_config: CoreConfig;
framework_config: FrameworkConfig;
auth_config?: AuthConfig;
proxy_config?: GlobalProxyConfig;
restart_required?: RestartRequiredConfig;
is_db_connected: boolean;
is_cache_connected: boolean;
is_logs_connected: boolean;
auth_token?: string;
}
export interface CompatConfig {
convert_text_to_chat: boolean;
convert_chat_to_responses: boolean;
should_drop_params: boolean;
should_convert_params: boolean;
}
// Core Bifrost configuration types
export interface CoreConfig {
drop_excess_requests: boolean;
initial_pool_size: number;
prometheus_labels: string[];
enable_logging: boolean;
disable_content_logging: boolean;
disable_db_pings_in_health: boolean;
log_retention_days: number;
enforce_auth_on_inference: boolean;
allow_direct_keys: boolean;
allowed_origins: string[];
allowed_headers: string[];
max_request_body_size_mb: number;
compat: CompatConfig;
mcp_agent_depth: number;
mcp_tool_execution_timeout: number;
mcp_code_mode_binding_level?: string;
mcp_tool_sync_interval: number;
mcp_disable_auto_tool_inject: boolean;
async_job_result_ttl: number;
required_headers: string[];
logging_headers: string[];
whitelisted_routes: string[];
hide_deleted_virtual_keys_in_filters: boolean;
routing_chain_max_depth: number;
header_filter_config?: GlobalHeaderFilterConfig;
}
export const DefaultCoreConfig: CoreConfig = {
drop_excess_requests: false,
initial_pool_size: 1000,
prometheus_labels: [],
enable_logging: true,
disable_content_logging: false,
disable_db_pings_in_health: false,
log_retention_days: 365,
enforce_auth_on_inference: false,
allow_direct_keys: false,
allowed_origins: [],
max_request_body_size_mb: 100,
compat: { convert_text_to_chat: false, convert_chat_to_responses: false, should_drop_params: false, should_convert_params: false },
mcp_agent_depth: 10,
mcp_tool_execution_timeout: 30,
mcp_code_mode_binding_level: "server",
mcp_tool_sync_interval: 10,
mcp_disable_auto_tool_inject: false,
async_job_result_ttl: 3600,
allowed_headers: [],
required_headers: [],
logging_headers: [],
whitelisted_routes: [],
hide_deleted_virtual_keys_in_filters: false,
routing_chain_max_depth: 10,
};
// Semantic cache configuration types
interface BaseCacheConfig {
ttl_seconds: number;
threshold: number;
conversation_history_threshold?: number;
exclude_system_prompt?: boolean;
cache_by_model: boolean;
cache_by_provider: boolean;
created_at?: string;
updated_at?: string;
}
export interface DirectCacheConfig extends BaseCacheConfig {
dimension: 1;
provider?: undefined;
keys?: ModelProviderKey[];
embedding_model?: undefined;
}
export interface ProviderBackedCacheConfig extends BaseCacheConfig {
provider: ModelProviderName;
keys?: ModelProviderKey[];
embedding_model: string;
dimension: number;
}
export type CacheConfig = DirectCacheConfig | ProviderBackedCacheConfig;
export interface EditorCacheConfig extends BaseCacheConfig {
provider?: ModelProviderName;
keys?: ModelProviderKey[];
embedding_model?: string;
dimension?: number;
}
// Maxim configuration types
export interface MaximConfig {
api_key: string;
log_repo_id: string;
}
// Form-specific custom provider config that allows any string for base_provider_type
export interface FormCustomProviderConfig extends Omit<CustomProviderConfig, "base_provider_type"> {
base_provider_type: string;
}
// Form-specific provider type that allows any string for name
export interface FormModelProvider extends Omit<ModelProvider, "name" | "custom_provider_config"> {
name: string;
custom_provider_config?: FormCustomProviderConfig;
}
// Utility types for form handling
export interface ProviderFormData {
provider: FormModelProvider;
keys: ModelProviderKey[];
network_config?: {
baseURL?: string;
defaultRequestTimeoutInSeconds: number;
maxRetries: number;
};
concurrency_and_buffer_size?: {
concurrency: number;
bufferSize: number;
};
custom_provider_config?: FormCustomProviderConfig;
}
// Status types
export type ProviderStatus = "active" | "error" | "deleted";

529
ui/lib/types/governance.ts Normal file
View File

@@ -0,0 +1,529 @@
// Governance types that match the Go backend structures
import { ModelProviderName, RequestType } from "./config";
export interface Budget {
id: string;
max_limit: number; // In dollars
reset_duration: string; // e.g., "30s", "5m", "1h", "1d", "1w", "1M"
current_usage: number; // In dollars
last_reset: string; // ISO timestamp
calendar_aligned?: boolean; // When true, resets at clean calendar boundaries (day/week/month/year start)
}
export interface RateLimit {
id: string;
// Flexible token limits
token_max_limit?: number; // Maximum tokens allowed
token_reset_duration?: string; // e.g., "30s", "5m", "1h", "1d", "1w", "1M"
token_current_usage: number; // Current token usage
token_last_reset: string; // ISO timestamp
// Flexible request limits
request_max_limit?: number; // Maximum requests allowed
request_reset_duration?: string; // e.g., "30s", "5m", "1h", "1d", "1w", "1M"
request_current_usage: number; // Current request usage
request_last_reset: string; // ISO timestamp
}
export interface Team {
id: string;
name: string;
customer_id?: string;
rate_limit_id?: string;
// Populated relationships
customer?: Customer;
budgets?: Budget[]; // Multi-budget: each with a distinct reset_duration
rate_limit?: RateLimit;
}
export interface Customer {
id: string;
name: string;
budget_id?: string;
rate_limit_id?: string;
// Populated relationships
teams?: Team[];
budget?: Budget;
rate_limit?: RateLimit;
}
export interface DBKey {
key_id: string; // UUID identifier for the key
name: string; // Name of the key
provider_id: string; // identifier for the provider
models: string[]; // List of models this key can access
provider: ModelProviderName; // Provider name
}
export interface RedactedDBKey {
id: string;
name: string;
models: string[];
weight: number;
}
export interface VirtualKey {
id: string;
name: string;
value: string; // The actual key value
description?: string;
provider_configs?: VirtualKeyProviderConfig[];
mcp_configs?: VirtualKeyMCPConfig[];
team_id?: string;
customer_id?: string;
rate_limit_id?: string;
is_active: boolean;
calendar_aligned?: boolean;
created_at: string;
updated_at: string;
// Populated relationships
team?: Team;
customer?: Customer;
budgets?: Budget[];
rate_limit?: RateLimit;
config_hash?: string; // Present when config is synced from config.json
}
// Provider config budgets don't have calendar_aligned (it's a VK-level field)
export type ProviderConfigBudget = Omit<Budget, "calendar_aligned">;
export interface VirtualKeyProviderConfig {
id?: number;
provider: string;
weight: number | null;
allowed_models: string[];
allow_all_keys: boolean; // True means all keys allowed; false with empty keys means no keys allowed
budgets?: ProviderConfigBudget[];
rate_limit?: RateLimit;
keys?: DBKey[]; // Associated database keys for this provider (only used when allow_all_keys is false)
}
export interface VirtualKeyMCPConfig {
id?: number;
virtual_key_id?: string;
mcp_client_id?: number;
mcp_client?: {
id: number;
name: string;
connection_type: string;
connection_string?: string;
tools_to_execute: string[];
created_at: string;
updated_at: string;
};
tools_to_execute?: string[];
}
// Request interfaces for create/update operations (still use mcp_client_name)
export interface VirtualKeyMCPConfigRequest {
id?: number;
mcp_client_name: string;
tools_to_execute?: string[];
}
export interface UsageStats {
virtual_key_id: string;
provider?: string;
model?: string;
tokens_current_usage: number;
requests_current_usage: number;
tokens_last_reset: string;
requests_last_reset: string;
}
// Request interfaces for provider config operations
export interface VirtualKeyProviderConfigRequest {
provider: string;
weight?: number | null;
allowed_models?: string[];
budgets?: ProviderConfigBudgetRequest[];
rate_limit?: CreateRateLimitRequest;
key_ids?: string[]; // List of DBKey UUIDs to associate with this provider config
}
export interface VirtualKeyProviderConfigUpdateRequest {
id?: number;
provider: string;
weight?: number | null;
allowed_models?: string[];
budgets?: ProviderConfigBudgetRequest[];
rate_limit?: UpdateRateLimitRequest;
key_ids?: string[]; // List of DBKey UUIDs to associate with this provider config
}
// VK-level budgets don't include calendar_aligned (it's a VK-level field, not per-budget)
export type VirtualKeyBudgetRequest = Omit<CreateBudgetRequest, "calendar_aligned">;
// Request types for API calls
export interface CreateVirtualKeyRequest {
name: string;
description?: string;
provider_configs?: VirtualKeyProviderConfigRequest[];
mcp_configs?: VirtualKeyMCPConfigRequest[];
team_id?: string;
customer_id?: string;
budgets?: VirtualKeyBudgetRequest[];
rate_limit?: CreateRateLimitRequest;
is_active?: boolean;
calendar_aligned?: boolean;
}
export interface UpdateVirtualKeyRequest {
name?: string;
description?: string;
provider_configs?: VirtualKeyProviderConfigUpdateRequest[];
mcp_configs?: VirtualKeyMCPConfigRequest[];
team_id?: string;
customer_id?: string;
budgets?: VirtualKeyBudgetRequest[];
rate_limit?: UpdateRateLimitRequest;
is_active?: boolean;
calendar_aligned?: boolean;
}
export interface CreateTeamRequest {
name: string;
customer_id?: string;
budgets?: CreateBudgetRequest[]; // Multi-budget: each must have a unique reset_duration
rate_limit?: CreateRateLimitRequest;
}
export interface UpdateTeamRequest {
name?: string;
customer_id?: string;
budgets?: CreateBudgetRequest[]; // Replaces all team budgets; empty array clears
rate_limit?: UpdateRateLimitRequest;
}
export interface CreateCustomerRequest {
name: string;
budget?: CreateBudgetRequest;
rate_limit?: CreateRateLimitRequest;
}
export interface UpdateCustomerRequest {
name?: string;
budget?: UpdateBudgetRequest;
rate_limit?: UpdateRateLimitRequest;
}
export interface CreateBudgetRequest {
max_limit: number; // In dollars
reset_duration: string; // e.g., "30s", "5m", "1h", "1d", "1w", "1M"
calendar_aligned?: boolean; // Snap resets to calendar boundaries (day/week/month/year)
}
// Provider config budget requests don't include calendar_aligned (it's a VK-level field)
export type ProviderConfigBudgetRequest = Omit<CreateBudgetRequest, "calendar_aligned">;
export interface UpdateBudgetRequest {
max_limit?: number;
reset_duration?: string;
calendar_aligned?: boolean; // When switching to true, current usage is reset to 0
}
export interface CreateRateLimitRequest {
token_max_limit?: number; // Maximum tokens allowed
token_reset_duration?: string; // e.g., "30s", "5m", "1h", "1d", "1w", "1M"
request_max_limit?: number; // Maximum requests allowed
request_reset_duration?: string; // e.g., "30s", "5m", "1h", "1d", "1w", "1M"
}
export interface UpdateRateLimitRequest {
token_max_limit?: number | null; // Maximum tokens allowed (null to clear)
token_reset_duration?: string | null; // e.g., "30s", "5m", "1h", "1d", "1w", "1M" (null to clear)
request_max_limit?: number | null; // Maximum requests allowed (null to clear)
request_reset_duration?: string | null; // e.g., "30s", "5m", "1h", "1d", "1w", "1M" (null to clear)
}
export interface ResetUsageRequest {
virtual_key_id: string;
provider?: string;
model?: string;
}
// Query params
export interface GetVirtualKeysParams {
limit?: number;
offset?: number;
search?: string;
customer_id?: string;
team_id?: string;
exclude_access_profile_managed_virtual?: boolean;
sort_by?: "name" | "budget_spent" | "created_at" | "status";
order?: "asc" | "desc";
export?: boolean;
}
// Response types
export interface GetVirtualKeysResponse {
virtual_keys: VirtualKey[];
count: number;
total_count: number;
limit: number;
offset: number;
}
export interface GetTeamsParams {
limit?: number;
offset?: number;
search?: string;
customer_id?: string;
}
export interface GetTeamsResponse {
teams: Team[];
count: number;
total_count: number;
limit: number;
offset: number;
}
export interface GetCustomersParams {
limit?: number;
offset?: number;
search?: string;
}
export interface GetCustomersResponse {
customers: Customer[];
count: number;
total_count: number;
limit: number;
offset: number;
}
export interface GetBudgetsResponse {
budgets: Budget[];
count: number;
}
export interface GetRateLimitsResponse {
rate_limits: RateLimit[];
count: number;
}
export interface GetUsageStatsResponse {
virtual_key_id?: string;
usage_stats: UsageStats | UsageStats[];
}
export interface DebugStatsResponse {
plugin_stats: Record<string, any>;
database_stats: {
virtual_keys_count: number;
teams_count: number;
customers_count: number;
budgets_count: number;
rate_limits_count: number;
usage_tracking_count: number;
audit_logs_count: number;
};
timestamp: string;
}
export interface HealthCheckResponse {
status: "healthy" | "unhealthy" | "warning";
timestamp: string;
checks: Record<
string,
{
status: "healthy" | "unhealthy" | "warning";
error?: string;
message?: string;
}
>;
}
// Model Config for per-model budgeting and rate limiting
export interface ModelConfig {
id: string;
model_name: string;
provider?: string; // Optional provider - if empty/null, applies to all providers
budget_id?: string;
rate_limit_id?: string;
// Populated relationships
budget?: Budget;
rate_limit?: RateLimit;
created_at: string;
updated_at: string;
}
// Request types for model config operations
export interface CreateModelConfigRequest {
model_name: string;
provider?: string; // Optional provider - if empty/null, applies to all providers
budget?: CreateBudgetRequest;
rate_limit?: CreateRateLimitRequest;
}
export interface UpdateModelConfigRequest {
model_name?: string;
provider?: string; // Optional provider - if empty/null, applies to all providers
budget?: UpdateBudgetRequest;
rate_limit?: UpdateRateLimitRequest;
}
export interface GetModelConfigsParams {
limit?: number;
offset?: number;
search?: string;
}
// Response types for model configs
export interface GetModelConfigsResponse {
model_configs: ModelConfig[];
count: number;
total_count: number;
limit: number;
offset: number;
}
export type PricingOverrideScopeKind =
| "global"
| "provider"
| "provider_key"
| "virtual_key"
| "virtual_key_provider"
| "virtual_key_provider_key";
export type PricingOverrideMatchType = "exact" | "wildcard";
export interface PricingOverridePatch {
// Token
input_cost_per_token?: number;
output_cost_per_token?: number;
input_cost_per_token_batches?: number;
output_cost_per_token_batches?: number;
input_cost_per_token_priority?: number;
output_cost_per_token_priority?: number;
input_cost_per_token_flex?: number;
output_cost_per_token_flex?: number;
input_cost_per_character?: number;
// 128k tier
input_cost_per_token_above_128k_tokens?: number;
output_cost_per_token_above_128k_tokens?: number;
input_cost_per_image_above_128k_tokens?: number;
input_cost_per_video_per_second_above_128k_tokens?: number;
input_cost_per_audio_per_second_above_128k_tokens?: number;
// 200k tier
input_cost_per_token_above_200k_tokens?: number;
input_cost_per_token_above_200k_tokens_priority?: number;
output_cost_per_token_above_200k_tokens?: number;
output_cost_per_token_above_200k_tokens_priority?: number;
// 272k tier
input_cost_per_token_above_272k_tokens?: number;
input_cost_per_token_above_272k_tokens_priority?: number;
output_cost_per_token_above_272k_tokens?: number;
output_cost_per_token_above_272k_tokens_priority?: number;
// Cache
cache_creation_input_token_cost?: number;
cache_read_input_token_cost?: number;
cache_creation_input_token_cost_above_200k_tokens?: number;
cache_read_input_token_cost_above_200k_tokens?: number;
cache_read_input_token_cost_above_200k_tokens_priority?: number;
cache_creation_input_token_cost_above_1hr?: number;
cache_creation_input_token_cost_above_1hr_above_200k_tokens?: number;
cache_creation_input_audio_token_cost?: number;
cache_read_input_token_cost_priority?: number;
cache_read_input_token_cost_flex?: number;
cache_read_input_image_token_cost?: number;
cache_read_input_token_cost_above_272k_tokens?: number;
cache_read_input_token_cost_above_272k_tokens_priority?: number;
// Image
input_cost_per_image_token?: number;
output_cost_per_image_token?: number;
input_cost_per_image?: number;
input_cost_per_pixel?: number;
output_cost_per_image?: number;
output_cost_per_pixel?: number;
output_cost_per_image_premium_image?: number;
output_cost_per_image_above_512_and_512_pixels?: number;
output_cost_per_image_above_512_and_512_pixels_and_premium_image?: number;
output_cost_per_image_above_1024_and_1024_pixels?: number;
output_cost_per_image_above_1024_and_1024_pixels_and_premium_image?: number;
output_cost_per_image_low_quality?: number;
output_cost_per_image_medium_quality?: number;
output_cost_per_image_high_quality?: number;
output_cost_per_image_auto_quality?: number;
// Audio/Video
input_cost_per_audio_token?: number;
input_cost_per_audio_per_second?: number;
input_cost_per_second?: number;
input_cost_per_video_per_second?: number;
output_cost_per_audio_token?: number;
output_cost_per_video_per_second?: number;
output_cost_per_second?: number;
// Other
search_context_cost_per_query?: number;
code_interpreter_cost_per_session?: number;
// OCR
ocr_cost_per_page?: number;
annotation_cost_per_page?: number;
}
export interface PricingOverride {
id: string;
name: string;
scope_kind: PricingOverrideScopeKind;
virtual_key_id?: string;
provider_id?: string;
provider_key_id?: string;
match_type: PricingOverrideMatchType;
pattern: string;
request_types?: RequestType[];
pricing_patch: string;
config_hash?: string;
created_at: string;
updated_at: string;
}
export interface CreatePricingOverrideRequest {
name: string;
scope_kind: PricingOverrideScopeKind;
virtual_key_id?: string;
provider_id?: string;
provider_key_id?: string;
match_type: PricingOverrideMatchType;
pattern: string;
request_types: RequestType[];
patch?: PricingOverridePatch;
}
export interface UpdatePricingOverrideRequest {
name?: string;
scope_kind?: PricingOverrideScopeKind;
virtual_key_id?: string;
provider_id?: string;
provider_key_id?: string;
match_type?: PricingOverrideMatchType;
pattern?: string;
request_types?: string[];
patch?: PricingOverridePatch;
}
export interface GetPricingOverridesResponse {
pricing_overrides: PricingOverride[];
count: number;
total_count: number;
limit: number;
offset: number;
}
// Provider governance - for extending provider with budget/rate limit
export interface ProviderGovernance {
provider: string;
budget_id?: string;
rate_limit_id?: string;
budget?: Budget;
rate_limit?: RateLimit;
}
export interface UpdateProviderGovernanceRequest {
budget?: UpdateBudgetRequest;
rate_limit?: UpdateRateLimitRequest;
}
export interface GetProviderGovernanceResponse {
providers: ProviderGovernance[];
count: number;
}

1221
ui/lib/types/logs.ts Normal file

File diff suppressed because it is too large Load Diff

138
ui/lib/types/mcp.ts Normal file
View File

@@ -0,0 +1,138 @@
import { Function as ToolFunction } from "./logs";
import { EnvVar } from "./schemas";
export type MCPConnectionType = "http" | "stdio" | "sse";
export type MCPConnectionState = "connected" | "disconnected" | "error";
export type MCPAuthType = "none" | "headers" | "oauth" | "per_user_oauth";
export type { EnvVar };
export interface MCPStdioConfig {
command: string;
args: string[];
envs: string[];
}
export interface OAuthConfig {
client_id: string;
client_secret?: string; // Optional for public clients using PKCE
authorize_url?: string; // Optional, will be discovered from server_url if not provided
token_url?: string; // Optional, will be discovered from server_url if not provided
registration_url?: string; // Optional, for dynamic client registration
scopes?: string[]; // Optional, can be discovered
server_url?: string; // MCP server URL for OAuth discovery (automatically set from connection_string)
}
export interface MCPClientConfig {
client_id: string; // Maps to ClientID in TableMCPClient
name: string;
is_code_mode_client?: boolean;
connection_type: MCPConnectionType;
connection_string?: EnvVar;
stdio_config?: MCPStdioConfig;
auth_type?: MCPAuthType;
oauth_config_id?: string;
tools_to_execute?: string[];
tools_to_auto_execute?: string[];
headers?: Record<string, EnvVar>;
is_ping_available?: boolean;
tool_pricing?: Record<string, number>;
tool_sync_interval?: number; // Per-client override in minutes (0 = use global, -1 = disabled)
allowed_extra_headers?: string[]; // Allowlist of x-bf-eh-* headers forwarded to this MCP server. ["*"] = allow all.
allow_on_all_virtual_keys?: boolean; // When true, available to all VKs with all tools allowed by default; explicit VK config overrides this
}
export interface MCPVKConfigResponse {
virtual_key_id: string;
virtual_key_name: string;
tools_to_execute: string[];
}
export interface MCPClient {
config: MCPClientConfig;
tools: ToolFunction[];
state: MCPConnectionState;
vk_configs: MCPVKConfigResponse[];
}
export interface CreateMCPClientRequest {
name: string;
is_code_mode_client?: boolean;
connection_type: MCPConnectionType;
connection_string?: EnvVar;
stdio_config?: MCPStdioConfig;
auth_type?: MCPAuthType;
oauth_config?: OAuthConfig;
tools_to_execute?: string[];
tools_to_auto_execute?: string[];
headers?: Record<string, EnvVar>;
is_ping_available?: boolean;
}
export interface OAuthFlowResponse {
status: "pending_oauth";
message: string;
oauth_config_id: string;
authorize_url: string;
expires_at: string;
mcp_client_id: string;
}
export interface OAuthStatusResponse {
id: string;
status: "pending" | "authorized" | "failed" | "expired" | "revoked";
created_at: string;
expires_at: string;
token_id?: string;
token_expires_at?: string;
token_scopes?: string;
}
export interface MCPVKConfig {
virtual_key_id: string;
tools_to_execute: string[];
}
export interface UpdateMCPClientRequest {
name?: string;
is_code_mode_client?: boolean;
headers?: Record<string, EnvVar>;
tools_to_execute?: string[];
tools_to_auto_execute?: string[];
is_ping_available?: boolean;
tool_pricing?: Record<string, number>;
tool_sync_interval?: number; // Per-client override in minutes (0 = use global, -1 = disabled)
allowed_extra_headers?: string[]; // Allowlist of x-bf-eh-* headers forwarded to this MCP server. ["*"] = allow all.
allow_on_all_virtual_keys?: boolean; // When true, available to all VKs with all tools allowed by default; explicit VK config overrides this
vk_configs?: MCPVKConfig[]; // When provided, replaces all VK assignments for this MCP client
}
// Pagination params for MCP clients list
export interface GetMCPClientsParams {
limit?: number;
offset?: number;
search?: string;
}
// Paginated response for MCP clients list
export interface GetMCPClientsResponse {
clients: MCPClient[];
count: number;
total_count: number;
limit: number;
offset: number;
}
// Types for MCP Tool Selector component
export interface SelectedTool {
mcpClientId: string;
toolName: string;
}
// MCP Tool Spec for tool groups (matches backend schema)
export interface MCPToolSpec {
mcp_client_id: string;
tool_names: string[];
}

47
ui/lib/types/plugins.ts Normal file
View File

@@ -0,0 +1,47 @@
// Plugins types that match the Go backend structures
export const SEMANTIC_CACHE_PLUGIN = "semantic_cache";
export const MAXIM_PLUGIN = "maxim";
export type PluginType = "llm" | "mcp" | "http";
export interface PluginStatus {
name: string;
status: string;
logs: string[];
types: PluginType[];
}
export interface Plugin {
name: string;
actualName?: string;
enabled: boolean;
config: any;
isCustom: boolean;
path?: string;
status?: PluginStatus;
placement?: string;
order?: number;
}
export interface PluginsResponse {
plugins: Plugin[];
count: number;
}
export interface CreatePluginRequest {
name: string;
path: string;
enabled: boolean;
config: any;
placement?: string;
order?: number;
}
export interface UpdatePluginRequest {
enabled: boolean;
path?: string;
config?: any;
placement?: string;
order?: number;
}

255
ui/lib/types/prompts.ts Normal file
View File

@@ -0,0 +1,255 @@
// Prompt Repository types for frontend
import type { SerializedMessage } from "@/lib/message";
export interface PromptUser {
id: string;
name: string;
email: string;
}
export type { MessageContent, MessageFile, MessageImageURL, MessageInputAudio, SerializedMessage } from "@/lib/message";
export interface Folder {
id: string;
name: string;
description?: string;
created_by_id?: number;
created_by?: PromptUser;
created_at: string;
updated_at: string;
prompts_count?: number;
}
export interface Prompt {
id: string;
name: string;
folder_id?: string;
folder?: Folder;
created_by_id?: number;
created_by?: PromptUser;
created_at: string;
updated_at: string;
latest_version?: PromptVersion;
}
export interface ModelParams {
temperature?: number;
max_tokens?: number;
top_p?: number;
frequency_penalty?: number;
presence_penalty?: number;
stop?: string[];
[key: string]: any;
}
export interface PromptVersion {
id: number;
prompt_id: string;
version_number: number;
commit_message: string;
messages: PromptVersionMessage[];
model_params: ModelParams;
provider: string;
model: string;
variables?: Record<string, string>;
is_latest: boolean;
created_by_id?: number;
created_by?: PromptUser;
created_at: string; // No updated_at - versions are immutable
}
export interface PromptVersionMessage {
id: number;
prompt_id: string;
version_id: number;
order_index: number;
message: PromptMessage;
}
export interface PromptSession {
id: number;
prompt_id: string;
prompt?: Prompt;
version_id?: number;
version?: PromptVersion;
name: string;
messages: PromptSessionMessage[];
model_params: ModelParams;
provider: string;
model: string;
variables?: Record<string, string>;
created_by_id?: number;
created_by?: PromptUser;
created_at: string;
updated_at: string;
}
export interface PromptSessionMessage {
id: number;
prompt_id: string;
session_id: number;
order_index: number;
message: PromptMessage;
}
// ============================================================================
// Message Types (OpenAI-compatible format)
// ============================================================================
export type PromptMessage = SerializedMessage;
// ============================================================================
// API Request/Response Types - Folders
// ============================================================================
export interface GetFoldersResponse {
folders: Folder[];
}
export interface GetFolderResponse {
folder: Folder;
}
export interface CreateFolderRequest {
name: string;
description?: string;
}
export interface CreateFolderResponse {
folder: Folder;
}
export interface UpdateFolderRequest {
name?: string;
description?: string;
}
export interface UpdateFolderResponse {
folder: Folder;
}
export interface DeleteFolderResponse {
message: string;
}
// ============================================================================
// API Request/Response Types - Prompts
// ============================================================================
export interface GetPromptsResponse {
prompts: Prompt[];
}
export interface GetPromptResponse {
prompt: Prompt;
}
export interface CreatePromptRequest {
name: string;
folder_id?: string;
}
export interface CreatePromptResponse {
prompt: Prompt;
}
export interface UpdatePromptRequest {
name?: string;
folder_id?: string | null;
}
export interface UpdatePromptResponse {
prompt: Prompt;
}
export interface DeletePromptResponse {
message: string;
}
// ============================================================================
// API Request/Response Types - Versions
// ============================================================================
export interface GetVersionsResponse {
versions: PromptVersion[];
}
export interface GetVersionResponse {
version: PromptVersion;
}
export interface CreateVersionRequest {
commit_message: string;
messages: PromptMessage[];
model_params: ModelParams;
provider: string;
model: string;
}
export interface CreateVersionResponse {
version: PromptVersion;
}
export interface DeleteVersionResponse {
message: string;
}
// ============================================================================
// API Request/Response Types - Sessions
// ============================================================================
export interface GetSessionsResponse {
sessions: PromptSession[];
}
export interface GetSessionResponse {
session: PromptSession;
}
export interface CreateSessionRequest {
name?: string;
version_id?: number;
messages?: PromptMessage[];
model_params: ModelParams;
provider: string;
model: string;
variables?: Record<string, string>;
}
export interface CreateSessionResponse {
session: PromptSession;
}
export interface UpdateSessionRequest {
name?: string;
messages: PromptMessage[];
model_params: ModelParams;
provider: string;
model: string;
variables?: Record<string, string>;
}
export interface UpdateSessionResponse {
session: PromptSession;
}
export interface RenameSessionRequest {
name: string;
}
export interface RenameSessionResponse {
session: PromptSession;
}
export interface DeleteSessionResponse {
message: string;
}
export interface CommitSessionRequest {
commit_message: string;
message_indices?: number[];
}
export interface CommitSessionResponse {
version: PromptVersion;
}

View File

@@ -0,0 +1,123 @@
/**
* Routing Rules Type Definitions
* Defines all TypeScript interfaces for routing rules feature
*/
import { RuleGroupType } from "react-querybuilder";
export interface RoutingTarget {
provider?: string;
model?: string;
key_id?: string;
weight: number;
}
export interface RoutingRule {
id: string;
name: string;
description: string;
cel_expression: string;
targets: RoutingTarget[];
fallbacks?: string[];
scope: "global" | "team" | "customer" | "virtual_key";
scope_id?: string;
priority: number;
enabled: boolean;
chain_rule: boolean;
query?: RuleGroupType;
created_at: string;
updated_at: string;
}
export interface CreateRoutingRuleRequest {
name: string;
description?: string;
cel_expression?: string;
targets: RoutingTarget[];
fallbacks?: string[];
scope: string;
scope_id?: string;
priority: number;
enabled?: boolean;
chain_rule?: boolean;
query?: RuleGroupType;
}
/** Partial update: only sent fields are applied; allows clearing fields by sending "" or []. */
export type UpdateRoutingRuleRequest = Partial<CreateRoutingRuleRequest>;
export interface GetRoutingRulesParams {
limit?: number;
offset?: number;
search?: string;
}
export interface GetRoutingRulesResponse {
rules: RoutingRule[];
count: number;
total_count: number;
limit: number;
offset: number;
}
export interface GetRoutingRuleResponse {
rule: RoutingRule;
}
export interface RoutingTargetFormData {
provider: string;
model: string;
key_id: string;
weight: number;
}
export interface RoutingRuleFormData {
id?: string;
name: string;
description: string;
cel_expression: string;
targets: RoutingTargetFormData[];
fallbacks: string[];
scope: string;
scope_id: string;
priority: number;
enabled: boolean;
chain_rule: boolean;
query?: RuleGroupType;
isDirty?: boolean;
}
export enum RoutingRuleScope {
Global = "global",
Team = "team",
Customer = "customer",
VirtualKey = "virtual_key",
}
export const ROUTING_RULE_SCOPES = [
{ value: RoutingRuleScope.Global, label: "Global" },
{ value: RoutingRuleScope.Team, label: "Team" },
{ value: RoutingRuleScope.Customer, label: "Customer" },
{ value: RoutingRuleScope.VirtualKey, label: "Virtual Key" },
];
export const DEFAULT_ROUTING_TARGET: RoutingTargetFormData = {
provider: "",
model: "",
key_id: "",
weight: 1,
};
export const DEFAULT_ROUTING_RULE_FORM_DATA: RoutingRuleFormData = {
name: "",
description: "",
cel_expression: "",
targets: [DEFAULT_ROUTING_TARGET],
fallbacks: [],
scope: RoutingRuleScope.Global,
scope_id: "",
priority: 0,
enabled: true,
chain_rule: false,
isDirty: false,
};

1108
ui/lib/types/schemas.ts Normal file

File diff suppressed because it is too large Load Diff

6
ui/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

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;
}