first commit
This commit is contained in:
195
ui/lib/config/celFieldsRouting.ts
Normal file
195
ui/lib/config/celFieldsRouting.ts
Normal 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",
|
||||
};
|
||||
50
ui/lib/config/celOperatorsRouting.ts
Normal file
50
ui/lib/config/celOperatorsRouting.ts
Normal 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
198
ui/lib/constants/config.ts
Normal 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);
|
||||
37
ui/lib/constants/governance.ts
Normal file
37
ui/lib/constants/governance.ts
Normal 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
783
ui/lib/constants/icons.tsx
Normal file
File diff suppressed because one or more lines are too long
11
ui/lib/constants/logs.test.ts
Normal file
11
ui/lib/constants/logs.test.ts
Normal 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
308
ui/lib/constants/logs.ts
Normal 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];
|
||||
176
ui/lib/markdown/codePlugin.ts
Normal file
176
ui/lib/markdown/codePlugin.ts
Normal 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();
|
||||
36
ui/lib/message/constant.ts
Normal file
36
ui/lib/message/constant.ts
Normal 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
27
ui/lib/message/index.ts
Normal 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
440
ui/lib/message/message.ts
Normal 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
107
ui/lib/message/types.ts
Normal 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;
|
||||
};
|
||||
363
ui/lib/message/variables.test.ts
Normal file
363
ui/lib/message/variables.test.ts
Normal 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: "" });
|
||||
});
|
||||
});
|
||||
87
ui/lib/message/variables.ts
Normal file
87
ui/lib/message/variables.ts
Normal 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;
|
||||
}
|
||||
260
ui/lib/schemas/providerForm.ts
Normal file
260
ui/lib/schemas/providerForm.ts
Normal 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>;
|
||||
198
ui/lib/store/apis/baseApi.ts
Normal file
198
ui/lib/store/apis/baseApi.ts
Normal 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";
|
||||
};
|
||||
110
ui/lib/store/apis/configApi.ts
Normal file
110
ui/lib/store/apis/configApi.ts
Normal 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
109
ui/lib/store/apis/devApi.ts
Normal 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
|
||||
|
||||
858
ui/lib/store/apis/governanceApi.ts
Normal file
858
ui/lib/store/apis/governanceApi.ts
Normal 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;
|
||||
14
ui/lib/store/apis/index.ts
Normal file
14
ui/lib/store/apis/index.ts
Normal 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";
|
||||
361
ui/lib/store/apis/logsApi.ts
Normal file
361
ui/lib/store/apis/logsApi.ts
Normal 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
150
ui/lib/store/apis/mcpApi.ts
Normal 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;
|
||||
189
ui/lib/store/apis/mcpLogsApi.ts
Normal file
189
ui/lib/store/apis/mcpLogsApi.ts
Normal 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;
|
||||
96
ui/lib/store/apis/pluginsApi.ts
Normal file
96
ui/lib/store/apis/pluginsApi.ts
Normal 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;
|
||||
276
ui/lib/store/apis/promptsApi.ts
Normal file
276
ui/lib/store/apis/promptsApi.ts
Normal 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;
|
||||
375
ui/lib/store/apis/providersApi.ts
Normal file
375
ui/lib/store/apis/providersApi.ts
Normal 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;
|
||||
136
ui/lib/store/apis/routingRulesApi.ts
Normal file
136
ui/lib/store/apis/routingRulesApi.ts
Normal 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;
|
||||
61
ui/lib/store/apis/sessionApi.ts
Normal file
61
ui/lib/store/apis/sessionApi.ts
Normal 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
6
ui/lib/store/hooks.ts
Normal 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
14
ui/lib/store/index.ts
Normal 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
11
ui/lib/store/provider.tsx
Normal 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>;
|
||||
}
|
||||
223
ui/lib/store/slices/appSlice.ts
Normal file
223
ui/lib/store/slices/appSlice.ts
Normal 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;
|
||||
14
ui/lib/store/slices/index.ts
Normal file
14
ui/lib/store/slices/index.ts
Normal 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";
|
||||
70
ui/lib/store/slices/pluginSlice.ts
Normal file
70
ui/lib/store/slices/pluginSlice.ts
Normal 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;
|
||||
80
ui/lib/store/slices/providerSlice.ts
Normal file
80
ui/lib/store/slices/providerSlice.ts
Normal 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
46
ui/lib/store/store.ts
Normal 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
593
ui/lib/types/config.ts
Normal 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
529
ui/lib/types/governance.ts
Normal 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
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
138
ui/lib/types/mcp.ts
Normal 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
47
ui/lib/types/plugins.ts
Normal 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
255
ui/lib/types/prompts.ts
Normal 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;
|
||||
}
|
||||
123
ui/lib/types/routingRules.ts
Normal file
123
ui/lib/types/routingRules.ts
Normal 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
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
6
ui/lib/utils.ts
Normal 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
15
ui/lib/utils/array.ts
Normal 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));
|
||||
};
|
||||
32
ui/lib/utils/browser-download.ts
Normal file
32
ui/lib/utils/browser-download.ts
Normal 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);
|
||||
};
|
||||
371
ui/lib/utils/celConverterRouting.ts
Normal file
371
ui/lib/utils/celConverterRouting.ts
Normal 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
42
ui/lib/utils/csv.ts
Normal 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
33
ui/lib/utils/date.ts
Normal 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;
|
||||
}
|
||||
98
ui/lib/utils/governance.ts
Normal file
98
ui/lib/utils/governance.ts
Normal 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
13
ui/lib/utils/numbers.ts
Normal 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
179
ui/lib/utils/pdf.ts
Normal 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
127
ui/lib/utils/port.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
167
ui/lib/utils/routingRules.ts
Normal file
167
ui/lib/utils/routingRules.ts
Normal 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;
|
||||
}
|
||||
204
ui/lib/utils/strings.test.ts
Normal file
204
ui/lib/utils/strings.test.ts
Normal 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
45
ui/lib/utils/strings.ts
Normal 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
44
ui/lib/utils/timeRange.ts
Normal 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
592
ui/lib/utils/validation.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user