Feat/provider validation banner (#353)

* models query combined

* make endpoint to handle provider health

* provider health banner

* update-pdf-to-include-provider-selection (#344)

* polishing the error fixing experience

* fix agent instructions and up char limit

* fix provider

* disable tracing in langflow

* improve docling serve banner remove false positives

* Changed pyproject.toml docling versions

* Added another uv lock revision

* version bump

* unused things and fix bad conflicts

* add isFetching to the hook

* put back settings for models queries to never cache results

* update banner refetching indicator

* validate provider settings when saving

* fix settings page layout issue

* Added retry as false on the get models, to not take a long time

---------

Co-authored-by: Mendon Kissling <59585235+mendonk@users.noreply.github.com>
Co-authored-by: Mike Fortman <michael.fortman@datastax.com>
Co-authored-by: phact <estevezsebastian@gmail.com>
Co-authored-by: Lucas Oliveira <lucas.edu.oli@hotmail.com>
This commit is contained in:
Cole Goldsmith 2025-11-06 13:03:50 -06:00 committed by GitHub
parent f8c11b0f1d
commit b88c8b20df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 927 additions and 471 deletions

View file

@ -3,150 +3,153 @@
import { AlertTriangle, Copy, ExternalLink } from "lucide-react";
import { useState } from "react";
import {
Banner,
BannerAction,
BannerIcon,
BannerTitle,
Banner,
BannerAction,
BannerIcon,
BannerTitle,
} from "@/components/ui/banner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { HEADER_HEIGHT } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery";
interface DoclingHealthBannerProps {
className?: string;
className?: string;
}
// DoclingSetupDialog component
interface DoclingSetupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
className?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
className?: string;
}
function DoclingSetupDialog({
open,
onOpenChange,
className,
open,
onOpenChange,
className,
}: DoclingSetupDialogProps) {
const [copied, setCopied] = useState(false);
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText("uv run openrag");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleCopy = async () => {
await navigator.clipboard.writeText("uv run openrag");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn("max-w-lg", className)}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
docling-serve is stopped. Knowledge ingest is unavailable.
</DialogTitle>
<DialogDescription>Start docling-serve by running:</DialogDescription>
</DialogHeader>
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn("max-w-lg", className)}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
docling-serve is stopped. Knowledge ingest is unavailable.
</DialogTitle>
<DialogDescription>Start docling-serve by running:</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2.5 rounded-md text-sm font-mono">
uv run openrag
</code>
<Button
variant="ghost"
size="icon"
onClick={handleCopy}
className="shrink-0"
title={copied ? "Copied!" : "Copy to clipboard"}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2.5 rounded-md text-sm font-mono">
uv run openrag
</code>
<Button
variant="ghost"
size="icon"
onClick={handleCopy}
className="shrink-0"
title={copied ? "Copied!" : "Copy to clipboard"}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<DialogDescription>
Then, select{" "}
<span className="font-semibold text-foreground">
Start All Services
</span>{" "}
in the TUI. Once docling-serve is running, refresh OpenRAG.
</DialogDescription>
</div>
<DialogDescription>
Then, select{" "}
<span className="font-semibold text-foreground">
Start All Services
</span>{" "}
in the TUI. Once docling-serve is running, refresh OpenRAG.
</DialogDescription>
</div>
<DialogFooter>
<Button variant="default" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
<DialogFooter>
<Button variant="default" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Custom hook to check docling health status
export function useDoclingHealth() {
const { data: health, isLoading, isError } = useDoclingHealthQuery();
const { data: health, isLoading, isError } = useDoclingHealthQuery();
const isHealthy = health?.status === "healthy" && !isError;
// Only consider unhealthy if backend is up but docling is down
// Don't show banner if backend is unavailable
const isUnhealthy = health?.status === "unhealthy";
const isBackendUnavailable = health?.status === "backend-unavailable" || isError;
const isHealthy = health?.status === "healthy" && !isError;
// Only consider unhealthy if backend is up but docling is down
// Don't show banner if backend is unavailable
const isUnhealthy = health?.status === "unhealthy";
const isBackendUnavailable =
health?.status === "backend-unavailable" || isError;
return {
health,
isLoading,
isError,
isHealthy,
isUnhealthy,
isBackendUnavailable,
};
return {
health,
isLoading,
isError,
isHealthy,
isUnhealthy,
isBackendUnavailable,
};
}
export function DoclingHealthBanner({ className }: DoclingHealthBannerProps) {
const { isLoading, isHealthy, isUnhealthy } = useDoclingHealth();
const [showDialog, setShowDialog] = useState(false);
const { isLoading, isHealthy, isUnhealthy } = useDoclingHealth();
const [showDialog, setShowDialog] = useState(false);
// Only show banner when service is unhealthy
if (isLoading || isHealthy) {
return null;
}
// Only show banner when service is unhealthy
if (isLoading || isHealthy) {
return null;
}
if (isUnhealthy) {
return (
<>
<Banner
className={cn(
`bg-amber-50 text-amber-900 dark:bg-amber-950 dark:text-amber-200 border-amber-200 dark:border-amber-800`,
className,
)}
>
<BannerIcon icon={AlertTriangle} />
<BannerTitle className="font-medium">
docling-serve native service is stopped. Knowledge ingest is
unavailable.
</BannerTitle>
<BannerAction
onClick={() => setShowDialog(true)}
className="bg-foreground text-background hover:bg-primary/90"
>
Setup Docling Serve
<ExternalLink className="h-3 w-3 ml-1" />
</BannerAction>
</Banner>
if (isUnhealthy) {
return (
<>
<Banner
className={cn(
"bg-amber-50 dark:bg-amber-950 text-foreground border-accent-amber border-b w-full",
className
)}
>
<BannerIcon
icon={AlertTriangle}
className="text-accent-amber-foreground"
/>
<BannerTitle className="font-medium">
docling-serve native service is stopped. Knowledge ingest is
unavailable.
</BannerTitle>
<BannerAction
onClick={() => setShowDialog(true)}
className="bg-foreground text-background hover:bg-primary/90"
>
Setup Docling Serve
<ExternalLink className="h-3 w-3 ml-1" />
</BannerAction>
</Banner>
<DoclingSetupDialog open={showDialog} onOpenChange={setShowDialog} />
</>
);
}
<DoclingSetupDialog open={showDialog} onOpenChange={setShowDialog} />
</>
);
}
return null;
return null;
}

View file

@ -0,0 +1,74 @@
"use client";
import { AlertTriangle } from "lucide-react";
import { Banner, BannerIcon, BannerTitle } from "@/components/ui/banner";
import { cn } from "@/lib/utils";
import { useProviderHealthQuery } from "@/src/app/api/queries/useProviderHealthQuery";
import { Button } from "./ui/button";
import { useRouter } from "next/navigation";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
interface ProviderHealthBannerProps {
className?: string;
}
// Custom hook to check provider health status
export function useProviderHealth() {
const {
data: health,
isLoading,
isFetching,
error,
isError,
} = useProviderHealthQuery();
const isHealthy = health?.status === "healthy" && !isError;
const isUnhealthy =
health?.status === "unhealthy" || health?.status === "error" || isError;
return {
health,
isLoading,
isFetching,
error,
isError,
isHealthy,
isUnhealthy,
};
}
export function ProviderHealthBanner({ className }: ProviderHealthBannerProps) {
const { isLoading, isHealthy, error } = useProviderHealth();
const router = useRouter();
const { data: settings = {} } = useGetSettingsQuery();
if (!isHealthy && !isLoading) {
const errorMessage = error?.message || "Provider validation failed";
const settingsUrl = settings.provider?.model_provider
? `/settings?setup=${settings.provider?.model_provider}`
: "/settings";
return (
<Banner
className={cn(
"bg-red-50 dark:bg-red-950 text-foreground border-accent-red border-b w-full",
className
)}
>
<BannerIcon
className="text-accent-red-foreground"
icon={AlertTriangle}
/>
<BannerTitle className="font-medium flex items-center gap-2">
{errorMessage}
</BannerTitle>
<Button size="sm" onClick={() => router.push(settingsUrl)}>
Fix Setup
</Button>
</Banner>
);
}
return null;
}

View file

@ -62,7 +62,6 @@ export const useUpdateSettingsMutation = (
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: ["settings"],
refetchType: "all"
});
options?.onSuccess?.(...args);
},

