Repository: comet-ml/opik
Pull request: [OPIK-689] [FR]: Add Support for Additional AI Provider Configurations (Local Models)
## Details
This PR should be merged only when the Python SDK part is merged.

Add the ability to select as a provider Ollama installed locally. 

![image](https://github.com/user-attachments/assets/02c2d551-cbbf-4086-a97e-c82ce5ee4f8c)
![image](https://github.com/user-attachments/assets/8f757294-35c8-4413-a917-289cb25c8c6b)

## Issues

Resolves #978

## Testing
Manually tested on localhost and dev env

## Documentation


Problem statement:
[FR]: Add Support for Additional AI Provider Configurations
### Proposal summary

Currently, the platform supports integrating AI providers like OpenAI in the configuration page for the playground. To improve flexibility and cater to a broader user base, we propose adding configurations for additional AI providers, specifically for locally hosted models such as those managed by Ollama and LM Studio.

### Motivation

1. Offer flexibility in choosing between cloud-based and locally hosted solutions.
2. The existing setup limits users to cloud-based AI providers, which can be restrictive due to internet dependency, high costs, and potential data privacy concerns. 
3. Users with offline environments or sensitive workflows need an option to leverage locally hosted AI models seamlessly.

Hints from issue discussion:
Hi @madhankumar2211 
This is a great idea !

@aadereiko Can we look into this once we have finished the dataset integration for the playground - The idea would be to have an AI provider that doesn't go through the BE proxy but rather Ollama running locally (see https://github.com/ollama/ollama?tab=readme-ov-file#chat-with-a-model)
Hey @madhankumar2211 @jverre 

A clever remark. Sure, I'll get to it right after I finish dataset integration :) 
Hey. tbh this is a deal breaker for me. :( I will come back to Opik once this is actually configurable. (I mean why have a dropdown menu anyways?)
![Image](https://github.com/user-attachments/assets/b3c848ac-23cf-47d4-90b9-fc8377ccada2)
@wijjj We are adding Gemini and Anthropic model this week so will have more providers soon, BE for it is done and just needs some FE tweaks.

Supporting locally hosted models is a bit different but we are looking into it and will try to support it soon
Hi Team, 

Implementation of self hosted model will be very useful. Looking forward for this update.
Just caught up with @andriidudar and we are working on experimental feature for this, @NaniNorris are you mostly interested in supporting self-hosted model in the playground or also for online evaluation rules ?
Adding a comment to this issue to signal my interest in this feature as well. I am using Opik specifically because it can be self-hosted for local/internal AI work with sensitive data. Thus remote AI providers are not an option for me.

[Ollama](https://ollama.com/) is one of the most common ways to run local models, soi I believe it would be a good choice to start with.

API docs: https://github.com/ollama/ollama/blob/main/docs/api.md
@LiveOverflow Would you like to use local models in the playground or also other parts of the platform ?

We are actively working on this, double checking we are building the right feature
@jverre I’ve noticed that there are a few others who are also interested in this feature request. Given this growing interest, I’d like to propose expanding the feature to support self-hosted LLMs, not just within the playground but across other parts of the platform, including the evaluation process where Ragas comes into play.

Looking forward to your thoughts on this!

Commit message: [OPIK-689] [FR]: Add Support for Additional AI Provider Configurations (Local Models)

Patch to review:
diff --git a/apps/opik-frontend/src/api/api.ts b/apps/opik-frontend/src/api/api.ts
index 81f2519765..28fb8ba8e1 100644
--- a/apps/opik-frontend/src/api/api.ts
+++ b/apps/opik-frontend/src/api/api.ts
@@ -25,7 +25,7 @@ export const COMPARE_EXPERIMENTS_KEY = "compare-experiments";
 export const SPANS_KEY = "spans";
 export const TRACES_KEY = "traces";
 export const TRACE_KEY = "trace";
-export const PROVIDERS_KEYS_KEY = "providerKeys";
+export const PROVIDERS_KEYS_KEY = "provider-keys";
 export const AUTOMATIONS_KEY = "automations";
 export const PROJECTS_KEY = "projects";
 export const PROJECT_STATISTICS_KEY = "project-statistics";
diff --git a/apps/opik-frontend/src/api/playground/createLogPlaygroundProcessor.ts b/apps/opik-frontend/src/api/playground/createLogPlaygroundProcessor.ts
index abc606768f..5bd3b2f460 100644
--- a/apps/opik-frontend/src/api/playground/createLogPlaygroundProcessor.ts
+++ b/apps/opik-frontend/src/api/playground/createLogPlaygroundProcessor.ts
@@ -16,10 +16,13 @@ import api, {
   TRACES_REST_ENDPOINT,
 } from "@/api/api";
 import { snakeCaseObj } from "@/lib/utils";
-import { getModelProvider } from "@/lib/llm";
 import { createBatchProcessor } from "@/lib/batches";
 import { RunStreamingReturn } from "@/api/playground/useCompletionProxyStreaming";
-import { LLMPromptConfigsType, PROVIDER_MODEL_TYPE } from "@/types/providers";
+import {
+  LLMPromptConfigsType,
+  PROVIDER_MODEL_TYPE,
+  PROVIDER_TYPE,
+} from "@/types/providers";
 import { ProviderMessageType } from "@/types/llm";
 
 export interface LogQueueParams extends RunStreamingReturn {
@@ -27,6 +30,7 @@ export interface LogQueueParams extends RunStreamingReturn {
   datasetItemId?: string;
   datasetName: string | null;
   model: PROVIDER_MODEL_TYPE | "";
+  provider: PROVIDER_TYPE | "";
   providerMessages: ProviderMessageType[];
   configs: LLMPromptConfigsType;
 }
@@ -98,7 +102,7 @@ const getSpanFromRun = (run: LogQueueParams, traceId: string): LogSpan => {
     output: { choices: run.choices ? run.choices : [] },
     usage: !run.usage ? undefined : pick(run.usage, USAGE_FIELDS_TO_SEND),
     metadata: {
-      created_from: run.model ? getModelProvider(run.model) : "",
+      created_from: run.provider,
       usage: run.usage,
       model: run.model,
       parameters: run.configs,
diff --git a/apps/opik-frontend/src/api/playground/useCompletionProxyStreaming.ts b/apps/opik-frontend/src/api/playground/useCompletionProxyStreaming.ts
index b266e95ba8..58b3b3ad8f 100644
--- a/apps/opik-frontend/src/api/playground/useCompletionProxyStreaming.ts
+++ b/apps/opik-frontend/src/api/playground/useCompletionProxyStreaming.ts
@@ -1,6 +1,7 @@
 import { useCallback } from "react";
-
 import dayjs from "dayjs";
+import isObject from "lodash/isObject";
+
 import { UsageType } from "@/types/shared";
 import {
   ChatCompletionMessageChoiceType,
@@ -8,6 +9,7 @@ import {
   ChatCompletionProviderErrorMessageType,
   ChatCompletionSuccessMessageType,
   ChatCompletionOpikErrorMessageType,
+  ChatCompletionPythonProxyErrorMessageType,
 } from "@/types/playground";
 import { isValidJsonObject, safelyParseJSON, snakeCaseObj } from "@/lib/utils";
 import { BASE_API_URL } from "@/api/api";
@@ -19,6 +21,7 @@ const getNowUtcTimeISOString = (): string => {
 };
 
 interface GetCompletionProxyStreamParams {
+  url?: string;
   model: PROVIDER_MODEL_TYPE | "";
   messages: ProviderMessageType[];
   signal: AbortSignal;
@@ -26,6 +29,12 @@ interface GetCompletionProxyStreamParams {
   workspaceName: string;
 }
 
+const isPythonProxyError = (
+  response: ChatCompletionResponse,
+): response is ChatCompletionPythonProxyErrorMessageType => {
+  return "detail" in response;
+};
+
 const isOpikError = (
   response: ChatCompletionResponse,
 ): response is ChatCompletionOpikErrorMessageType => {
@@ -42,13 +51,14 @@ const isProviderError = (
 };
 
 const getCompletionProxyStream = async ({
+  url = `${BASE_API_URL}/v1/private/chat/completions`,
   model,
   messages,
   signal,
   configs,
   workspaceName,
 }: GetCompletionProxyStreamParams) => {
-  return fetch(`${BASE_API_URL}/v1/private/chat/completions`, {
+  return fetch(url, {
     method: "POST",
     headers: {
       "Content-Type": "application/json",
@@ -67,6 +77,7 @@ const getCompletionProxyStream = async ({
 };
 
 export interface RunStreamingArgs {
+  url?: string;
   model: PROVIDER_MODEL_TYPE | "";
   messages: ProviderMessageType[];
   configs: LLMPromptConfigsType;
@@ -82,6 +93,7 @@ export interface RunStreamingReturn {
   choices: ChatCompletionMessageChoiceType[] | null;
   providerError: null | string;
   opikError: null | string;
+  pythonProxyError: null | string;
 }
 
 interface UseCompletionProxyStreamingParameters {
@@ -91,8 +103,9 @@ interface UseCompletionProxyStreamingParameters {
 const useCompletionProxyStreaming = ({
   workspaceName,
 }: UseCompletionProxyStreamingParameters) => {
-  const runStreaming = useCallback(
+  return useCallback(
     async ({
+      url,
       model,
       messages,
       configs,
@@ -106,11 +119,13 @@ const useCompletionProxyStreaming = ({
       let choices: ChatCompletionMessageChoiceType[] = [];
 
       // errors
+      let pythonProxyError = null;
       let opikError = null;
       let providerError = null;
 
       try {
         const response = await getCompletionProxyStream({
+          url,
           model,
           messages,
           configs,
@@ -156,12 +171,25 @@ const useCompletionProxyStreaming = ({
           opikError = parsedMessage.errors.join(" ");
         };
 
+        const handlePythonProxyErrorMessage = (
+          parsedMessage: ChatCompletionPythonProxyErrorMessageType,
+        ) => {
+          if (
+            isObject(parsedMessage.detail) &&
+            "error" in parsedMessage.detail
+          ) {
+            pythonProxyError = parsedMessage.detail.error;
+          } else {
+            pythonProxyError = parsedMessage.detail ?? "Python proxy error";
+          }
+        };
+
         // an analogue of true && reader
         // we need it to wait till the stream is closed
         while (reader) {
           const { done, value } = await reader.read();
 
-          if (done || opikError || providerError) {
+          if (done || opikError || pythonProxyError || providerError) {
             break;
           }
 
@@ -169,10 +197,17 @@ const useCompletionProxyStreaming = ({
           const lines = chunk.split("\n").filter((line) => line.trim() !== "");
 
           for (const line of lines) {
-            const parsed = safelyParseJSON(line) as ChatCompletionResponse;
+            const ollamaDataPrefix = "data:";
+            const JSONData = line.startsWith(ollamaDataPrefix)
+              ? line.split(ollamaDataPrefix)[1]
+              : line;
+
+            const parsed = safelyParseJSON(JSONData) as ChatCompletionResponse;
 
             // handle different message types
-            if (isOpikError(parsed)) {
+            if (isPythonProxyError(parsed)) {
+              handlePythonProxyErrorMessage(parsed);
+            } else if (isOpikError(parsed)) {
               handleOpikErrorMessage(parsed);
             } else if (isProviderError(parsed)) {
               handleAIPlatformErrorMessage(parsed);
@@ -188,6 +223,7 @@ const useCompletionProxyStreaming = ({
           result: accumulatedValue,
           providerError,
           opikError,
+          pythonProxyError,
           usage,
           choices,
         };
@@ -205,6 +241,7 @@ const useCompletionProxyStreaming = ({
           result: accumulatedValue,
           providerError,
           opikError: opikError || defaultErrorMessage,
+          pythonProxyError,
           usage: null,
           choices,
         };
@@ -212,8 +249,6 @@ const useCompletionProxyStreaming = ({
     },
     [workspaceName],
   );
-
-  return runStreaming;
 };
 
 export default useCompletionProxyStreaming;
diff --git a/apps/opik-frontend/src/api/provider-keys/useProviderKeys.tsx b/apps/opik-frontend/src/api/provider-keys/useProviderKeys.ts
similarity index 62%
rename from apps/opik-frontend/src/api/provider-keys/useProviderKeys.tsx
rename to apps/opik-frontend/src/api/provider-keys/useProviderKeys.ts
index 47e3b3c669..31b4d559bf 100644
--- a/apps/opik-frontend/src/api/provider-keys/useProviderKeys.tsx
+++ b/apps/opik-frontend/src/api/provider-keys/useProviderKeys.ts
@@ -5,6 +5,7 @@ import api, {
   QueryConfig,
 } from "@/api/api";
 import { ProviderKey } from "@/types/providers";
+import useLocalAIProviderData from "@/hooks/useLocalAIProviderData";
 
 type UseProviderKeysListParams = {
   workspaceName: string;
@@ -15,21 +16,28 @@ type UseProviderKeysListResponse = {
   total: number;
 };
 
-const getProviderKeys = async ({ signal }: QueryFunctionContext) => {
+const getProviderKeys = async (
+  { signal }: QueryFunctionContext,
+  extendWithLocalData: (
+    data: UseProviderKeysListResponse,
+  ) => UseProviderKeysListResponse,
+) => {
   const { data } = await api.get(PROVIDER_KEYS_REST_ENDPOINT, {
     signal,
   });
 
-  return data;
+  return extendWithLocalData(data);
 };
 
 export default function useProviderKeys(
   params: UseProviderKeysListParams,
   options?: QueryConfig<UseProviderKeysListResponse>,
 ) {
+  const { extendWithLocalData } = useLocalAIProviderData();
+
   return useQuery({
     queryKey: [PROVIDERS_KEYS_KEY, params],
-    queryFn: (context) => getProviderKeys(context),
+    queryFn: (context) => getProviderKeys(context, extendWithLocalData),
     ...options,
   });
 }
diff --git a/apps/opik-frontend/src/components/pages-shared/llm/PromptModelSelect/PromptModelSelect.tsx b/apps/opik-frontend/src/components/pages-shared/llm/PromptModelSelect/PromptModelSelect.tsx
index 6a0a3001ed..47c28e06b4 100644
--- a/apps/opik-frontend/src/components/pages-shared/llm/PromptModelSelect/PromptModelSelect.tsx
+++ b/apps/opik-frontend/src/components/pages-shared/llm/PromptModelSelect/PromptModelSelect.tsx
@@ -2,7 +2,6 @@ import React, { useCallback, useMemo, useRef, useState } from "react";
 import isNull from "lodash/isNull";
 import pick from "lodash/pick";
 
-import { PROVIDER_MODELS } from "@/constants/llm";
 import { PROVIDERS } from "@/constants/providers";
 
 import {
@@ -29,11 +28,12 @@ import { PROVIDER_MODEL_TYPE, PROVIDER_TYPE } from "@/types/providers";
 import useProviderKeys from "@/api/provider-keys/useProviderKeys";
 import AddEditAIProviderDialog from "@/components/shared/AddEditAIProviderDialog/AddEditAIProviderDialog";
 import { areAllProvidersConfigured } from "@/lib/provider";
+import useLLMProviderModelsData from "@/hooks/useLLMProviderModelsData";
 
 interface PromptModelSelectProps {
   value: PROVIDER_MODEL_TYPE | "";
   workspaceName: string;
-  onChange: (value: PROVIDER_MODEL_TYPE) => void;
+  onChange: (value: PROVIDER_MODEL_TYPE, provider: PROVIDER_TYPE) => void;
   hasError?: boolean;
   provider: PROVIDER_TYPE | "";
   onAddProvider?: (provider: PROVIDER_TYPE) => void;
@@ -57,6 +57,7 @@ const PromptModelSelect = ({
   const [openConfigDialog, setOpenConfigDialog] = React.useState(false);
   const [filterValue, setFilterValue] = useState("");
   const [openProviderMenu, setOpenProviderMenu] = useState<string | null>(null);
+  const { getProviderModels } = useLLMProviderModelsData();
 
   const { data } = useProviderKeys(
     {
@@ -74,7 +75,7 @@ const PromptModelSelect = ({
 
   const groupOptions = useMemo(() => {
     const filteredByConfiguredProviders = pick(
-      PROVIDER_MODELS,
+      getProviderModels(),
       configuredProviderKeys,
     );
 
@@ -97,10 +98,11 @@ const PromptModelSelect = ({
           label: PROVIDERS[providerName].label,
           options,
           icon: PROVIDERS[providerName].icon,
+          provider: providerName,
         };
       })
       .filter((g): g is NonNullable<typeof g> => !isNull(g));
-  }, [configuredProviderKeys, onlyWithStructuredOutput]);
+  }, [configuredProviderKeys, onlyWithStructuredOutput, getProviderModels]);
 
   const filteredOptions = useMemo(() => {
     if (filterValue === "") {
@@ -131,9 +133,9 @@ const PromptModelSelect = ({
 
   const handleOnChange = useCallback(
     (value: PROVIDER_MODEL_TYPE) => {
-      onChange(value);
+      onChange(value, openProviderMenu as PROVIDER_TYPE);
     },
-    [onChange],
+    [onChange, openProviderMenu],
   );
 
   const handleSelectOpenChange = useCallback((open: boolean) => {
@@ -191,16 +193,17 @@ const PromptModelSelect = ({
     return (
       <div>
         {groupOptions.map((group) => (
-          <Popover key={group.label} open={group.label === openProviderMenu}>
+          <Popover key={group.label} open={group.provider === openProviderMenu}>
             <PopoverTrigger asChild>
               <div
                 key={group.label}
-                onMouseEnter={() => setOpenProviderMenu(group.label)}
+                onMouseEnter={() => setOpenProviderMenu(group.provider)}
                 onMouseLeave={() => setOpenProviderMenu(null)}
                 className={cn(
                   "comet-body-s flex h-10 w-full items-center rounded-sm p-0 pl-2 hover:bg-primary-foreground justify-center",
                   {
-                    "bg-primary-foreground": group.label === openProviderMenu,
+                    "bg-primary-foreground":
+                      group.provider === openProviderMenu,
                   },
                 )}
               >
@@ -215,7 +218,7 @@ const PromptModelSelect = ({
               align="start"
               className="max-h-[400px] overflow-y-auto p-1"
               sideOffset={-5}
-              onMouseEnter={() => setOpenProviderMenu(group.label)}
+              onMouseEnter={() => setOpenProviderMenu(group.provider)}
               hideWhenDetached
             >
               {group.options.map((option) => {
@@ -308,6 +311,7 @@ const PromptModelSelect = ({
       </Select>
       <AddEditAIProviderDialog
         key={resetDialogKeyRef.current}
+        excludedProviders={configuredProviderKeys}
         open={openConfigDialog}
         setOpen={setOpenConfigDialog}
         onAddProvider={onAddProvider}
diff --git a/apps/opik-frontend/src/components/pages-shared/llm/PromptModelSettings/PromptModelConfigs.tsx b/apps/opik-frontend/src/components/pages-shared/llm/PromptModelSettings/PromptModelConfigs.tsx
index 0072790a63..b71ceed428 100644
--- a/apps/opik-frontend/src/components/pages-shared/llm/PromptModelSettings/PromptModelConfigs.tsx
+++ b/apps/opik-frontend/src/components/pages-shared/llm/PromptModelSettings/PromptModelConfigs.tsx
@@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button";
 
 import OpenAIModelConfigs from "@/components/pages-shared/llm/PromptModelSettings/providerConfigs/OpenAIModelConfigs";
 import AnthropicModelConfigs from "@/components/pages-shared/llm/PromptModelSettings/providerConfigs/AnthropicModelConfigs";
+import isEmpty from "lodash/isEmpty";
 
 interface PromptModelConfigsProps {
   provider: PROVIDER_TYPE | "";
@@ -53,12 +54,12 @@ const PromptModelConfigs = ({
     return;
   };
 
-  const noProvider = provider === "";
+  const disabled = provider === "" || isEmpty(configs);
 
   return (
     <DropdownMenu>
       <DropdownMenuTrigger asChild>
-        <Button variant="outline" size={size} disabled={noProvider}>
+        <Button variant="outline" size={size} disabled={disabled}>
           <Settings2 className="size-3.5" />
         </Button>
       </DropdownMenuTrigger>
diff --git a/apps/opik-frontend/src/components/pages/ConfigurationPage/AIProvidersTab/AIProvidersRowActionsCell.tsx b/apps/opik-frontend/src/components/pages/ConfigurationPage/AIProvidersTab/AIProvidersRowActionsCell.tsx
index c05b1fefa8..c16d80cbe5 100644
--- a/apps/opik-frontend/src/components/pages/ConfigurationPage/AIProvidersTab/AIProvidersRowActionsCell.tsx
+++ b/apps/opik-frontend/src/components/pages/ConfigurationPage/AIProvidersTab/AIProvidersRowActionsCell.tsx
@@ -12,9 +12,10 @@ import { Button } from "@/components/ui/button";
 import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
 import CellWrapper from "@/components/shared/DataTableCells/CellWrapper";
 import AddEditAIProviderDialog from "@/components/shared/AddEditAIProviderDialog/AddEditAIProviderDialog";
-import { ProviderKey } from "@/types/providers";
+import { PROVIDER_LOCATION_TYPE, ProviderKey } from "@/types/providers";
 import useProviderKeysDeleteMutation from "@/api/provider-keys/useProviderKeysDeleteMutation";
 import { PROVIDERS } from "@/constants/providers";
+import useLocalAIProviderData from "@/hooks/useLocalAIProviderData";
 
 const AIProvidersRowActionsCell: React.FunctionComponent<
   CellContext<ProviderKey, unknown>
@@ -23,13 +24,25 @@ const AIProvidersRowActionsCell: React.FunctionComponent<
   const providerKey = context.row.original;
   const [open, setOpen] = useState<boolean | number>(false);
 
+  const { deleteLocalAIProviderData } = useLocalAIProviderData();
+
   const { mutate: deleteProviderKey } = useProviderKeysDeleteMutation();
 
   const deleteProviderKeyHandler = useCallback(() => {
-    deleteProviderKey({
-      providerId: providerKey.id,
-    });
-  }, [providerKey.id, deleteProviderKey]);
+    const config = PROVIDERS[providerKey.provider];
+    if (config.locationType === PROVIDER_LOCATION_TYPE.local) {
+      deleteLocalAIProviderData(providerKey.provider);
+    } else {
+      deleteProviderKey({
+        providerId: providerKey.id,
+      });
+    }
+  }, [
+    providerKey.provider,
+    providerKey.id,
+    deleteLocalAIProviderData,
+    deleteProviderKey,
+  ]);
 
   return (
     <CellWrapper
diff --git a/apps/opik-frontend/src/components/pages/ConfigurationPage/AIProvidersTab/AIProvidersTab.tsx b/apps/opik-frontend/src/components/pages/ConfigurationPage/AIProvidersTab/AIProvidersTab.tsx
index 3ca9e515d6..12dccf88ff 100644
--- a/apps/opik-frontend/src/components/pages/ConfigurationPage/AIProvidersTab/AIProvidersTab.tsx
+++ b/apps/opik-frontend/src/components/pages/ConfigurationPage/AIProvidersTab/AIProvidersTab.tsx
@@ -63,6 +63,11 @@ const AIProvidersTab = () => {
 
   const providerKeys = useMemo(() => data?.content ?? [], [data?.content]);
 
+  const configuredProviderKeys = useMemo(
+    () => providerKeys.map((p) => p.provider),
+    [providerKeys],
+  );
+
   const filteredProviderKeys = useMemo(() => {
     if (providerKeys?.length === 0 || search === "") {
       return providerKeys;
@@ -140,6 +145,7 @@ const AIProvidersTab = () => {
         />
       </div>
       <AddEditAIProviderDialog
+        excludedProviders={configuredProviderKeys}
         key={resetDialogKeyRef.current}
         open={openDialog}
         setOpen={setOpenDialog}
diff --git a/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundOutputs/PlaygroundOutputActions/usePromptDatasetItemCombination.ts b/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundOutputs/PlaygroundOutputActions/usePromptDatasetItemCombination.ts
index 75197e8bca..00681d2ca7 100644
--- a/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundOutputs/PlaygroundOutputActions/usePromptDatasetItemCombination.ts
+++ b/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundOutputs/PlaygroundOutputActions/usePromptDatasetItemCombination.ts
@@ -16,6 +16,7 @@ import mustache from "mustache";
 import cloneDeep from "lodash/cloneDeep";
 import set from "lodash/set";
 import isObject from "lodash/isObject";
+import useLocalAIProviderData from "@/hooks/useLocalAIProviderData";
 
 export interface DatasetItemPromptCombination {
   datasetItem?: DatasetItem;
@@ -85,6 +86,8 @@ const usePromptDatasetItemCombination = ({
   // the prop is just taken as the value on the moment of creation
   const isToStopRef = useRef(isToStop);
 
+  const { getLocalIAProviderURL } = useLocalAIProviderData();
+
   const runStreaming = useCompletionProxyStreaming({
     workspaceName,
   });
@@ -138,6 +141,7 @@ const usePromptDatasetItemCombination = ({
         );
 
         const run = await runStreaming({
+          url: getLocalIAProviderURL(prompt.provider),
           model: prompt.model,
           messages: providerMessages,
           configs: prompt.configs,
@@ -149,7 +153,8 @@ const usePromptDatasetItemCombination = ({
           },
         });
 
-        const error = run.opikError || run.providerError;
+        const error =
+          run.opikError || run.providerError || run.pythonProxyError;
 
         updateOutput(prompt.id, datasetItemId, {
           isLoading: false,
@@ -160,7 +165,7 @@ const usePromptDatasetItemCombination = ({
           providerMessages,
           configs: prompt.configs,
           model: prompt.model,
-
+          provider: prompt.provider,
           promptId: prompt.id,
           datasetName,
           datasetItemId: datasetItemId,
@@ -182,10 +187,11 @@ const usePromptDatasetItemCombination = ({
     },
 
     [
-      datasetName,
-      runStreaming,
-      updateOutput,
       addAbortController,
+      updateOutput,
+      runStreaming,
+      getLocalIAProviderURL,
+      datasetName,
       deleteAbortController,
     ],
   );
diff --git a/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompt.tsx b/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompt.tsx
index 8b6bbfc8d6..523add9792 100644
--- a/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompt.tsx
+++ b/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompt.tsx
@@ -15,11 +15,7 @@ import {
   generateDefaultPrompt,
   getDefaultConfigByProvider,
 } from "@/lib/playground";
-import {
-  generateDefaultLLMPromptMessage,
-  getModelProvider,
-  getNextMessageType,
-} from "@/lib/llm";
+import { generateDefaultLLMPromptMessage, getNextMessageType } from "@/lib/llm";
 import LLMPromptMessages from "@/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessages";
 import PromptModelSelect from "@/components/pages-shared/llm/PromptModelSelect/PromptModelSelect";
 import { getAlphabetLetter } from "@/lib/utils";
@@ -33,9 +29,11 @@ import {
   useUpdateOutput,
   useUpdatePrompt,
 } from "@/store/PlaygroundStore";
-import { getDefaultProviderKey } from "@/lib/provider";
-import { PROVIDERS } from "@/constants/providers";
 import useLastPickedModel from "@/components/pages/PlaygroundPage/PlaygroundPrompts/useLastPickedModel";
+import {
+  ModelResolver,
+  ProviderResolver,
+} from "@/hooks/useLLMProviderModelsData";
 
 interface PlaygroundPromptProps {
   workspaceName: string;
@@ -43,6 +41,8 @@ interface PlaygroundPromptProps {
   promptId: string;
   providerKeys: PROVIDER_TYPE[];
   isPendingProviderKeys: boolean;
+  providerResolver: ProviderResolver;
+  modelResolver: ModelResolver;
 }
 
 const PlaygroundPrompt = ({
@@ -51,6 +51,8 @@ const PlaygroundPrompt = ({
   index,
   providerKeys,
   isPendingProviderKeys,
+  providerResolver,
+  modelResolver,
 }: PlaygroundPromptProps) => {
   const checkedIfModelIsValidRef = useRef(false);
 
@@ -66,7 +68,7 @@ const PlaygroundPrompt = ({
   const deletePrompt = useDeletePrompt();
   const updateOutput = useUpdateOutput();
 
-  const provider = model ? getModelProvider(model) : "";
+  const provider = providerResolver(model);
 
   const hintMessage = datasetVariables?.length
     ? `Reference dataset variables using mustache syntax: ${datasetVariables
@@ -91,6 +93,8 @@ const PlaygroundPrompt = ({
     const newPrompt = generateDefaultPrompt({
       initPrompt: prompt,
       setupProviders: providerKeys,
+      providerResolver: providerResolver,
+      modelResolver: modelResolver,
     });
 
     addPrompt(newPrompt, index + 1);
@@ -116,32 +120,40 @@ const PlaygroundPrompt = ({
   );
 
   const handleUpdateModel = useCallback(
-    (model: PROVIDER_MODEL_TYPE) => {
-      updatePrompt(promptId, { model });
-      setLastPickedModel(model);
+    (newModel: PROVIDER_MODEL_TYPE, newProvider: PROVIDER_TYPE) => {
+      updatePrompt(promptId, {
+        model: newModel,
+        provider: newProvider,
+        ...(newProvider !== provider && {
+          configs: getDefaultConfigByProvider(newProvider),
+        }),
+      });
+      setLastPickedModel(newModel);
     },
-    [updatePrompt, promptId, setLastPickedModel],
+    [updatePrompt, promptId, provider, setLastPickedModel],
   );
 
   const handleAddProvider = useCallback(
     (provider: PROVIDER_TYPE) => {
-      const modelProvider = model ? getModelProvider(model) : "";
-      const noCurrentModel = !modelProvider;
-
-      if (noCurrentModel) {
-        const newModel = PROVIDERS[provider]?.defaultModel || "";
-
-        const newDefaultConfigs = provider
-          ? getDefaultConfigByProvider(provider)
-          : {};
+      const newModel = modelResolver(model, providerKeys, provider);
 
+      if (newModel !== model) {
+        const newProvider = providerResolver(newModel);
         updatePrompt(promptId, {
           model: newModel,
-          configs: newDefaultConfigs,
+          provider: newProvider,
+          configs: getDefaultConfigByProvider(newProvider),
         });
       }
     },
-    [model, promptId, updatePrompt],
+    [
+      modelResolver,
+      model,
+      providerKeys,
+      providerResolver,
+      updatePrompt,
+      promptId,
+    ],
   );
 
   useEffect(() => {
@@ -149,40 +161,24 @@ const PlaygroundPrompt = ({
     if (!checkedIfModelIsValidRef.current && !isPendingProviderKeys) {
       checkedIfModelIsValidRef.current = true;
 
-      const modelProvider = model ? getModelProvider(model) : "";
+      const newModel = modelResolver(model, providerKeys);
 
-      const noModelProviderWhenProviderKeysSet =
-        !modelProvider && providerKeys.length > 0;
-      const modelProviderIsNotFromProviderKeys =
-        modelProvider && !providerKeys.includes(modelProvider);
-
-      const needToChangeProvider =
-        noModelProviderWhenProviderKeysSet ||
-        modelProviderIsNotFromProviderKeys;
+      if (newModel !== model) {
+        const newProvider = providerResolver(newModel);
+        updatePrompt(promptId, {
+          model: newModel,
+          provider: newProvider,
+          configs: getDefaultConfigByProvider(newProvider),
+        });
 
-      if (!needToChangeProvider) {
-        return;
+        updateOutput(promptId, "", { value: "" });
       }
-
-      const newProvider = getDefaultProviderKey(providerKeys);
-      const newModel = newProvider
-        ? PROVIDERS[newProvider]?.defaultModel || ""
-        : "";
-
-      const newDefaultConfigs = newProvider
-        ? getDefaultConfigByProvider(newProvider)
-        : {};
-
-      updatePrompt(promptId, {
-        model: newModel,
-        configs: newDefaultConfigs,
-      });
-
-      updateOutput(promptId, "", { value: "" });
     }
   }, [
     providerKeys,
     isPendingProviderKeys,
+    providerResolver,
+    modelResolver,
     updateOutput,
     updatePrompt,
     promptId,
diff --git a/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompts.tsx b/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompts.tsx
index c14df24854..8d7eae76d2 100644
--- a/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompts.tsx
+++ b/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompts.tsx
@@ -11,6 +11,7 @@ import {
   useSetPromptMap,
 } from "@/store/PlaygroundStore";
 import useLastPickedModel from "@/components/pages/PlaygroundPage/PlaygroundPrompts/useLastPickedModel";
+import useLLMProviderModelsData from "@/hooks/useLLMProviderModelsData";
 
 interface PlaygroundPromptsState {
   workspaceName: string;
@@ -32,11 +33,15 @@ const PlaygroundPrompts = ({
   const promptIds = usePromptIds();
 
   const [lastPickedModel] = useLastPickedModel();
+  const { calculateModelProvider, calculateDefaultModel } =
+    useLLMProviderModelsData();
 
   const handleAddPrompt = () => {
     const newPrompt = generateDefaultPrompt({
       setupProviders: providerKeys,
       lastPickedModel,
+      providerResolver: calculateModelProvider,
+      modelResolver: calculateDefaultModel,
     });
     addPrompt(newPrompt);
   };
@@ -45,10 +50,19 @@ const PlaygroundPrompts = ({
     const newPrompt = generateDefaultPrompt({
       setupProviders: providerKeys,
       lastPickedModel,
+      providerResolver: calculateModelProvider,
+      modelResolver: calculateDefaultModel,
     });
     setPromptMap([newPrompt.id], { [newPrompt.id]: newPrompt });
     onResetHeight();
-  }, [setPromptMap, providerKeys, lastPickedModel, onResetHeight]);
+  }, [
+    providerKeys,
+    lastPickedModel,
+    calculateModelProvider,
+    calculateDefaultModel,
+    setPromptMap,
+    onResetHeight,
+  ]);
 
   useEffect(() => {
     // hasn't been initialized yet or the last prompt is removed
@@ -84,6 +98,8 @@ const PlaygroundPrompts = ({
             key={promptId}
             providerKeys={providerKeys}
             isPendingProviderKeys={isPendingProviderKeys}
+            providerResolver={calculateModelProvider}
+            modelResolver={calculateDefaultModel}
           />
         ))}
       </div>
diff --git a/apps/opik-frontend/src/components/pages/TracesPage/RulesTab/AddEditRuleDialog/LLMJudgeRuleDetails.tsx b/apps/opik-frontend/src/components/pages/TracesPage/RulesTab/AddEditRuleDialog/LLMJudgeRuleDetails.tsx
index 0d8a7d5536..7c6be7cc0c 100644
--- a/apps/opik-frontend/src/components/pages/TracesPage/RulesTab/AddEditRuleDialog/LLMJudgeRuleDetails.tsx
+++ b/apps/opik-frontend/src/components/pages/TracesPage/RulesTab/AddEditRuleDialog/LLMJudgeRuleDetails.tsx
@@ -25,10 +25,11 @@ import {
   LLMMessage,
   LLMPromptTemplate,
 } from "@/types/llm";
-import { generateDefaultLLMPromptMessage, getModelProvider } from "@/lib/llm";
+import { generateDefaultLLMPromptMessage } from "@/lib/llm";
 import { PROVIDER_MODEL_TYPE } from "@/types/providers";
 import { safelyGetPromptMustacheTags } from "@/lib/prompt";
 import { EvaluationRuleFormType } from "@/components/pages/TracesPage/RulesTab/AddEditRuleDialog/schema";
+import useLLMProviderModelsData from "@/hooks/useLLMProviderModelsData";
 
 const MESSAGE_TYPE_OPTIONS = [
   {
@@ -61,6 +62,7 @@ const LLMJudgeRuleDetails: React.FC<LLMJudgeRuleDetailsProps> = ({
   form,
 }) => {
   const cache = useRef<Record<string | LLM_JUDGE, LLMPromptTemplate>>({});
+  const { calculateModelProvider } = useLLMProviderModelsData();
 
   return (
     <>
@@ -69,7 +71,7 @@ const LLMJudgeRuleDetails: React.FC<LLMJudgeRuleDetailsProps> = ({
         name="llmJudgeDetails.model"
         render={({ field, formState }) => {
           const model = field.value as PROVIDER_MODEL_TYPE | "";
-          const provider = model ? getModelProvider(model) : "";
+          const provider = calculateModelProvider(model);
           const validationErrors = get(formState.errors, [
             "llmJudgeDetails",
             "model",
diff --git a/apps/opik-frontend/src/components/shared/AddEditAIProviderDialog/AddEditAIProviderDialog.tsx b/apps/opik-frontend/src/components/shared/AddEditAIProviderDialog/AddEditAIProviderDialog.tsx
index 47ac3aef21..d4e042f282 100644
--- a/apps/opik-frontend/src/components/shared/AddEditAIProviderDialog/AddEditAIProviderDialog.tsx
+++ b/apps/opik-frontend/src/components/shared/AddEditAIProviderDialog/AddEditAIProviderDialog.tsx
@@ -1,4 +1,12 @@
-import React, { useCallback, useState } from "react";
+import React, { useCallback, useMemo } from "react";
+import { useForm, UseFormReturn } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import get from "lodash/get";
+import isFunction from "lodash/isFunction";
+import isArray from "lodash/isArray";
+
+import { cn } from "@/lib/utils";
 import { Button } from "@/components/ui/button";
 import {
   Dialog,
@@ -8,22 +16,40 @@ import {
   DialogHeader,
   DialogTitle,
 } from "@/components/ui/dialog";
+import {
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  FormMessage,
+} from "@/components/ui/form";
 import { Label } from "@/components/ui/label";
-import { ProviderKey, PROVIDER_TYPE } from "@/types/providers";
+import {
+  LocalAIProviderData,
+  PROVIDER_LOCATION_TYPE,
+  PROVIDER_TYPE,
+  ProviderKey,
+} from "@/types/providers";
+import SelectBox from "@/components/shared/SelectBox/SelectBox";
 import useProviderKeysUpdateMutation from "@/api/provider-keys/useProviderKeysUpdateMutation";
 import useProviderKeysCreateMutation from "@/api/provider-keys/useProviderKeysCreateMutation";
-import SelectBox from "@/components/shared/SelectBox/SelectBox";
+import useLocalAIProviderData from "@/hooks/useLocalAIProviderData";
 import { PROVIDERS, PROVIDERS_OPTIONS } from "@/constants/providers";
 import { SelectItem } from "@/components/ui/select";
 import { DropdownOption } from "@/types/shared";
-import EyeInput from "@/components/shared/EyeInput/EyeInput";
-import isFunction from "lodash/isFunction";
+import {
+  AIProviderFormSchema,
+  AIProviderFormType,
+} from "@/components/shared/AddEditAIProviderDialog/schema";
+import CloudAIProviderDetails from "@/components/shared/AddEditAIProviderDialog/CloudAIProviderDetails";
+import LocalAIProviderDetails from "@/components/shared/AddEditAIProviderDialog/LocalAIProviderDetails";
 
 type AddEditAIProviderDialogProps = {
   providerKey?: ProviderKey;
   open: boolean;
   setOpen: (open: boolean) => void;
   onAddProvider?: (provider: PROVIDER_TYPE) => void;
+  excludedProviders?: PROVIDER_TYPE[];
 };
 
 const AddEditAIProviderDialog: React.FC<AddEditAIProviderDialogProps> = ({
@@ -31,18 +57,40 @@ const AddEditAIProviderDialog: React.FC<AddEditAIProviderDialogProps> = ({
   open,
   setOpen,
   onAddProvider,
+  excludedProviders,
 }) => {
+  const { getLocalAIProviderData, setLocalAIProviderData } =
+    useLocalAIProviderData();
   const { mutate: createMutate } = useProviderKeysCreateMutation();
   const { mutate: updateMutate } = useProviderKeysUpdateMutation();
-  const [provider, setProvider] = useState<PROVIDER_TYPE | "">(
-    providerKey?.provider || "",
-  );
-  const [apiKey, setApiKey] = useState("");
 
-  const isEdit = Boolean(providerKey);
-  const isValid = Boolean(apiKey.length);
+  const localData = useMemo(() => {
+    return providerKey?.provider
+      ? getLocalAIProviderData(providerKey.provider)
+      : undefined;
+  }, [getLocalAIProviderData, providerKey]);
+
+  const form: UseFormReturn<AIProviderFormType> = useForm<
+    z.infer<typeof AIProviderFormSchema>
+  >({
+    resolver: zodResolver(AIProviderFormSchema),
+    defaultValues: {
+      provider: providerKey?.provider || "",
+      locationType: providerKey?.provider
+        ? PROVIDERS[providerKey.provider]?.locationType
+        : PROVIDER_LOCATION_TYPE.cloud,
+      apiKey: "",
+      url: localData?.url ?? "",
+      models: localData?.models ?? "",
+    },
+  });
 
-  const providerName = (provider && PROVIDERS[provider]?.label) || "";
+  const provider = form.watch("provider") as PROVIDER_TYPE | "";
+
+  const isEdit = Boolean(providerKey);
+  const isCloudProvider =
+    provider === "" ||
+    PROVIDERS[provider].locationType === PROVIDER_LOCATION_TYPE.cloud;
 
   const title = isEdit
     ? "Edit AI provider configuration"
@@ -50,9 +98,33 @@ const AddEditAIProviderDialog: React.FC<AddEditAIProviderDialogProps> = ({
 
   const buttonText = isEdit ? "Update configuration" : "Save configuration";
 
-  const apiKeyLabel = provider ? `${providerName} API Key` : "API Key";
+  const localConfigHandler = useCallback(() => {
+    const data: LocalAIProviderData = {
+      created_at: new Date().toISOString(),
+      ...localData,
+      url: form.getValues("url"),
+      models: form.getValues("models"),
+    };
+
+    if (provider) {
+      setLocalAIProviderData(provider, data);
+      if (isFunction(onAddProvider)) {
+        onAddProvider(provider);
+      }
+    }
 
-  const submitHandler = useCallback(() => {
+    setOpen(false);
+  }, [
+    localData,
+    form,
+    provider,
+    setOpen,
+    setLocalAIProviderData,
+    onAddProvider,
+  ]);
+
+  const cloudConfigHandler = useCallback(() => {
+    const apiKey = form.getValues("apiKey");
     if (isEdit) {
       updateMutate({
         providerKey: {
@@ -72,21 +144,41 @@ const AddEditAIProviderDialog: React.FC<AddEditAIProviderDialogProps> = ({
         },
       });
     }
+    setOpen(false);
   }, [
-    createMutate,
+    form,
     isEdit,
-    apiKey,
-    updateMutate,
     provider,
+    setOpen,
+    updateMutate,
     providerKey,
     onAddProvider,
+    createMutate,
   ]);
 
+  const onSubmit = useCallback(
+    () => (isCloudProvider ? cloudConfigHandler() : localConfigHandler()),
+    [isCloudProvider, cloudConfigHandler, localConfigHandler],
+  );
+
+  const options = useMemo(() => {
+    return isArray(excludedProviders)
+      ? PROVIDERS_OPTIONS.filter(
+          ({ value }) => !excludedProviders.includes(value),
+        )
+      : PROVIDERS_OPTIONS;
+  }, [excludedProviders]);
+
   const renderOption = (option: DropdownOption<string>) => {
     const Icon = PROVIDERS[option.value as PROVIDER_TYPE]?.icon;
 
     return (
-      <SelectItem key={option.value} value={option.value} withoutCheck>
+      <SelectItem
+        key={option.value}
+        value={option.value}
+        description={<div className="pl-6">{option.description}</div>}
+        withoutCheck
+      >
         <div className="flex items-center gap-2">
           <Icon />
           {option.label}
@@ -101,50 +193,61 @@ const AddEditAIProviderDialog: React.FC<AddEditAIProviderDialogProps> = ({
         <DialogHeader>
           <DialogTitle>{title}</DialogTitle>
         </DialogHeader>
-        <div className="flex flex-col gap-2 pb-4">
-          <Label>Provider</Label>
-          <SelectBox
-            disabled={isEdit}
-            renderOption={renderOption}
-            value={provider}
-            onChange={(v) => setProvider(v as PROVIDER_TYPE)}
-            options={PROVIDERS_OPTIONS}
-            placeholder="Select a provider"
-          />
-        </div>
-        <div className="flex flex-col gap-2 pb-4">
-          <Label htmlFor="apiKey">{apiKeyLabel}</Label>
-          <EyeInput
-            id="apiKey"
-            placeholder={apiKeyLabel}
-            value={apiKey}
-            onChange={(e) => setApiKey(e.target.value)}
-          />
-          {provider && (
-            <span className="comet-body-s mt-1 text-light-slate">
-              Get your {providerName} API key{" "}
-              <Button variant="link" size="sm" asChild className="px-0">
-                <a
-                  href={PROVIDERS[provider]?.apiKeyURL}
-                  target="_blank"
-                  rel="noreferrer"
-                >
-                  here
-                </a>
-              </Button>
-              .
-            </span>
-          )}
-        </div>
+        <Form {...form}>
+          <form
+            className="flex flex-col gap-4 pb-4"
+            onSubmit={form.handleSubmit(onSubmit)}
+          >
+            <FormField
+              control={form.control}
+              name="provider"
+              render={({ field, formState }) => {
+                const validationErrors = get(formState.errors, ["provider"]);
+
+                return (
+                  <FormItem>
+                    <Label>Provider</Label>
+                    <FormControl>
+                      <SelectBox
+                        disabled={isEdit}
+                        renderOption={renderOption}
+                        value={field.value}
+                        onChange={(v) => {
+                          const p = v as PROVIDER_TYPE;
+                          form.setValue(
+                            "locationType",
+                            PROVIDERS[p].locationType,
+                          );
+                          field.onChange(p);
+                        }}
+                        options={options}
+                        placeholder="Select a provider"
+                        className={cn({
+                          "border-destructive": Boolean(
+                            validationErrors?.message,
+                          ),
+                        })}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                );
+              }}
+            />
+            {isCloudProvider ? (
+              <CloudAIProviderDetails provider={provider} form={form} />
+            ) : (
+              <LocalAIProviderDetails provider={provider} form={form} />
+            )}
+          </form>
+        </Form>
         <DialogFooter>
           <DialogClose asChild>
             <Button variant="outline">Cancel</Button>
           </DialogClose>
-          <DialogClose asChild>
-            <Button type="submit" disabled={!isValid} onClick={submitHandler}>
-              {buttonText}
-            </Button>
-          </DialogClose>
+          <Button type="submit" onClick={form.handleSubmit(onSubmit)}>
+            {buttonText}
+          </Button>
         </DialogFooter>
       </DialogContent>
     </Dialog>
diff --git a/apps/opik-frontend/src/components/shared/AddEditAIProviderDialog/CloudAIProviderDetails.tsx b/apps/opik-frontend/src/components/shared/AddEditAIProviderDialog/CloudAIProviderDetails.tsx
new file mode 100644
index 0000000000..016aeb573c
--- /dev/null
+++ b/apps/opik-frontend/src/components/shared/AddEditAIProviderDialog/CloudAIProviderDetails.tsx
@@ -0,0 +1,78 @@
+import React from "react";
+import { UseFormReturn } from "react-hook-form";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { PROVIDER_TYPE } from "@/types/providers";
+import { CLOUD_PROVIDER_OPTION_TYPE, PROVIDERS } from "@/constants/providers";
+import EyeInput from "@/components/shared/EyeInput/EyeInput";
+import { AIProviderFormType } from "@/components/shared/AddEditAIProviderDialog/schema";
+import get from "lodash/get";
+import {
+  FormControl,
+  FormField,
+  FormItem,
+  FormMessage,
+} from "@/components/ui/form";
+import { cn } from "@/lib/utils";
+
+type CloudAIProviderDetailsProps = {
+  provider: PROVIDER_TYPE | "";
+  form: UseFormReturn<AIProviderFormType>;
+};
+
+const CloudAIProviderDetails: React.FC<CloudAIProviderDetailsProps> = ({
+  provider,
+  form,
+}) => {
+  const providerName = (provider && PROVIDERS[provider]?.label + " ") || "";
+  const apiKeyLabel = `${providerName}API Key`;
+
+  return (
+    <div className="flex flex-col gap-2 pb-4">
+      <FormField
+        control={form.control}
+        name="apiKey"
+        render={({ field, formState }) => {
+          const validationErrors = get(formState.errors, ["apiKey"]);
+
+          return (
+            <FormItem>
+              <Label htmlFor="apiKey">{apiKeyLabel}</Label>
+              <FormControl>
+                <EyeInput
+                  id="apiKey"
+                  placeholder={apiKeyLabel}
+                  value={field.value}
+                  onChange={(e) => field.onChange(e.target.value)}
+                  className={cn({
+                    "border-destructive": Boolean(validationErrors?.message),
+                  })}
+                />
+              </FormControl>
+              <FormMessage />
+            </FormItem>
+          );
+        }}
+      />
+      {provider && (
+        <span className="comet-body-s mt-1 text-light-slate">
+          Get your {providerName} API key{" "}
+          <Button variant="link" size="sm" asChild className="px-0">
+            <a
+              href={
+                (PROVIDERS[provider] as CLOUD_PROVIDER_OPTION_TYPE)?.apiKeyURL
+              }
+              target="_blank"
+              rel="noreferrer"
+            >
+              here
+            </a>
+          </Button>
+          .
+        </span>
+      )}
+    </div>
+  );
+};
+
+export default CloudAIProviderDetails;
diff --git a/apps/opik-frontend/src/components/shared/AddEditAIProviderDialog/LocalAIProviderDetails.tsx b/apps/opik-frontend/src/components/shared/AddEditAIProviderDialog/LocalAIProviderDetails.tsx
new file mode 100644
index 0000000000..4d8fe8bb00
--- /dev/null
+++ b/apps/opik-frontend/src/components/shared/AddEditAIProviderDialog/LocalAIProviderDetails.tsx
@@ -0,0 +1,93 @@
+import React from "react";
+import { UseFormReturn } from "react-hook-form";
+import { Label } from "@/components/ui/label";
+import { PROVIDER_TYPE } from "@/types/providers";
+import { PROVIDERS } from "@/constants/providers";
+import { AIProviderFormType } from "@/components/shared/AddEditAIProviderDialog/schema";
+import get from "lodash/get";
+import {
+  FormControl,
+  FormField,
+  FormItem,
+  FormMessage,
+} from "@/components/ui/form";
+import { cn } from "@/lib/utils";
+import { Input } from "@/components/ui/input";
+import { Description } from "@/components/ui/description";
+
+type LocalAIProviderDetailsProps = {
+  provider: PROVIDER_TYPE | "";
+  form: UseFormReturn<AIProviderFormType>;
+};
+
+const LocalAIProviderDetails: React.FC<LocalAIProviderDetailsProps> = ({
+  provider,
+  form,
+}) => {
+  const providerName = (provider && PROVIDERS[provider]?.label + " ") || "";
+  const urlLabel = `${providerName}URL`;
+
+  return (
+    <>
+      <div className="flex flex-col gap-2">
+        <FormField
+          control={form.control}
+          name="url"
+          render={({ field, formState }) => {
+            const validationErrors = get(formState.errors, ["url"]);
+
+            return (
+              <FormItem>
+                <Label htmlFor="url">{urlLabel}</Label>
+                <FormControl>
+                  <Input
+                    id="url"
+                    placeholder={urlLabel}
+                    value={field.value}
+                    onChange={(e) => field.onChange(e.target.value)}
+                    className={cn({
+                      "border-destructive": Boolean(validationErrors?.message),
+                    })}
+                  />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            );
+          }}
+        />
+      </div>
+      <div className="flex flex-col gap-2">
+        <FormField
+          control={form.control}
+          name="models"
+          render={({ field, formState }) => {
+            const validationErrors = get(formState.errors, ["models"]);
+
+            return (
+              <FormItem>
+                <Label htmlFor="models">Models list</Label>
+                <Description>
+                  Comma separated list of models available by specified URL
+                </Description>
+                <FormControl>
+                  <Input
+                    id="models"
+                    placeholder="Comma separated models list"
+                    value={field.value}
+                    onChange={(e) => field.onChange(e.target.value)}
+                    className={cn({
+                      "border-destructive": Boolean(validationErrors?.message),
+                    })}
+                  />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            );
+          }}
+        />
+      </div>
+    </>
+  );
+};
+
+export default LocalAIProviderDetails;
diff --git a/apps/opik-frontend/src/components/shared/AddEditAIProviderDialog/schema.ts b/apps/opik-frontend/src/components/shared/AddEditAIProviderDialog/schema.ts
new file mode 100644
index 0000000000..9b42d0db24
--- /dev/null
+++ b/apps/opik-frontend/src/components/shared/AddEditAIProviderDialog/schema.ts
@@ -0,0 +1,43 @@
+import { z } from "zod";
+import { PROVIDER_LOCATION_TYPE, PROVIDER_TYPE } from "@/types/providers";
+import uniq from "lodash/uniq";
+
+const ProviderSchema = z
+  .union([z.nativeEnum(PROVIDER_TYPE), z.string().length(0)], {
+    required_error: "Provider is required",
+  })
+  .refine((model) => model.length >= 1, { message: "Provider is required" });
+
+export const CloudAIProviderDetailsFormSchema = z.object({
+  provider: ProviderSchema,
+  locationType: z.literal(PROVIDER_LOCATION_TYPE.cloud),
+  apiKey: z
+    .string({
+      required_error: "API key is required",
+    })
+    .min(1, { message: "API key is required" }),
+});
+
+export const LocalAIProviderDetailsFormSchema = z.object({
+  provider: ProviderSchema,
+  locationType: z.literal(PROVIDER_LOCATION_TYPE.local),
+  url: z.string().url(),
+  models: z
+    .string()
+    .min(1, { message: "Models is required" })
+    .refine(
+      (models) => {
+        const modelsArray = models.split(",");
+
+        return modelsArray.length === uniq(modelsArray).length;
+      },
+      { message: "All model names should be unique" },
+    ),
+});
+
+export const AIProviderFormSchema = z.discriminatedUnion("locationType", [
+  CloudAIProviderDetailsFormSchema,
+  LocalAIProviderDetailsFormSchema,
+]);
+
+export type AIProviderFormType = z.infer<typeof AIProviderFormSchema>;
diff --git a/apps/opik-frontend/src/components/ui/select.tsx b/apps/opik-frontend/src/components/ui/select.tsx
index 6d8d40637a..4844b3c89c 100644
--- a/apps/opik-frontend/src/components/ui/select.tsx
+++ b/apps/opik-frontend/src/components/ui/select.tsx
@@ -112,7 +112,7 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
 interface SelectItemProps
   extends React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> {
   withoutCheck?: boolean;
-  description?: string;
+  description?: React.ReactNode;
 }
 
 const SelectItem = React.forwardRef<
@@ -143,7 +143,7 @@ const SelectItem = React.forwardRef<
         <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
       </div>
       {description && (
-        <div className="comet-body-s mt-0.5 text-light-slate">
+        <div className="comet-body-s mt-0.5 whitespace-pre-wrap text-light-slate">
           {description}
         </div>
       )}
diff --git a/apps/opik-frontend/src/constants/llm.ts b/apps/opik-frontend/src/constants/llm.ts
index c218dc8990..2cb67307f9 100644
--- a/apps/opik-frontend/src/constants/llm.ts
+++ b/apps/opik-frontend/src/constants/llm.ts
@@ -1,4 +1,3 @@
-import { PROVIDER_MODEL_TYPE, PROVIDER_TYPE } from "@/types/providers";
 import {
   LLM_JUDGE,
   LLM_MESSAGE_ROLE,
@@ -6,127 +5,6 @@ import {
   LLM_SCHEMA_TYPE,
 } from "@/types/llm";
 
-type PROVIDER_MODELS_TYPE = {
-  [key in PROVIDER_TYPE]: {
-    value: PROVIDER_MODEL_TYPE;
-    label: string;
-    structuredOutput?: boolean;
-  }[];
-};
-
-export const PROVIDER_MODELS: PROVIDER_MODELS_TYPE = {
-  [PROVIDER_TYPE.OPEN_AI]: [
-    // GPT-4.0 Models
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_4O,
-      label: "GPT 4o",
-      structuredOutput: true,
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_4O_MINI,
-      label: "GPT 4o Mini",
-      structuredOutput: true,
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_4O_MINI_2024_07_18,
-      label: "GPT 4o Mini 2024-07-18",
-      structuredOutput: true,
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_4O_2024_08_06,
-      label: "GPT 4o 2024-08-06",
-      structuredOutput: true,
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_4O_2024_05_13,
-      label: "GPT 4o 2024-05-13",
-    },
-
-    // GPT-4 Models
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_4_TURBO,
-      label: "GPT 4 Turbo",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_4,
-      label: "GPT 4",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_4_TURBO_PREVIEW,
-      label: "GPT 4 Turbo Preview",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_4_TURBO_2024_04_09,
-      label: "GPT 4 Turbo 2024-04-09",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_4_1106_PREVIEW,
-      label: "GPT 4 1106 Preview",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_4_0613,
-      label: "GPT 4 0613",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_4_0125_PREVIEW,
-      label: "GPT 4 0125 Preview",
-    },
-
-    // GPT-3.5 Models
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_3_5_TURBO,
-      label: "GPT 3.5 Turbo",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_3_5_TURBO_1106,
-      label: "GPT 3.5 Turbo 1106",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.GPT_3_5_TURBO_0125,
-      label: "GPT 3.5 Turbo 0125",
-    },
-  ],
-
-  [PROVIDER_TYPE.ANTHROPIC]: [
-    {
-      value: PROVIDER_MODEL_TYPE.CLAUDE_3_5_SONNET_20241022,
-      label: "Claude 3.5 Sonnet 2024-10-22",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.CLAUDE_3_5_HAIKU_20241022,
-      label: "Claude 3.5 Haiku 2024-10-22",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.CLAUDE_3_5_SONNET_20240620,
-      label: "Claude 3.5 Sonnet 2024-06-20",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.CLAUDE_3_OPUS_20240229,
-      label: "Claude 3 Opus 2024-02-29",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.CLAUDE_3_SONNET_20240229,
-      label: "Claude 3 Sonnet 2024-02-29",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.CLAUDE_3_HAIKU_20240307,
-      label: "Claude 3 Haiku 2024-03-07",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.CLAUDE_3_5_SONNET_LATEST,
-      label: "Claude 3.5 Sonnet Latest",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.CLAUDE_3_5_HAIKU_LATEST,
-      label: "Claude 3.5 Haiku Latest",
-    },
-    {
-      value: PROVIDER_MODEL_TYPE.CLAUDE_3_OPUS_LATEST,
-      label: "Claude 3 Opus Latest",
-    },
-  ],
-};
-
 export const DEFAULT_OPEN_AI_CONFIGS = {
   TEMPERATURE: 0,
   MAX_COMPLETION_TOKENS: 1024,
diff --git a/apps/opik-frontend/src/constants/providers.ts b/apps/opik-frontend/src/constants/providers.ts
index ec0ef44ca7..2c9fc78585 100644
--- a/apps/opik-frontend/src/constants/providers.ts
+++ b/apps/opik-frontend/src/constants/providers.ts
@@ -1,23 +1,42 @@
 import OpenAIIcon from "@/icons/integrations/openai.svg?react";
 import AnthropicIcon from "@/icons/integrations/anthropic.svg?react";
+import OllamaIcon from "@/icons/integrations/ollama.svg?react";
 
-import { PROVIDER_MODEL_TYPE, PROVIDER_TYPE } from "@/types/providers";
+import {
+  PROVIDER_LOCATION_TYPE,
+  PROVIDER_MODEL_TYPE,
+  PROVIDER_TYPE,
+} from "@/types/providers";
 
 type IconType = typeof OpenAIIcon;
 
-export type PROVIDER_OPTION_TYPE = {
+type PROVIDER_OPTION_TYPE = {
   label: string;
   value: PROVIDER_TYPE;
   icon: IconType;
   apiKeyName: string;
+  defaultModel: PROVIDER_MODEL_TYPE | "";
+  description?: string;
+};
+
+export type CLOUD_PROVIDER_OPTION_TYPE = PROVIDER_OPTION_TYPE & {
+  locationType: PROVIDER_LOCATION_TYPE.cloud;
   apiKeyURL: string;
-  defaultModel: PROVIDER_MODEL_TYPE;
+};
+
+export type LOCAL_PROVIDER_OPTION_TYPE = PROVIDER_OPTION_TYPE & {
+  locationType: PROVIDER_LOCATION_TYPE.local;
+  lsKey: string;
 };
 
 type PROVIDERS_TYPE = {
-  [key in PROVIDER_TYPE]: PROVIDER_OPTION_TYPE;
+  [key in PROVIDER_TYPE]:
+    | CLOUD_PROVIDER_OPTION_TYPE
+    | LOCAL_PROVIDER_OPTION_TYPE;
 };
 
+export const OLLAMA_LS_KEY = "provider_ollama";
+
 export const PROVIDERS: PROVIDERS_TYPE = {
   [PROVIDER_TYPE.OPEN_AI]: {
     label: "OpenAI",
@@ -26,6 +45,7 @@ export const PROVIDERS: PROVIDERS_TYPE = {
     apiKeyName: "OPENAI_API_KEY",
     apiKeyURL: "https://platform.openai.com/account/api-keys",
     defaultModel: PROVIDER_MODEL_TYPE.GPT_4O,
+    locationType: PROVIDER_LOCATION_TYPE.cloud,
   },
   [PROVIDER_TYPE.ANTHROPIC]: {
     label: "Anthropic",
@@ -34,6 +54,18 @@ export const PROVIDERS: PROVIDERS_TYPE = {
     apiKeyName: "ANTHROPIC_API_KEY",
     apiKeyURL: "https://console.anthropic.com/settings/keys",
     defaultModel: PROVIDER_MODEL_TYPE.CLAUDE_3_5_SONNET_LATEST,
+    locationType: PROVIDER_LOCATION_TYPE.cloud,
+  },
+  [PROVIDER_TYPE.OLLAMA]: {
+    label: "Ollama (Experimental)",
+    value: PROVIDER_TYPE.OLLAMA,
+    icon: OllamaIcon,
+    apiKeyName: "OLLAMA_LOCAL_EXPERIMENTAL",
+    description:
+      "All configuration for this provider is saved locally, and will not \nbe accessible in different browsers",
+    locationType: PROVIDER_LOCATION_TYPE.local,
+    lsKey: OLLAMA_LS_KEY,
+    defaultModel: "",
   },
 };
 
diff --git a/apps/opik-frontend/src/hooks/useLLMProviderModelsData.ts b/apps/opik-frontend/src/hooks/useLLMProviderModelsData.ts
new file mode 100644
index 0000000000..e227269784
--- /dev/null
+++ b/apps/opik-frontend/src/hooks/useLLMProviderModelsData.ts
@@ -0,0 +1,216 @@
+import { useCallback } from "react";
+import first from "lodash/first";
+import {
+  PROVIDER_LOCATION_TYPE,
+  PROVIDER_MODEL_TYPE,
+  PROVIDER_MODELS_TYPE,
+  PROVIDER_TYPE,
+} from "@/types/providers";
+import useLocalAIProviderData from "@/hooks/useLocalAIProviderData";
+import { getDefaultProviderKey } from "@/lib/provider";
+import { PROVIDERS } from "@/constants/providers";
+
+export type ProviderResolver = (
+  modelName?: PROVIDER_MODEL_TYPE | "",
+) => PROVIDER_TYPE | "";
+
+export type ModelResolver = (
+  lastPickedModel: PROVIDER_MODEL_TYPE | "",
+  setupProviders: PROVIDER_TYPE[],
+  preferredProvider?: PROVIDER_TYPE | "",
+) => PROVIDER_MODEL_TYPE | "";
+
+export const PROVIDER_MODELS: PROVIDER_MODELS_TYPE = {
+  [PROVIDER_TYPE.OPEN_AI]: [
+    // GPT-4.0 Models
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_4O,
+      label: "GPT 4o",
+      structuredOutput: true,
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_4O_MINI,
+      label: "GPT 4o Mini",
+      structuredOutput: true,
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_4O_MINI_2024_07_18,
+      label: "GPT 4o Mini 2024-07-18",
+      structuredOutput: true,
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_4O_2024_08_06,
+      label: "GPT 4o 2024-08-06",
+      structuredOutput: true,
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_4O_2024_05_13,
+      label: "GPT 4o 2024-05-13",
+    },
+
+    // GPT-4 Models
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_4_TURBO,
+      label: "GPT 4 Turbo",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_4,
+      label: "GPT 4",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_4_TURBO_PREVIEW,
+      label: "GPT 4 Turbo Preview",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_4_TURBO_2024_04_09,
+      label: "GPT 4 Turbo 2024-04-09",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_4_1106_PREVIEW,
+      label: "GPT 4 1106 Preview",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_4_0613,
+      label: "GPT 4 0613",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_4_0125_PREVIEW,
+      label: "GPT 4 0125 Preview",
+    },
+
+    // GPT-3.5 Models
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_3_5_TURBO,
+      label: "GPT 3.5 Turbo",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_3_5_TURBO_1106,
+      label: "GPT 3.5 Turbo 1106",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.GPT_3_5_TURBO_0125,
+      label: "GPT 3.5 Turbo 0125",
+    },
+  ],
+
+  [PROVIDER_TYPE.ANTHROPIC]: [
+    {
+      value: PROVIDER_MODEL_TYPE.CLAUDE_3_5_SONNET_20241022,
+      label: "Claude 3.5 Sonnet 2024-10-22",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.CLAUDE_3_5_HAIKU_20241022,
+      label: "Claude 3.5 Haiku 2024-10-22",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.CLAUDE_3_5_SONNET_20240620,
+      label: "Claude 3.5 Sonnet 2024-06-20",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.CLAUDE_3_OPUS_20240229,
+      label: "Claude 3 Opus 2024-02-29",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.CLAUDE_3_SONNET_20240229,
+      label: "Claude 3 Sonnet 2024-02-29",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.CLAUDE_3_HAIKU_20240307,
+      label: "Claude 3 Haiku 2024-03-07",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.CLAUDE_3_5_SONNET_LATEST,
+      label: "Claude 3.5 Sonnet Latest",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.CLAUDE_3_5_HAIKU_LATEST,
+      label: "Claude 3.5 Haiku Latest",
+    },
+    {
+      value: PROVIDER_MODEL_TYPE.CLAUDE_3_OPUS_LATEST,
+      label: "Claude 3 Opus Latest",
+    },
+  ],
+  [PROVIDER_TYPE.OLLAMA]: [
+    // the list will be full filled base on data in localstorage
+  ],
+};
+
+const useLLMProviderModelsData = () => {
+  const { localModels, getLocalAIProviderData } = useLocalAIProviderData();
+
+  const getProviderModels = useCallback(() => {
+    return { ...PROVIDER_MODELS, ...localModels };
+  }, [localModels]);
+
+  const calculateModelProvider = useCallback(
+    (modelName?: PROVIDER_MODEL_TYPE | ""): PROVIDER_TYPE | "" => {
+      if (!modelName) {
+        return "";
+      }
+
+      const provider = Object.entries(getProviderModels()).find(
+        ([providerName, providerModels]) => {
+          if (providerModels.find((pm) => modelName === pm.value)) {
+            return providerName;
+          }
+
+          return false;
+        },
+      );
+
+      if (!provider) {
+        return "";
+      }
+
+      const [providerName] = provider;
+
+      return providerName as PROVIDER_TYPE;
+    },
+    [getProviderModels],
+  );
+
+  const calculateDefaultModel = useCallback(
+    (
+      lastPickedModel: PROVIDER_MODEL_TYPE | "",
+      setupProviders: PROVIDER_TYPE[],
+      preferredProvider?: PROVIDER_TYPE | "",
+    ) => {
+      const lastPickedModelProvider = calculateModelProvider(lastPickedModel);
+
+      const isLastPickedModelValid =
+        !!lastPickedModelProvider &&
+        setupProviders.includes(lastPickedModelProvider);
+
+      if (isLastPickedModelValid) {
+        return lastPickedModel;
+      }
+
+      const provider =
+        preferredProvider ?? getDefaultProviderKey(setupProviders);
+
+      if (provider) {
+        if (PROVIDERS[provider].locationType === PROVIDER_LOCATION_TYPE.local) {
+          return (
+            (first(
+              (getLocalAIProviderData(provider)?.models || "").split(","),
+            )?.trim() as PROVIDER_MODEL_TYPE) ?? ""
+          );
+        } else {
+          return PROVIDERS[provider].defaultModel;
+        }
+      }
+
+      return "";
+    },
+    [calculateModelProvider, getLocalAIProviderData],
+  );
+
+  return {
+    getProviderModels,
+    calculateModelProvider,
+    calculateDefaultModel,
+  };
+};
+
+export default useLLMProviderModelsData;
diff --git a/apps/opik-frontend/src/hooks/useLocalAIProviderData.ts b/apps/opik-frontend/src/hooks/useLocalAIProviderData.ts
new file mode 100644
index 0000000000..9892d77910
--- /dev/null
+++ b/apps/opik-frontend/src/hooks/useLocalAIProviderData.ts
@@ -0,0 +1,147 @@
+import { useCallback } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import isEmpty from "lodash/isEmpty";
+import isNumber from "lodash/isNumber";
+import isArray from "lodash/isArray";
+
+import { safelyParseJSON } from "@/lib/utils";
+import {
+  LOCAL_PROVIDER_OPTION_TYPE,
+  PROVIDERS,
+  PROVIDERS_OPTIONS,
+} from "@/constants/providers";
+import {
+  LocalAIProviderData,
+  PROVIDER_LOCATION_TYPE,
+  PROVIDER_MODEL_TYPE,
+  PROVIDER_MODELS_TYPE,
+  PROVIDER_TYPE,
+  ProviderKey,
+} from "@/types/providers";
+import { PROVIDERS_KEYS_KEY } from "@/api/api";
+
+const useLocalAIProviderData = () => {
+  const queryClient = useQueryClient();
+
+  // workaround to recalculate list of local models every type we have any change in PROVIDERS_KEYS_KEY
+  const { data: localModels } = useQuery({
+    queryKey: [PROVIDERS_KEYS_KEY],
+    queryFn: () => {
+      const retVal: Partial<PROVIDER_MODELS_TYPE> = {};
+
+      PROVIDERS_OPTIONS.forEach((option) => {
+        if (option.locationType === PROVIDER_LOCATION_TYPE.local) {
+          const providerData: LocalAIProviderData | undefined =
+            getLocalAIProviderData(option.value);
+
+          if (providerData) {
+            retVal[option.value] = providerData.models.split(",").map((m) => ({
+              value: m.trim() as PROVIDER_MODEL_TYPE,
+              label: m.trim(),
+            }));
+          }
+        }
+      });
+
+      return retVal;
+    },
+  });
+
+  const setLocalAIProviderData = useCallback(
+    (provider: PROVIDER_TYPE, data: LocalAIProviderData) => {
+      const config = PROVIDERS[provider] as LOCAL_PROVIDER_OPTION_TYPE;
+      localStorage.setItem(config.lsKey, JSON.stringify(data));
+      queryClient.invalidateQueries({ queryKey: [PROVIDERS_KEYS_KEY] });
+    },
+    [queryClient],
+  );
+
+  const deleteLocalAIProviderData = useCallback(
+    (provider: PROVIDER_TYPE) => {
+      const config = PROVIDERS[provider] as LOCAL_PROVIDER_OPTION_TYPE;
+      localStorage.removeItem(config.lsKey);
+      queryClient.invalidateQueries({ queryKey: [PROVIDERS_KEYS_KEY] });
+    },
+    [queryClient],
+  );
+
+  const getLocalAIProviderData = useCallback((provider: PROVIDER_TYPE) => {
+    let retVal: LocalAIProviderData | undefined = undefined;
+    const config = PROVIDERS[provider] as LOCAL_PROVIDER_OPTION_TYPE;
+    const lsData = localStorage.getItem(config.lsKey);
+
+    if (lsData) {
+      const providerData: LocalAIProviderData | undefined = safelyParseJSON(
+        lsData,
+        true,
+      );
+
+      retVal = isEmpty(providerData) ? undefined : providerData;
+    }
+
+    return retVal;
+  }, []);
+
+  const extendWithLocalData = useCallback(
+    (data: { total: number; content: ProviderKey[] }) => {
+      const localData: ProviderKey[] = [];
+
+      PROVIDERS_OPTIONS.forEach((option) => {
+        if (option.locationType === PROVIDER_LOCATION_TYPE.local) {
+          const providerData: LocalAIProviderData | undefined =
+            getLocalAIProviderData(option.value);
+
+          if (providerData) {
+            localData.push({
+              id: option.lsKey,
+              keyName: option.apiKeyName,
+              created_at: providerData.created_at,
+              provider: option.value,
+            });
+          }
+        }
+      });
+
+      return localData.length > 0 &&
+        isNumber(data.total) &&
+        isArray(data.content)
+        ? {
+            content: [...data.content, ...localData],
+            total: data.total + localData.length,
+          }
+        : data;
+    },
+    [getLocalAIProviderData],
+  );
+
+  const getLocalIAProviderURL = useCallback(
+    (provider: PROVIDER_TYPE | "") => {
+      let retVal: string | undefined = undefined;
+
+      if (
+        provider &&
+        PROVIDERS[provider].locationType === PROVIDER_LOCATION_TYPE.local
+      ) {
+        const providerData = getLocalAIProviderData(provider);
+
+        if (providerData) {
+          retVal = providerData.url;
+        }
+      }
+
+      return retVal;
+    },
+    [getLocalAIProviderData],
+  );
+
+  return {
+    localModels,
+    extendWithLocalData,
+    getLocalAIProviderData,
+    setLocalAIProviderData,
+    deleteLocalAIProviderData,
+    getLocalIAProviderURL,
+  };
+};
+
+export default useLocalAIProviderData;
diff --git a/apps/opik-frontend/src/icons/integrations/ollama.svg b/apps/opik-frontend/src/icons/integrations/ollama.svg
new file mode 100644
index 0000000000..4ad94d2a46
--- /dev/null
+++ b/apps/opik-frontend/src/icons/integrations/ollama.svg
@@ -0,0 +1,17 @@
+<svg width="12" height="16" viewBox="0 0 646 854" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path
+    d="M140.629 0.239929C132.66 1.52725 123.097 5.69568 116.354 10.845C95.941 26.3541 80.1253 59.2728 73.4435 100.283C70.9302 115.792 69.2138 137.309 69.2138 153.738C69.2138 173.109 71.4819 197.874 74.7309 214.977C75.4665 218.778 75.8343 222.15 75.5278 222.395C75.2826 222.64 72.2788 225.092 68.9072 227.789C57.3827 236.984 44.2029 251.145 35.1304 264.08C17.7209 288.784 6.44151 316.86 1.72133 347.265C-0.117698 359.28 -0.608106 383.555 0.863118 395.57C4.11207 423.278 12.449 446.695 26.7321 468.151L31.391 475.078L30.0424 477.346C20.4794 493.407 12.3264 516.64 8.52575 538.953C5.522 556.608 5.15419 561.328 5.15419 584.99C5.15419 608.837 5.4607 613.557 8.28054 630.047C11.6521 649.786 18.5178 670.689 26.1804 684.605C28.6938 689.141 34.8239 698.581 35.5595 699.072C35.8047 699.194 35.0691 701.462 33.9044 704.098C25.077 723.408 17.537 749.093 14.4106 770.733C12.2038 785.567 11.8973 790.349 11.8973 805.981C11.8973 825.903 13.0007 835.589 17.1692 851.466L17.7822 853.795H44.019H70.3172L68.6007 850.546C57.9957 830.93 57.0149 794.517 66.1487 758.166C70.3172 741.369 75.0374 729.048 83.8647 712.067L89.1366 701.769V695.455C89.1366 689.57 89.014 688.896 87.1137 685.034C85.6424 682.091 83.6808 679.578 80.1866 676.145C74.2404 670.383 69.9494 664.314 66.5165 656.835C51.4365 624.1 48.494 575.489 59.0991 534.049C63.5128 516.762 70.8076 501.376 78.4702 492.978C83.6808 487.215 86.378 480.779 86.378 474.097C86.378 467.17 83.926 461.469 78.4089 455.523C62.5932 438.604 52.8464 418.006 49.3522 394.038C44.3868 359.893 53.3981 322.683 73.8726 293.198C93.9181 264.263 122.055 245.689 153.503 240.724C160.552 239.559 173.732 239.743 181.088 241.092C189.119 242.502 194.145 242.072 199.295 239.62C205.67 236.617 208.858 232.877 212.597 224.295C215.907 216.633 218.482 212.464 225.409 203.821C233.746 193.461 241.776 186.411 254.649 177.89C269.362 168.266 286.097 161.278 302.771 157.906C308.839 156.68 311.659 156.496 323 156.496C334.341 156.496 337.161 156.68 343.229 157.906C367.688 162.872 391.964 175.5 411.335 193.399C415.503 197.261 425.495 209.644 428.683 214.794C429.909 216.816 432.055 221.108 433.403 224.295C437.142 232.877 440.33 236.617 446.705 239.62C451.671 242.011 456.881 242.502 464.605 241.214C476.804 239.13 486.183 239.314 498.137 241.766C538.841 249.98 574.273 283.512 589.966 328.446C603.636 367.862 599.774 409.118 579.422 440.626C575.989 445.96 572.556 450.251 567.591 455.523C556.863 466.986 556.863 481.208 567.53 492.978C585.062 512.165 596.035 559.367 592.724 600.99C590.518 628.453 583.468 653.035 573.782 666.95C572.066 669.402 568.511 673.57 565.813 676.145C562.319 679.578 560.358 682.091 558.886 685.034C556.986 688.896 556.863 689.57 556.863 695.455V701.769L562.135 712.067C570.963 729.048 575.683 741.369 579.851 758.166C588.863 794.027 588.066 829.704 577.767 849.995C576.909 851.711 576.173 853.305 576.173 853.489C576.173 853.673 587.882 853.795 602.226 853.795H628.218L628.892 851.159C629.26 849.75 629.873 847.604 630.179 846.378C630.854 843.681 632.202 835.712 633.306 828.049C634.348 820.325 634.348 791.881 633.306 783.299C629.383 752.158 622.823 727.454 612.096 704.098C610.931 701.462 610.195 699.194 610.44 699.072C610.747 698.888 612.463 696.436 614.302 693.677C627.666 673.448 635.88 648.008 640.049 614.415C641.152 605.158 641.152 565.374 640.049 556.485C637.106 533.559 633.551 517.988 627.666 502.234C625.214 495.675 618.716 481.821 615.958 477.346L614.609 475.078L619.268 468.151C633.551 446.695 641.888 423.278 645.137 395.57C646.608 383.555 646.118 359.28 644.279 347.265C639.497 316.798 628.279 288.845 610.87 264.08C601.797 251.145 588.617 236.984 577.093 227.789C573.721 225.092 570.717 222.64 570.472 222.395C570.166 222.15 570.534 218.778 571.269 214.977C578.687 176.296 578.441 128.053 570.656 90.3524C563.913 57.4951 551.653 31.3808 535.837 16.3008C523.209 4.28578 510.336 -0.863507 494.888 0.11731C459.456 2.20154 430.89 42.9667 419.61 107.21C417.771 117.57 416.178 129.708 416.178 133.018C416.178 134.305 415.932 135.347 415.626 135.347C415.319 135.347 412.929 134.121 410.354 132.589C383.014 116.405 352.608 107.762 323 107.762C293.392 107.762 262.986 116.405 235.646 132.589C233.071 134.121 230.681 135.347 230.374 135.347C230.068 135.347 229.822 134.305 229.822 133.018C229.822 129.585 228.167 117.08 226.39 107.21C216.152 49.5259 192.674 11.3354 161.472 1.71112C157.181 0.423799 144.982 -0.434382 140.629 0.239929ZM151.051 50.139C159.878 57.1273 169.686 77.1114 175.326 99.4863C176.368 103.532 177.471 108.191 177.778 109.907C178.023 111.563 178.697 115.302 179.249 118.183C181.64 131.179 182.743 145.217 182.866 162.32L182.927 179.178L178.697 185.43L174.468 191.744H164.598C153.074 191.744 141.61 193.216 130.637 196.158C126.714 197.139 122.913 198.12 122.178 198.304C121.013 198.549 120.829 198.181 120.155 193.154C116.538 165.875 116.722 135.654 120.707 110.52C125.12 82.5059 135.419 57.1273 145.472 49.6486C147.863 47.8708 148.292 47.9321 151.051 50.139ZM500.589 49.7098C506.658 54.1848 513.34 66.0772 518.305 81.2798C528.297 111.685 531.117 153.431 525.845 193.154C525.171 198.181 524.987 198.549 523.822 198.304C523.087 198.12 519.286 197.139 515.363 196.158C504.39 193.216 492.926 191.744 481.402 191.744H471.532L467.303 185.43L463.073 179.178L463.134 162.32C463.257 138.535 465.464 119.961 470.735 99.3024C476.314 77.1114 486.183 57.1273 494.949 50.139C497.708 47.9321 498.137 47.8708 500.589 49.7098Z"
+    fill="currentColor"/>
+  <path
+    d="M313.498 358.237C300.195 359.525 296.579 360.015 290.203 361.303C279.843 363.448 265.989 368.23 256.365 372.95C222.895 389.317 199.846 416.596 192.796 448.166C191.386 454.419 191.202 456.503 191.202 467.047C191.202 477.468 191.386 479.736 192.735 485.682C202.114 526.938 240.12 557.405 289.284 562.983C299.95 564.148 346.049 564.148 356.715 562.983C396.193 558.508 430.154 537.114 445.418 507.076C449.463 499.046 451.425 493.835 453.264 485.682C454.613 479.736 454.797 477.468 454.797 467.047C454.797 456.503 454.613 454.419 453.203 448.166C442.965 402.313 398.461 366.207 343.903 359.341C336.792 358.483 318.157 357.747 313.498 358.237ZM336.424 391.585C354.631 393.547 372.96 400.045 387.672 409.853C395.58 415.125 406.737 426.159 411.518 433.393C417.403 442.342 420.774 451.476 422.307 462.572C422.981 467.66 422.614 471.522 420.774 479.736C417.893 491.996 408.943 504.808 396.867 513.758C391.227 517.865 379.519 523.812 372.347 526.141C358.738 530.493 349.849 531.29 318.095 531.045C297.376 530.861 293.697 530.677 287.751 529.574C267.461 525.773 251.4 517.681 239.753 505.36C230.312 495.429 226.021 486.357 223.692 471.706C222.65 464.901 224.611 453.622 228.596 444.12C233.439 432.534 245.944 418.129 258.327 409.853C272.671 400.29 291.552 393.486 308.9 391.647C315.582 390.911 329.742 390.911 336.424 391.585Z"
+    fill="currentColor"/>
+  <path
+    d="M299.584 436.336C294.925 438.849 291.676 445.224 292.657 449.944C293.76 455.032 298.235 460.182 305.223 464.412C308.963 466.68 309.208 466.986 309.392 469.254C309.514 470.603 309.024 474.465 308.35 477.898C307.614 481.269 307.062 484.825 307.062 485.806C307.124 488.442 309.576 492.733 312.15 494.817C314.419 496.656 314.848 496.717 321.223 496.901C327.047 497.085 328.273 496.962 330.602 495.859C336.61 492.916 338.142 487.522 335.935 477.162C334.096 468.519 334.464 467.17 339.062 464.534C343.904 461.714 349.054 456.749 350.586 453.377C353.529 446.941 350.831 439.646 344.333 436.274C342.74 435.477 340.778 435.11 337.897 435.11C333.422 435.11 330.541 436.152 325.269 439.523L322.265 441.424L320.365 440.259C312.58 435.661 311.17 435.11 306.449 435.171C303.078 435.171 301.239 435.477 299.584 436.336Z"
+    fill="currentColor"/>
+  <path
+    d="M150.744 365.165C139.894 368.598 131.802 376.567 127.634 387.908C125.611 393.303 124.63 401.824 125.488 406.421C127.511 417.394 136.522 427.386 146.76 430.145C159.633 433.516 169.257 431.309 177.778 422.85C182.743 418.007 185.441 413.777 188.138 406.911C190.099 402.069 190.222 401.211 190.222 394.345L190.283 386.989L187.709 381.717C183.601 373.38 176.184 367.188 167.602 364.92C162.759 363.694 154.974 363.756 150.744 365.165Z"
+    fill="currentColor"/>
+  <path
+    d="M478.153 364.982C469.755 367.25 462.276 373.502 458.291 381.717L455.717 386.989L455.778 394.345C455.778 401.211 455.901 402.069 457.862 406.911C460.56 413.777 463.257 418.007 468.222 422.85C476.743 431.309 486.367 433.516 499.241 430.145C506.658 428.183 514.075 421.93 517.631 414.635C520.696 408.444 521.431 403.969 520.451 396.919C518.183 380.797 508.742 369.089 494.704 364.982C490.597 363.756 482.628 363.756 478.153 364.982Z"
+    fill="currentColor"/>
+</svg>
diff --git a/apps/opik-frontend/src/lib/llm.ts b/apps/opik-frontend/src/lib/llm.ts
index 47522d9298..a6e6d3d968 100644
--- a/apps/opik-frontend/src/lib/llm.ts
+++ b/apps/opik-frontend/src/lib/llm.ts
@@ -1,7 +1,5 @@
 import { LLM_MESSAGE_ROLE, LLMMessage } from "@/types/llm";
 import { generateRandomString } from "@/lib/utils";
-import { PROVIDER_MODEL_TYPE, PROVIDER_TYPE } from "@/types/providers";
-import { PROVIDER_MODELS } from "@/constants/llm";
 
 export const generateDefaultLLMPromptMessage = (
   message: Partial<LLMMessage> = {},
@@ -13,27 +11,6 @@ export const generateDefaultLLMPromptMessage = (
     id: generateRandomString(),
   };
 };
-export const getModelProvider = (
-  modelName: PROVIDER_MODEL_TYPE,
-): PROVIDER_TYPE | "" => {
-  const provider = Object.entries(PROVIDER_MODELS).find(
-    ([providerName, providerModels]) => {
-      if (providerModels.find((pm) => modelName === pm.value)) {
-        return providerName;
-      }
-
-      return false;
-    },
-  );
-
-  if (!provider) {
-    return "";
-  }
-
-  const [providerName] = provider;
-
-  return providerName as PROVIDER_TYPE;
-};
 
 export const getNextMessageType = (
   previousMessage: LLMMessage,
diff --git a/apps/opik-frontend/src/lib/playground.ts b/apps/opik-frontend/src/lib/playground.ts
index e4454dcfab..a80863525e 100644
--- a/apps/opik-frontend/src/lib/playground.ts
+++ b/apps/opik-frontend/src/lib/playground.ts
@@ -11,12 +11,14 @@ import {
   PROVIDER_MODEL_TYPE,
   PROVIDER_TYPE,
 } from "@/types/providers";
-import { getDefaultProviderKey } from "@/lib/provider";
-import { PROVIDERS } from "@/constants/providers";
-import { generateDefaultLLMPromptMessage, getModelProvider } from "@/lib/llm";
+import { generateDefaultLLMPromptMessage } from "@/lib/llm";
+import {
+  ModelResolver,
+  ProviderResolver,
+} from "@/hooks/useLLMProviderModelsData";
 
 export const getDefaultConfigByProvider = (
-  provider: PROVIDER_TYPE,
+  provider?: PROVIDER_TYPE | "",
 ): LLMPromptConfigsType => {
   if (provider === PROVIDER_TYPE.OPEN_AI) {
     return {
@@ -39,59 +41,30 @@ export const getDefaultConfigByProvider = (
   return {};
 };
 
-const getDefaultModel = (
-  lastPickedModel: PROVIDER_MODEL_TYPE | "",
-  setupProviders: PROVIDER_TYPE[],
-) => {
-  const lastPickedModelProvider = lastPickedModel
-    ? getModelProvider(lastPickedModel)
-    : "";
-
-  const isLastPickedModelValid =
-    !!lastPickedModelProvider &&
-    setupProviders.includes(lastPickedModelProvider);
-
-  if (isLastPickedModelValid) {
-    return lastPickedModel;
-  }
-
-  const defaultProviderKey = getDefaultProviderKey(setupProviders);
-
-  if (defaultProviderKey) {
-    return PROVIDERS[defaultProviderKey]?.defaultModel || "";
-  }
-
-  return "";
-};
-
-const getDefaultModelConfigs = (model: PROVIDER_MODEL_TYPE | "") => {
-  if (!model) {
-    return {};
-  }
-
-  const modelProvider = getModelProvider(model);
-
-  return modelProvider ? getDefaultConfigByProvider(modelProvider) : {};
-};
-
 interface GenerateDefaultPromptParams {
   initPrompt?: Partial<PlaygroundPromptType>;
   setupProviders: PROVIDER_TYPE[];
   lastPickedModel?: PROVIDER_MODEL_TYPE | "";
+  providerResolver: ProviderResolver;
+  modelResolver: ModelResolver;
 }
 
 export const generateDefaultPrompt = ({
   initPrompt = {},
   setupProviders = [],
   lastPickedModel,
+  providerResolver,
+  modelResolver,
 }: GenerateDefaultPromptParams): PlaygroundPromptType => {
-  const modelByDefault = getDefaultModel(lastPickedModel || "", setupProviders);
+  const modelByDefault = modelResolver(lastPickedModel || "", setupProviders);
+  const provider = providerResolver(modelByDefault);
 
   return {
     name: "Prompt",
     messages: [generateDefaultLLMPromptMessage()],
     model: modelByDefault,
-    configs: getDefaultModelConfigs(modelByDefault),
+    provider,
+    configs: getDefaultConfigByProvider(provider),
     ...initPrompt,
     id: generateRandomString(),
   };
diff --git a/apps/opik-frontend/src/lib/utils.ts b/apps/opik-frontend/src/lib/utils.ts
index 10c9adbb51..89c59b04b1 100644
--- a/apps/opik-frontend/src/lib/utils.ts
+++ b/apps/opik-frontend/src/lib/utils.ts
@@ -34,11 +34,11 @@ export const isValidJsonObject = (string: string) => {
   return json && isObject(json);
 };
 
-export const safelyParseJSON = (string: string) => {
+export const safelyParseJSON = (string: string, silent = false) => {
   try {
     return JSON.parse(string);
   } catch (e) {
-    console.error(e);
+    if (!silent) console.error(e);
     return {};
   }
 };
diff --git a/apps/opik-frontend/src/types/playground.ts b/apps/opik-frontend/src/types/playground.ts
index d9548e17c2..509dac41a7 100644
--- a/apps/opik-frontend/src/types/playground.ts
+++ b/apps/opik-frontend/src/types/playground.ts
@@ -2,7 +2,11 @@ import { HttpStatusCode } from "axios";
 
 import { JsonNode, UsageType } from "@/types/shared";
 import { LLMMessage, ProviderMessageType } from "@/types/llm";
-import { LLMPromptConfigsType, PROVIDER_MODEL_TYPE } from "@/types/providers";
+import {
+  LLMPromptConfigsType,
+  PROVIDER_MODEL_TYPE,
+  PROVIDER_TYPE,
+} from "@/types/providers";
 import { SPAN_TYPE } from "@/types/traces";
 
 export interface PlaygroundPromptType {
@@ -10,6 +14,7 @@ export interface PlaygroundPromptType {
   id: string;
   messages: LLMMessage[];
   model: PROVIDER_MODEL_TYPE | "";
+  provider: PROVIDER_TYPE | "";
   configs: LLMPromptConfigsType;
 }
 
@@ -38,7 +43,18 @@ export type ChatCompletionOpikErrorMessageType =
       code: string;
     };
 
+export type ChatCompletionPythonProxyErrorMessageType = {
+  detail:
+    | {
+        error: string;
+      }
+    | {
+        detail: string;
+      };
+};
+
 export type ChatCompletionResponse =
+  | ChatCompletionPythonProxyErrorMessageType
   | ChatCompletionOpikErrorMessageType
   | ChatCompletionSuccessMessageType
   | ChatCompletionProviderErrorMessageType;
diff --git a/apps/opik-frontend/src/types/providers.ts b/apps/opik-frontend/src/types/providers.ts
index d93552f416..0a52b8d0b7 100644
--- a/apps/opik-frontend/src/types/providers.ts
+++ b/apps/opik-frontend/src/types/providers.ts
@@ -1,6 +1,7 @@
 export enum PROVIDER_TYPE {
   OPEN_AI = "openai",
   ANTHROPIC = "anthropic",
+  OLLAMA = "ollama",
 }
 
 export enum PROVIDER_MODEL_TYPE {
@@ -33,6 +34,25 @@ export enum PROVIDER_MODEL_TYPE {
   CLAUDE_3_HAIKU_20240307 = "claude-3-haiku-20240307",
 }
 
+export type PROVIDER_MODELS_TYPE = {
+  [key in PROVIDER_TYPE]: {
+    value: PROVIDER_MODEL_TYPE;
+    label: string;
+    structuredOutput?: boolean;
+  }[];
+};
+
+export enum PROVIDER_LOCATION_TYPE {
+  cloud = "cloud",
+  local = "local",
+}
+
+export interface LocalAIProviderData {
+  url: string;
+  models: string;
+  created_at: string;
+}
+
 export interface ProviderKey {
   id: string;
   keyName: string;


Code review comments:
diff_hunk: @@ -0,0 +1,93 @@
+import React from "react";
+import { UseFormReturn } from "react-hook-form";
+import { Label } from "@/components/ui/label";
+import { PROVIDER_TYPE } from "@/types/providers";
+import { PROVIDERS } from "@/constants/providers";
+import { AIProviderFormType } from "@/components/shared/AddEditAIProviderDialog/schema";
+import get from "lodash/get";
+import {
+  FormControl,
+  FormField,
+  FormItem,
+  FormMessage,
+} from "@/components/ui/form";
+import { cn } from "@/lib/utils";
+import { Input } from "@/components/ui/input";
+import { Description } from "@/components/ui/description";
+
+type LocalAIProviderDetailsProps = {
+  provider: PROVIDER_TYPE | "";
+  form: UseFormReturn<AIProviderFormType>;
+};
+
+const LocalAIProviderDetails: React.FC<LocalAIProviderDetailsProps> = ({
+  provider,
+  form,
+}) => {
+  const providerName = (provider && PROVIDERS[provider]?.label + " ") || "";
+  const urlLabel = `${providerName}URL`;
+
+  return (
+    <>
+      <div className="flex flex-col gap-2">
+        <FormField
+          control={form.control}
+          name="url"
+          render={({ field, formState }) => {
+            const validationErrors = get(formState.errors, ["url"]);
+
+            return (
+              <FormItem>
+                <Label htmlFor="url">{urlLabel}</Label>
+                <FormControl>
+                  <Input
+                    id="url"
+                    placeholder={urlLabel}
+                    value={field.value}
+                    onChange={(e) => field.onChange(e.target.value)}
+                    className={cn({
+                      "border-destructive": Boolean(validationErrors?.message),
+                    })}
+                  />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            );
+          }}
+        />
+      </div>
+      <div className="flex flex-col gap-2">
+        <FormField
+          control={form.control}
+          name="models"
+          render={({ field, formState }) => {
+            const validationErrors = get(formState.errors, ["models"]);
+
+            return (
+              <FormItem>
+                <Label htmlFor="models">Models list</Label>
+                <Description>
+                  Comma separated list of models available by specified URL
path: apps/opik-frontend/src/components/shared/AddEditAIProviderDialog/LocalAIProviderDetails.tsx
text: @user1:
```suggestion
                  Comma separated list of available models
```

@author:
Done