View file

@ -3,6 +3,7 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useGetSettingsQuery } from "./useGetSettingsQuery";
export interface ModelOption {
value: string;
@ -55,6 +56,7 @@ export const useGetOpenAIModelsQuery = (
queryFn: getOpenAIModels,
staleTime: 0, // Always fetch fresh data
gcTime: 0, // Don't cache results
retry: false,
...options,
},
queryClient,
@ -89,6 +91,7 @@ export const useGetOllamaModelsQuery = (
queryFn: getOllamaModels,
staleTime: 0, // Always fetch fresh data
gcTime: 0, // Don't cache results
retry: false,
...options,
},
queryClient,
@ -129,6 +132,7 @@ export const useGetIBMModelsQuery = (
queryFn: getIBMModels,
staleTime: 0, // Always fetch fresh data
gcTime: 0, // Don't cache results
retry: false,
...options,
},
queryClient,
@ -136,3 +140,65 @@ export const useGetIBMModelsQuery = (
return queryResult;
};
/**
* Hook that automatically fetches models for the current provider
* based on the settings configuration
*/
export const useGetCurrentProviderModelsQuery = (
options?: Omit<UseQueryOptions<ModelsResponse>, "queryKey" | "queryFn">,
) => {
const { data: settings } = useGetSettingsQuery();
const currentProvider = settings?.provider?.model_provider;
// Determine which hook to use based on current provider
const openaiModels = useGetOpenAIModelsQuery(
{ apiKey: "" },
{
enabled: currentProvider === "openai" && options?.enabled !== false,
...options,
}
);
const ollamaModels = useGetOllamaModelsQuery(
{ endpoint: settings?.provider?.endpoint },
{
enabled: currentProvider === "ollama" && !!settings?.provider?.endpoint && options?.enabled !== false,
...options,
}
);
const ibmModels = useGetIBMModelsQuery(
{
endpoint: settings?.provider?.endpoint,
apiKey: "",
projectId: settings?.provider?.project_id,
},
{
enabled:
currentProvider === "watsonx" &&
!!settings?.provider?.endpoint &&
!!settings?.provider?.project_id &&
options?.enabled !== false,
...options,
}
);
// Return the appropriate query result based on current provider
switch (currentProvider) {
case "openai":
return openaiModels;
case "ollama":
return ollamaModels;
case "watsonx":
return ibmModels;
default:
// Return a default/disabled query if no provider is set
return {
data: undefined,
isLoading: false,
error: null,
refetch: async () => ({ data: undefined }),
} as ReturnType<typeof useGetOpenAIModelsQuery>;
}
};

View file

@ -0,0 +1,71 @@
import { ModelProvider } from "@/app/settings/helpers/model-helpers";
import {
type UseQueryOptions,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
export interface ProviderHealthDetails {
llm_model: string;
embedding_model: string;
endpoint?: string | null;
}
export interface ProviderHealthResponse {
status: "healthy" | "unhealthy" | "error";
message: string;
provider: string;
details?: ProviderHealthDetails;
}
export interface ProviderHealthParams {
provider?: "openai" | "ollama" | "watsonx";
}
const providerTitleMap: Record<ModelProvider, string> = {
openai: "OpenAI",
ollama: "Ollama",
watsonx: "IBM watsonx.ai",
};
export const useProviderHealthQuery = (
params?: ProviderHealthParams,
options?: Omit<
UseQueryOptions<ProviderHealthResponse, Error>,
"queryKey" | "queryFn"
>
) => {
const queryClient = useQueryClient();
async function checkProviderHealth(): Promise<ProviderHealthResponse> {
const url = new URL("/api/provider/health", window.location.origin);
// Add provider query param if specified
if (params?.provider) {
url.searchParams.set("provider", params.provider);
}
const response = await fetch(url.toString());
if (response.ok) {
return await response.json();
} else {
// For 400 and 503 errors, still parse JSON for error details
const errorData = await response.json().catch(() => ({}));
throw new Error(`${providerTitleMap[errorData.provider as ModelProvider] || "Provider"} error: ${errorData.message || "Failed to check provider health"}`);
}
}
const queryResult = useQuery(
{
queryKey: ["provider", "health"],
queryFn: checkProviderHealth,
retry: false, // Don't retry health checks automatically
...options,
},
queryClient
);
return queryResult;
};

View file

@ -111,7 +111,6 @@
.app-grid-arrangement {
--notifications-width: 0px;
--filters-width: 0px;
--top-banner-height: 0px;
--header-height: 54px;
--sidebar-width: 280px;
@ -121,14 +120,11 @@
&.filters-open {
--filters-width: 320px;
}
&.banner-visible {
--top-banner-height: 52px;
}
display: grid;
height: 100%;
width: 100%;
grid-template-rows:
var(--top-banner-height)
auto
var(--header-height)
1fr;
grid-template-columns:
@ -345,12 +341,12 @@
@apply text-xs opacity-70;
}
.prose :where(strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
.prose
:where(strong):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
@apply text-current;
}
.prose :where(a):not(:where([class~="not-prose"],[class~="not-prose"] *))
{
.prose :where(a):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
@apply text-current;
}

View file

@ -3,158 +3,170 @@ import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export function ModelSelector({
options,
value,
onValueChange,
icon,
placeholder = "Select model...",
searchPlaceholder = "Search model...",
noOptionsPlaceholder = "No models available",
custom = false,
hasError = false,
options,
value = "",
onValueChange,
icon,
placeholder = "Select model...",
searchPlaceholder = "Search model...",
noOptionsPlaceholder = "No models available",
custom = false,
hasError = false,
}: {
options: {
value: string;
label: string;
default?: boolean;
}[];
value: string;
icon?: React.ReactNode;
placeholder?: string;
searchPlaceholder?: string;
noOptionsPlaceholder?: string;
custom?: boolean;
onValueChange: (value: string) => void;
hasError?: boolean;
options: {
value: string;
label: string;
default?: boolean;
}[];
value: string;
icon?: React.ReactNode;
placeholder?: string;
searchPlaceholder?: string;
noOptionsPlaceholder?: string;
custom?: boolean;
onValueChange: (value: string) => void;
hasError?: boolean;
}) {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
useEffect(() => {
if (value && value !== "" && (!options.find((option) => option.value === value) && !custom)) {
onValueChange("");
}
}, [options, value, custom, onValueChange]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{/** biome-ignore lint/a11y/useSemanticElements: has to be a Button */}
<Button
variant="outline"
role="combobox"
disabled={options.length === 0}
aria-expanded={open}
className={cn("w-full gap-2 justify-between font-normal text-sm", hasError && "!border-destructive")}
>
{value ? (
<div className="flex items-center gap-2">
{icon && <div className="w-4 h-4">{icon}</div>}
{options.find((framework) => framework.value === value)?.label ||
value}
{/* {options.find((framework) => framework.value === value)
useEffect(() => {
if (
value &&
value !== "" &&
!options.find((option) => option.value === value) &&
!custom
) {
onValueChange("");
}
}, [options, value, custom, onValueChange]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{/** biome-ignore lint/a11y/useSemanticElements: has to be a Button */}
<Button
variant="outline"
role="combobox"
disabled={options.length === 0}
aria-expanded={open}
className={cn(
"w-full gap-2 justify-between font-normal text-sm",
hasError && "!border-destructive"
)}
>
{value ? (
<div className="flex items-center gap-2">
{icon && <div className="w-4 h-4">{icon}</div>}
{options.find((framework) => framework.value === value)?.label ||
value}
{/* {options.find((framework) => framework.value === value)
?.default && (
<span className="text-xs text-foreground p-1 rounded-md bg-muted">
Default
</span>
)} */}
{custom &&
value &&
!options.find((framework) => framework.value === value) && (
<Badge variant="outline" className="text-xs">
CUSTOM
</Badge>
)}
</div>
) : options.length === 0 ? (
noOptionsPlaceholder
) : (
placeholder
)}
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className=" p-0 w-[var(--radix-popover-trigger-width)]">
<Command>
<CommandInput
placeholder={searchPlaceholder}
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandList>
<CommandEmpty>{noOptionsPlaceholder}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
if (currentValue !== value) {
onValueChange(currentValue);
}
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex items-center gap-2">
{option.label}
{/* {option.default && (
{custom &&
value &&
!options.find((framework) => framework.value === value) && (
<Badge variant="outline" className="text-xs">
CUSTOM
</Badge>
)}
</div>
) : options.length === 0 ? (
noOptionsPlaceholder
) : (
placeholder
)}
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className=" p-0 w-[var(--radix-popover-trigger-width)]"
>
<Command>
<CommandInput
placeholder={searchPlaceholder}
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandList>
<CommandEmpty>{noOptionsPlaceholder}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
if (currentValue !== value) {
onValueChange(currentValue);
}
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center gap-2">
{option.label}
{/* {option.default && (
<span className="text-xs text-foreground p-1 rounded-md bg-muted"> // DISABLING DEFAULT TAG FOR NOW
Default
</span>
)} */}
</div>
</CommandItem>
))}
{custom &&
searchValue &&
!options.find((option) => option.value === searchValue) && (
<CommandItem
value={searchValue}
onSelect={(currentValue) => {
if (currentValue !== value) {
onValueChange(currentValue);
}
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
value === searchValue ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex items-center gap-2">
{searchValue}
<span className="text-xs text-foreground p-1 rounded-md bg-muted">
Custom
</span>
</div>
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
</div>
</CommandItem>
))}
{custom &&
searchValue &&
!options.find((option) => option.value === searchValue) && (
<CommandItem
value={searchValue}
onSelect={(currentValue) => {
if (currentValue !== value) {
onValueChange(currentValue);
}
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
value === searchValue ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center gap-2">
{searchValue}
<span className="text-xs text-foreground p-1 rounded-md bg-muted">
Custom
</span>
</div>
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View file

@ -30,7 +30,7 @@ export function OllamaOnboarding({
isLoading: isLoadingModels,
error: modelsError,
} = useGetOllamaModelsQuery(
debouncedEndpoint ? { endpoint: debouncedEndpoint } : undefined,
debouncedEndpoint ? { endpoint: debouncedEndpoint } : undefined
);
// Use custom hook for model selection logic

View file

@ -6,23 +6,51 @@ import OpenAILogo from "@/components/logo/openai-logo";
import IBMLogo from "@/components/logo/ibm-logo";
import OllamaLogo from "@/components/logo/ollama-logo";
import { useAuth } from "@/contexts/auth-context";
import { ReactNode, useState } from "react";
import { ReactNode, useState, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import OpenAISettingsDialog from "./openai-settings-dialog";
import OllamaSettingsDialog from "./ollama-settings-dialog";
import WatsonxSettingsDialog from "./watsonx-settings-dialog";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { useProviderHealth } from "@/components/provider-health-banner";
export const ModelProviders = () => {
const { isAuthenticated, isNoAuthMode } = useAuth();
const searchParams = useSearchParams();
const router = useRouter();
const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
});
const { isUnhealthy } = useProviderHealth();
const [dialogOpen, setDialogOpen] = useState<ModelProvider | undefined>();
const allProviderKeys: ModelProvider[] = ["openai", "ollama", "watsonx"];
// Handle URL search param to open dialogs
useEffect(() => {
const searchParam = searchParams.get("setup");
if (searchParam && allProviderKeys.includes(searchParam as ModelProvider)) {
setDialogOpen(searchParam as ModelProvider);
}
}, [searchParams]);
// Function to close dialog and remove search param
const handleCloseDialog = () => {
setDialogOpen(undefined);
// Remove search param from URL
const params = new URLSearchParams(searchParams.toString());
params.delete("setup");
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
router.replace(newUrl);
};
const modelProvidersMap: Record<
ModelProvider,
{
@ -56,7 +84,6 @@ export const ModelProviders = () => {
(settings.provider?.model_provider as ModelProvider) || "openai";
// Get all provider keys with active provider first
const allProviderKeys: ModelProvider[] = ["openai", "ollama", "watsonx"];
const sortedProviderKeys = [
currentProviderKey,
...allProviderKeys.filter((key) => key !== currentProviderKey),
@ -72,14 +99,15 @@ export const ModelProviders = () => {
logoColor,
logoBgColor,
} = modelProvidersMap[providerKey];
const isActive = providerKey === currentProviderKey;
const isCurrentProvider = providerKey === currentProviderKey;
return (
<Card
key={providerKey}
className={cn(
"relative flex flex-col",
!isActive && "text-muted-foreground"
!isCurrentProvider && "text-muted-foreground",
isCurrentProvider && isUnhealthy && "border-destructive"
)}
>
<CardHeader>
@ -89,13 +117,15 @@ export const ModelProviders = () => {
<div
className={cn(
"w-8 h-8 rounded flex items-center justify-center border",
isActive ? logoBgColor : "bg-muted"
isCurrentProvider ? logoBgColor : "bg-muted"
)}
>
{
<Logo
className={
isActive ? logoColor : "text-muted-foreground"
isCurrentProvider
? logoColor
: "text-muted-foreground"
}
/>
}
@ -103,20 +133,27 @@ export const ModelProviders = () => {
</div>
<CardTitle className="flex flex-row items-center gap-2">
{name}
{isActive && (
<div className="h-2 w-2 bg-accent-emerald-foreground rounded-full" />
{isCurrentProvider && (
<div
className={cn(
"h-2 w-2 rounded-full",
isUnhealthy
? "bg-destructive"
: "bg-accent-emerald-foreground"
)}
/>
)}
</CardTitle>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-end space-y-4">
{isActive ? (
{isCurrentProvider ? (
<Button
variant="outline"
variant={isUnhealthy ? "default" : "outline"}
onClick={() => setDialogOpen(providerKey)}
>
Edit Setup
{isUnhealthy ? "Fix Setup" : "Edit Setup"}
</Button>
) : (
<p>
@ -139,15 +176,15 @@ export const ModelProviders = () => {
</div>
<OpenAISettingsDialog
open={dialogOpen === "openai"}
setOpen={() => setDialogOpen(undefined)}
setOpen={handleCloseDialog}
/>
<OllamaSettingsDialog
open={dialogOpen === "ollama"}
setOpen={() => setDialogOpen(undefined)}
setOpen={handleCloseDialog}
/>
<WatsonxSettingsDialog
open={dialogOpen === "watsonx"}
setOpen={() => setDialogOpen(undefined)}
setOpen={handleCloseDialog}
/>
</>
);

View file

@ -47,7 +47,15 @@ export function ModelSelectors({
shouldValidate: true,
});
}
}, [defaultLlmModel, defaultEmbeddingModel, setValue]);
}, [
defaultLlmModel,
defaultEmbeddingModel,
llmModel,
embeddingModel,
setValue,
languageModelName,
embeddingModelName,
]);
return (
<>

View file

@ -16,6 +16,9 @@ import {
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { useAuth } from "@/contexts/auth-context";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation";
import { useQueryClient } from "@tanstack/react-query";
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
import { AnimatePresence, motion } from "motion/react";
const OllamaSettingsDialog = ({
open,
@ -25,6 +28,7 @@ const OllamaSettingsDialog = ({
setOpen: (open: boolean) => void;
}) => {
const { isAuthenticated, isNoAuthMode } = useAuth();
const queryClient = useQueryClient();
const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
@ -49,14 +53,17 @@ const OllamaSettingsDialog = ({
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
// Update provider health cache to healthy since backend validated the setup
const healthData: ProviderHealthResponse = {
status: "healthy",
message: "Provider is configured and working correctly",
provider: "ollama",
};
queryClient.setQueryData(["provider", "health"], healthData);
toast.success("Ollama settings updated successfully");
setOpen(false);
},
onError: (error) => {
toast.error("Failed to update Ollama settings", {
description: error.message,
});
},
});
const onSubmit = (data: OllamaSettingsFormData) => {
@ -83,6 +90,21 @@ const OllamaSettingsDialog = ({
</DialogHeader>
<OllamaSettingsForm />
<AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<p className="rounded-lg border border-destructive p-4">
{settingsMutation.error?.message}
</p>
</motion.div>
)}
</AnimatePresence>
<DialogFooter className="mt-4">
<Button
variant="outline"

View file

@ -16,6 +16,9 @@ import {
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { useAuth } from "@/contexts/auth-context";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation";
import { AnimatePresence, motion } from "motion/react";
import { useQueryClient } from "@tanstack/react-query";
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
const OpenAISettingsDialog = ({
open,
@ -25,6 +28,7 @@ const OpenAISettingsDialog = ({
setOpen: (open: boolean) => void;
}) => {
const { isAuthenticated, isNoAuthMode } = useAuth();
const queryClient = useQueryClient();
const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
@ -47,14 +51,17 @@ const OpenAISettingsDialog = ({
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
// Update provider health cache to healthy since backend validated the setup
const healthData: ProviderHealthResponse = {
status: "healthy",
message: "Provider is configured and working correctly",
provider: "openai",
};
queryClient.setQueryData(["provider", "health"], healthData);
toast.success("OpenAI settings updated successfully");
setOpen(false);
},
onError: (error) => {
toast.error("Failed to update OpenAI settings", {
description: error.message,
});
},
});
const onSubmit = (data: OpenAISettingsFormData) => {
@ -94,7 +101,21 @@ const OpenAISettingsDialog = ({
<OpenAISettingsForm isCurrentProvider={isOpenAIConfigured} />
<DialogFooter>
<AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<p className="rounded-lg border border-destructive p-4">
{settingsMutation.error?.message}
</p>
</motion.div>
)}
</AnimatePresence>
<DialogFooter className="mt-4">
<Button
variant="outline"
type="button"

View file

@ -16,6 +16,9 @@ import {
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { useAuth } from "@/contexts/auth-context";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation";
import { useQueryClient } from "@tanstack/react-query";
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
import { AnimatePresence, motion } from "motion/react";
const WatsonxSettingsDialog = ({
open,
@ -25,6 +28,7 @@ const WatsonxSettingsDialog = ({
setOpen: (open: boolean) => void;
}) => {
const { isAuthenticated, isNoAuthMode } = useAuth();
const queryClient = useQueryClient();
const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
@ -51,14 +55,16 @@ const WatsonxSettingsDialog = ({
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
// Update provider health cache to healthy since backend validated the setup
const healthData: ProviderHealthResponse = {
status: "healthy",
message: "Provider is configured and working correctly",
provider: "watsonx",
};
queryClient.setQueryData(["provider", "health"], healthData);
toast.success("watsonx settings updated successfully");
setOpen(false);
},
onError: (error) => {
toast.error("Failed to update watsonx settings", {
description: error.message,
});
},
});
const onSubmit = (data: WatsonxSettingsFormData) => {
@ -98,10 +104,24 @@ const WatsonxSettingsDialog = ({
</div>
IBM watsonx.ai Setup
</DialogTitle>
</DialogHeader>
</DialogHeader>
<WatsonxSettingsForm isCurrentProvider={isWatsonxConfigured} />
<AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<p className="rounded-lg border border-destructive p-4">
{settingsMutation.error?.message}
</p>
</motion.div>
)}
</AnimatePresence>
<DialogFooter>
<Button
variant="outline"

View file

@ -4,15 +4,10 @@ import { ArrowUpRight, Loader2, Minus, PlugZap, Plus } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useState } from "react";
import {
useGetIBMModelsQuery,
useGetOllamaModelsQuery,
useGetOpenAIModelsQuery,
} from "@/app/api/queries/useGetModelsQuery";
import { useGetCurrentProviderModelsQuery } from "@/app/api/queries/useGetModelsQuery";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { ConfirmationDialog } from "@/components/confirmation-dialog";
import { LabelWrapper } from "@/components/label-wrapper";
import OpenAILogo from "@/components/logo/openai-logo";
import { ProtectedRoute } from "@/components/protected-route";
import { Button } from "@/components/ui/button";
import {
@ -24,17 +19,6 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Select,
SelectContent,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/contexts/auth-context";
@ -46,14 +30,13 @@ import {
} from "@/lib/constants";
import { useDebounce } from "@/lib/debounce";
import { ModelSelector } from "../onboarding/components/model-selector";
import { getFallbackModels, type ModelProvider } from "./helpers/model-helpers";
import { ModelSelectItems } from "./helpers/model-select-item";
import { getModelLogo, type ModelProvider } from "./helpers/model-helpers";
import GoogleDriveIcon from "./icons/google-drive-icon";
import OneDriveIcon from "./icons/one-drive-icon";
import SharePointIcon from "./icons/share-point-icon";
import ModelProviders from "./components/model-providers";
import { useUpdateSettingsMutation } from "../api/mutations/useUpdateSettingsMutation";
import { toast } from "sonner";
const { MAX_SYSTEM_PROMPT_CHARS } = UI_CONSTANTS;
@ -137,56 +120,18 @@ function KnowledgeSourcesPage() {
const currentProvider = (settings.provider?.model_provider ||
"openai") as ModelProvider;
// Fetch available models based on provider
const { data: openaiModelsData } = useGetOpenAIModelsQuery(
{
apiKey: ""
},
{
enabled:
(isAuthenticated || isNoAuthMode) && currentProvider === "openai",
}
);
const { data: ollamaModelsData } = useGetOllamaModelsQuery(
{
endpoint: settings.provider?.endpoint,
},
{
enabled:
(isAuthenticated || isNoAuthMode) && currentProvider === "ollama",
}
);
const { data: ibmModelsData } = useGetIBMModelsQuery(
{
endpoint: settings.provider?.endpoint,
apiKey: "",
projectId: settings.provider?.project_id,
},
{
enabled:
(isAuthenticated || isNoAuthMode) && currentProvider === "watsonx",
}
);
// Select the appropriate models data based on provider
const modelsData =
currentProvider === "openai"
? openaiModelsData
: currentProvider === "ollama"
? ollamaModelsData
: currentProvider === "watsonx"
? ibmModelsData
: openaiModelsData; // fallback to openai
const { data: modelsData, isLoading: modelsLoading } =
useGetCurrentProviderModelsQuery();
// Mutations
const updateSettingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
console.log("Setting updated successfully");
toast.success("Settings updated successfully");
},
onError: (error) => {
console.error("Failed to update setting:", error.message);
toast.error("Failed to update settings", {
description: error.message,
});
},
});
@ -239,7 +184,7 @@ function KnowledgeSourcesPage() {
// Update model selection immediately
const handleModelChange = (newModel: string) => {
updateSettingsMutation.mutate({ llm_model: newModel });
if (newModel) updateSettingsMutation.mutate({ llm_model: newModel });
};
// Update system prompt with save button
@ -249,7 +194,7 @@ function KnowledgeSourcesPage() {
// Update embedding model selection immediately
const handleEmbeddingModelChange = (newModel: string) => {
updateSettingsMutation.mutate({ embedding_model: newModel });
if (newModel) updateSettingsMutation.mutate({ embedding_model: newModel });
};
const isEmbeddingModelSelectDisabled = updateSettingsMutation.isPending;
@ -465,11 +410,17 @@ function KnowledgeSourcesPage() {
const getStatusBadge = (status: Connector["status"]) => {
switch (status) {
case "connected":
return <div className="h-2 w-2 bg-green-500 rounded-full" />;
return (
<div className="h-2 w-2 bg-accent-emerald-foreground rounded-full" />
);
case "connecting":
return <div className="h-2 w-2 bg-yellow-500 rounded-full" />;
return (
<div className="h-2 w-2 bg-accent-amber-foreground rounded-full" />
);
case "error":
return <div className="h-2 w-2 bg-red-500 rounded-full" />;
return (
<div className="h-2 w-2 bg-accent-red-foreground rounded-full" />
);
default:
return <div className="h-2 w-2 bg-muted rounded-full" />;
}
@ -909,12 +860,12 @@ function KnowledgeSourcesPage() {
<ModelSelector
options={modelsData?.language_models || []}
noOptionsPlaceholder={
modelsData
? "No language models detected."
: "Loading models..."
modelsLoading
? "Loading models..."
: "No language models detected."
}
icon={<OpenAILogo className="w-4 h-4" />}
value={modelsData ? settings.agent?.llm_model || "" : ""}
icon={getModelLogo("", currentProvider)}
value={settings.agent?.llm_model || ""}
onValueChange={handleModelChange}
/>
</LabelWrapper>
@ -1051,41 +1002,17 @@ function KnowledgeSourcesPage() {
id="embedding-model-select"
label="Embedding model"
>
<Select
disabled={isEmbeddingModelSelectDisabled}
value={
settings.knowledge?.embedding_model ||
modelsData?.embedding_models?.find((m) => m.default)
?.value ||
"text-embedding-ada-002"
<ModelSelector
options={modelsData?.embedding_models || []}
noOptionsPlaceholder={
modelsLoading
? "Loading models..."
: "No embedding models detected."
}
icon={getModelLogo("", currentProvider)}
value={settings.knowledge?.embedding_model || ""}
onValueChange={handleEmbeddingModelChange}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger
disabled={isEmbeddingModelSelectDisabled}
id="embedding-model-select"
>
<SelectValue placeholder="Select an embedding model" />
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>
{isEmbeddingModelSelectDisabled
? "Please wait while we update your settings"
: "Choose the embedding model used for new ingests"}
</TooltipContent>
</Tooltip>
<SelectContent>
<ModelSelectItems
models={modelsData?.embedding_models}
fallbackModels={
getFallbackModels(currentProvider).embedding
}
provider={currentProvider}
/>
</SelectContent>
</Select>
/>
</LabelWrapper>
</div>
<div className="grid grid-cols-2 gap-4">

View file

@ -155,7 +155,7 @@ export function ChatRenderer({
ease: "easeOut",
}}
className={cn(
"flex h-full w-full max-w-full max-h-full items-center justify-center overflow-hidden",
"flex h-full w-full max-w-full max-h-full items-center justify-center overflow-y-auto",
!showLayout && "absolute max-h-[calc(100vh-190px)]",
showLayout && !isOnChatPage && "bg-background",
)}
@ -163,10 +163,10 @@ export function ChatRenderer({
<div
className={cn(
"h-full bg-background w-full",
showLayout && !isOnChatPage && "p-6 container overflow-y-auto",
showLayout && !isOnChatPage && "p-6 container",
showLayout && isSmallWidthPath && "max-w-[850px] ml-0",
!showLayout &&
"w-full bg-card rounded-lg shadow-2xl p-0 py-2 overflow-y-auto",
"w-full bg-card rounded-lg shadow-2xl p-0 py-2",
)}
>
<motion.div

View file

@ -1,18 +1,24 @@
"use client";
import { Loader2 } from "lucide-react";
import { usePathname } from "next/navigation";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { DoclingHealthBanner } from "@/components/docling-health-banner";
import {
DoclingHealthBanner,
useDoclingHealth,
} from "@/components/docling-health-banner";
import {
ProviderHealthBanner,
useProviderHealth,
} from "@/components/provider-health-banner";
import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel";
import { TaskNotificationMenu } from "@/components/task-notification-menu";
import { useAuth } from "@/contexts/auth-context";
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
import { useTask } from "@/contexts/task-context";
import { cn } from "@/lib/utils";
import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery";
import { ChatRenderer } from "./chat-renderer";
import AnimatedProcessingIcon from "./ui/animated-processing-icon";
import { AnimatedConditional } from "./animated-conditional";
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
@ -29,13 +35,9 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const { data: settings, isLoading: isSettingsLoading } = useGetSettingsQuery({
enabled: !isAuthPage && (isAuthenticated || isNoAuthMode),
});
const {
data: health,
isLoading: isHealthLoading,
isError,
} = useDoclingHealthQuery({
enabled: !isAuthPage,
});
const { isUnhealthy: isDoclingUnhealthy } = useDoclingHealth();
const { isUnhealthy: isProviderUnhealthy } = useProviderHealth();
// For auth pages, render immediately without navigation
// This prevents the main layout from flashing
@ -45,12 +47,13 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const isOnKnowledgePage = pathname.startsWith("/knowledge");
const isUnhealthy = health?.status === "unhealthy" || isError;
const isBannerVisible = !isHealthLoading && isUnhealthy;
const isSettingsLoadingOrError = isSettingsLoading || !settings;
// Show loading state when backend isn't ready
if (isLoading || (isSettingsLoadingOrError && (isNoAuthMode || isAuthenticated))) {
if (
isLoading ||
(isSettingsLoadingOrError && (isNoAuthMode || isAuthenticated))
) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
@ -63,17 +66,31 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
// For all other pages, render with Langflow-styled navigation and task menu
return (
<div className=" h-screen w-screen flex items-center justify-center">
<div className="h-screen w-screen flex items-center justify-center">
<div
className={cn(
"app-grid-arrangement bg-black relative",
isBannerVisible && "banner-visible",
isPanelOpen && isOnKnowledgePage && !isMenuOpen && "filters-open",
isMenuOpen && "notifications-open",
isMenuOpen && "notifications-open"
)}
>
<div className={`w-full z-10 bg-background [grid-area:banner]`}>
<DoclingHealthBanner className="w-full" />
<div className="w-full z-10 bg-background [grid-area:banner]">
<AnimatedConditional
vertical
isOpen={isDoclingUnhealthy}
className="w-full"
>
<DoclingHealthBanner />
</AnimatedConditional>
{settings?.edited && (
<AnimatedConditional
vertical
isOpen={isProviderUnhealthy}
className="w-full"
>
<ProviderHealthBanner />
</AnimatedConditional>
)}
</div>
<ChatRenderer settings={settings}>{children}</ChatRenderer>

117
src/api/provider_health.py Normal file
View file

@ -0,0 +1,117 @@
"""Provider health check endpoint."""
from starlette.responses import JSONResponse
from utils.logging_config import get_logger
from config.settings import get_openrag_config
from api.provider_validation import validate_provider_setup
logger = get_logger(__name__)
async def check_provider_health(request):
"""
Check if the configured provider is healthy and properly validated.
Query parameters:
provider (optional): Provider to check ('openai', 'ollama', 'watsonx').
If not provided, checks the currently configured provider.
Returns:
200: Provider is healthy and validated
400: Invalid provider specified
503: Provider validation failed
"""
try:
# Get optional provider from query params
query_params = dict(request.query_params)
check_provider = query_params.get("provider")
# Get current config
current_config = get_openrag_config()
# Determine which provider to check
if check_provider:
provider = check_provider.lower()
else:
provider = current_config.provider.model_provider
# Validate provider name
valid_providers = ["openai", "ollama", "watsonx"]
if provider not in valid_providers:
return JSONResponse(
{
"status": "error",
"message": f"Invalid provider: {provider}. Must be one of: {', '.join(valid_providers)}",
"provider": provider,
},
status_code=400,
)
# Get provider configuration
if check_provider:
# If checking a specific provider, we may not have all config
# So we'll try to use what's available or fail gracefully
if provider == current_config.provider.model_provider:
# Use current config if checking current provider
api_key = current_config.provider.api_key
endpoint = current_config.provider.endpoint
project_id = current_config.provider.project_id
llm_model = current_config.agent.llm_model
embedding_model = current_config.knowledge.embedding_model
else:
# For other providers, we can't validate without config
return JSONResponse(
{
"status": "error",
"message": f"Cannot validate {provider} - not currently configured. Please configure it first.",
"provider": provider,
},
status_code=400,
)
else:
# Check current provider
api_key = current_config.provider.api_key
endpoint = current_config.provider.endpoint
project_id = current_config.provider.project_id
llm_model = current_config.agent.llm_model
embedding_model = current_config.knowledge.embedding_model
logger.info(f"Checking health for provider: {provider}")
# Validate provider setup
await validate_provider_setup(
provider=provider,
api_key=api_key,
embedding_model=embedding_model,
llm_model=llm_model,
endpoint=endpoint,
project_id=project_id,
)
return JSONResponse(
{
"status": "healthy",
"message": "Properly configured and validated",
"provider": provider,
"details": {
"llm_model": llm_model,
"embedding_model": embedding_model,
"endpoint": endpoint if provider in ["ollama", "watsonx"] else None,
},
},
status_code=200,
)
except Exception as e:
error_message = str(e)
logger.error(f"Provider health check failed for {provider}: {error_message}")
return JSONResponse(
{
"status": "unhealthy",
"message": error_message,
"provider": provider,
},
status_code=503,
)

View file

@ -15,6 +15,7 @@ from config.settings import (
get_openrag_config,
config_manager,
)
from api.provider_validation import validate_provider_setup
logger = get_logger(__name__)
@ -201,7 +202,115 @@ async def update_settings(request, session_manager):
status_code=400,
)
# Validate types early before modifying config
if "embedding_model" in body:
if (
not isinstance(body["embedding_model"], str)
or not body["embedding_model"].strip()
):
return JSONResponse(
{"error": "embedding_model must be a non-empty string"},
status_code=400,
)
if "table_structure" in body:
if not isinstance(body["table_structure"], bool):
return JSONResponse(
{"error": "table_structure must be a boolean"}, status_code=400
)
if "ocr" in body:
if not isinstance(body["ocr"], bool):
return JSONResponse(
{"error": "ocr must be a boolean"}, status_code=400
)
if "picture_descriptions" in body:
if not isinstance(body["picture_descriptions"], bool):
return JSONResponse(
{"error": "picture_descriptions must be a boolean"}, status_code=400
)
if "chunk_size" in body:
if not isinstance(body["chunk_size"], int) or body["chunk_size"] <= 0:
return JSONResponse(
{"error": "chunk_size must be a positive integer"}, status_code=400
)
if "chunk_overlap" in body:
if not isinstance(body["chunk_overlap"], int) or body["chunk_overlap"] < 0:
return JSONResponse(
{"error": "chunk_overlap must be a non-negative integer"},
status_code=400,
)
if "model_provider" in body:
if (
not isinstance(body["model_provider"], str)
or not body["model_provider"].strip()
):
return JSONResponse(
{"error": "model_provider must be a non-empty string"},
status_code=400,
)
if "api_key" in body:
if not isinstance(body["api_key"], str):
return JSONResponse(
{"error": "api_key must be a string"}, status_code=400
)
if "endpoint" in body:
if not isinstance(body["endpoint"], str) or not body["endpoint"].strip():
return JSONResponse(
{"error": "endpoint must be a non-empty string"}, status_code=400
)
if "project_id" in body:
if (
not isinstance(body["project_id"], str)
or not body["project_id"].strip()
):
return JSONResponse(
{"error": "project_id must be a non-empty string"}, status_code=400
)
# Validate provider setup if provider-related fields are being updated
# Do this BEFORE modifying any config
provider_fields = ["model_provider", "api_key", "endpoint", "project_id", "llm_model", "embedding_model"]
should_validate = any(field in body for field in provider_fields)
if should_validate:
try:
logger.info("Running provider validation before modifying config")
provider = body.get("model_provider", current_config.provider.model_provider)
api_key = body.get("api_key") if "api_key" in body and body["api_key"].strip() else current_config.provider.api_key
endpoint = body.get("endpoint", current_config.provider.endpoint)
project_id = body.get("project_id", current_config.provider.project_id)
llm_model = body.get("llm_model", current_config.agent.llm_model)
embedding_model = body.get("embedding_model", current_config.knowledge.embedding_model)
await validate_provider_setup(
provider=provider,
api_key=api_key,
embedding_model=embedding_model,
llm_model=llm_model,
endpoint=endpoint,
project_id=project_id,
)
logger.info(f"Provider validation successful for {provider}")
except Exception as e:
logger.error(f"Provider validation failed: {str(e)}")
return JSONResponse(
{"error": f"{str(e)}"},
status_code=400
)
# Update configuration
# Only reached if validation passed or wasn't needed
config_updated = False
# Update agent settings
@ -240,14 +349,6 @@ async def update_settings(request, session_manager):
# Update knowledge settings
if "embedding_model" in body:
if (
not isinstance(body["embedding_model"], str)
or not body["embedding_model"].strip()
):
return JSONResponse(
{"error": "embedding_model must be a non-empty string"},
status_code=400,
)
new_embedding_model = body["embedding_model"].strip()
current_config.knowledge.embedding_model = new_embedding_model
config_updated = True
@ -297,10 +398,6 @@ async def update_settings(request, session_manager):
# The config will still be saved
if "table_structure" in body:
if not isinstance(body["table_structure"], bool):
return JSONResponse(
{"error": "table_structure must be a boolean"}, status_code=400
)
current_config.knowledge.table_structure = body["table_structure"]
config_updated = True
@ -318,10 +415,6 @@ async def update_settings(request, session_manager):
logger.error(f"Failed to update docling settings in flow: {str(e)}")
if "ocr" in body:
if not isinstance(body["ocr"], bool):
return JSONResponse(
{"error": "ocr must be a boolean"}, status_code=400
)
current_config.knowledge.ocr = body["ocr"]
config_updated = True
@ -339,10 +432,6 @@ async def update_settings(request, session_manager):
logger.error(f"Failed to update docling settings in flow: {str(e)}")
if "picture_descriptions" in body:
if not isinstance(body["picture_descriptions"], bool):
return JSONResponse(
{"error": "picture_descriptions must be a boolean"}, status_code=400
)
current_config.knowledge.picture_descriptions = body["picture_descriptions"]
config_updated = True
@ -360,10 +449,6 @@ async def update_settings(request, session_manager):
logger.error(f"Failed to update docling settings in flow: {str(e)}")
if "chunk_size" in body:
if not isinstance(body["chunk_size"], int) or body["chunk_size"] <= 0:
return JSONResponse(
{"error": "chunk_size must be a positive integer"}, status_code=400
)
current_config.knowledge.chunk_size = body["chunk_size"]
config_updated = True
@ -380,11 +465,6 @@ async def update_settings(request, session_manager):
# The config will still be saved
if "chunk_overlap" in body:
if not isinstance(body["chunk_overlap"], int) or body["chunk_overlap"] < 0:
return JSONResponse(
{"error": "chunk_overlap must be a non-negative integer"},
status_code=400,
)
current_config.knowledge.chunk_overlap = body["chunk_overlap"]
config_updated = True
@ -404,43 +484,20 @@ async def update_settings(request, session_manager):
# Update provider settings
if "model_provider" in body:
if (
not isinstance(body["model_provider"], str)
or not body["model_provider"].strip()
):
return JSONResponse(
{"error": "model_provider must be a non-empty string"},
status_code=400,
)
current_config.provider.model_provider = body["model_provider"].strip()
config_updated = True
if "api_key" in body:
if not isinstance(body["api_key"], str):
return JSONResponse(
{"error": "api_key must be a string"}, status_code=400
)
# Only update if non-empty string (empty string means keep current value)
if body["api_key"].strip():
current_config.provider.api_key = body["api_key"]
config_updated = True
if "endpoint" in body:
if not isinstance(body["endpoint"], str) or not body["endpoint"].strip():
return JSONResponse(
{"error": "endpoint must be a non-empty string"}, status_code=400
)
current_config.provider.endpoint = body["endpoint"].strip()
config_updated = True
if "project_id" in body:
if (
not isinstance(body["project_id"], str)
or not body["project_id"].strip()
):
return JSONResponse(
{"error": "project_id must be a non-empty string"}, status_code=400
)
current_config.provider.project_id = body["project_id"].strip()
config_updated = True

View file

@ -39,6 +39,7 @@ from api import (
models,
nudges,
oidc,
provider_health,
router,
search,
settings,
@ -986,6 +987,14 @@ async def create_app():
),
methods=["POST"],
),
# Provider health check endpoint
Route(
"/provider/health",
require_auth(services["session_manager"])(
provider_health.check_provider_health
),
methods=["GET"],
),
# Models endpoints
Route(
"/models/openai",