Browse Source

update model setting pages, support Ollama, use ChatOpenAI for custom providers by default

alexchenzl 5 months ago
parent
commit
5b995d4d43

+ 1 - 0
chrome-extension/package.json

@@ -21,6 +21,7 @@
     "@langchain/anthropic": "^0.3.12",
     "@langchain/anthropic": "^0.3.12",
     "@langchain/core": "^0.3.37",
     "@langchain/core": "^0.3.37",
     "@langchain/google-genai": "0.1.10",
     "@langchain/google-genai": "0.1.10",
+    "@langchain/ollama": "^0.2.0",
     "@langchain/openai": "^0.4.2",
     "@langchain/openai": "^0.4.2",
     "puppeteer-core": "24.1.1",
     "puppeteer-core": "24.1.1",
     "webextension-polyfill": "^0.12.0",
     "webextension-polyfill": "^0.12.0",

+ 74 - 2
chrome-extension/src/background/agent/helper.ts

@@ -1,8 +1,9 @@
-import { type ProviderConfig, AgentNameEnum } from '@extension/storage';
+import { type ProviderConfig, AgentNameEnum, OLLAMA_PROVIDER } from '@extension/storage';
 import { ChatOpenAI } from '@langchain/openai';
 import { ChatOpenAI } from '@langchain/openai';
 import { ChatAnthropic } from '@langchain/anthropic';
 import { ChatAnthropic } from '@langchain/anthropic';
 import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
 import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
 import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
 import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
+import { ChatOllama } from '@langchain/ollama';
 
 
 // Provider constants
 // Provider constants
 const OPENAI_PROVIDER = 'openai';
 const OPENAI_PROVIDER = 'openai';
@@ -20,6 +21,10 @@ export function createChatModel(
   const maxCompletionTokens = 5000;
   const maxCompletionTokens = 5000;
   let temperature = 0;
   let temperature = 0;
   let topP = 0.001;
   let topP = 0.001;
+
+  console.log('providerName', providerName);
+  console.log('providerConfig', providerConfig);
+
   switch (providerName) {
   switch (providerName) {
     case OPENAI_PROVIDER: {
     case OPENAI_PROVIDER: {
       if (agentName === AgentNameEnum.Planner) {
       if (agentName === AgentNameEnum.Planner) {
@@ -85,8 +90,75 @@ export function createChatModel(
       };
       };
       return new ChatGoogleGenerativeAI(args);
       return new ChatGoogleGenerativeAI(args);
     }
     }
+    case OLLAMA_PROVIDER: {
+      if (agentName === AgentNameEnum.Planner) {
+        temperature = 0.02;
+      }
+      const args: {
+        model: string;
+        apiKey?: string;
+        baseUrl: string;
+        modelKwargs?: { max_completion_tokens: number };
+        topP?: number;
+        temperature?: number;
+        maxTokens?: number;
+        options: {
+          num_ctx: number;
+        };
+      } = {
+        model: modelName,
+        apiKey: providerConfig.apiKey,
+        baseUrl: providerConfig.baseUrl ?? 'http://localhost:11434',
+        options: {
+          num_ctx: 128000,
+        },
+      };
+
+      // O series models have different parameters
+      if (modelName.startsWith('o')) {
+        args.modelKwargs = {
+          max_completion_tokens: maxCompletionTokens,
+        };
+      } else {
+        args.topP = topP;
+        args.temperature = temperature;
+        args.maxTokens = maxTokens;
+      }
+      return new ChatOllama(args);
+    }
     default: {
     default: {
-      throw new Error(`Provider ${providerName} not supported yet`);
+      if (agentName === AgentNameEnum.Planner) {
+        temperature = 0.02;
+      }
+      const args: {
+        model: string;
+        apiKey: string;
+        configuration: Record<string, unknown>;
+        modelKwargs?: { max_completion_tokens: number };
+        topP?: number;
+        temperature?: number;
+        maxTokens?: number;
+      } = {
+        model: modelName,
+        apiKey: providerConfig.apiKey,
+        configuration: {},
+      };
+
+      args.configuration = {
+        baseURL: providerConfig.baseUrl,
+      };
+
+      // O series models have different parameters
+      if (modelName.startsWith('o')) {
+        args.modelKwargs = {
+          max_completion_tokens: maxCompletionTokens,
+        };
+      } else {
+        args.topP = topP;
+        args.temperature = temperature;
+        args.maxTokens = maxTokens;
+      }
+      return new ChatOpenAI(args);
     }
     }
   }
   }
 }
 }

+ 1 - 7
packages/storage/lib/settings/agentModels.ts

@@ -1,7 +1,7 @@
 import { StorageEnum } from '../base/enums';
 import { StorageEnum } from '../base/enums';
 import { createStorage } from '../base/base';
 import { createStorage } from '../base/base';
 import type { BaseStorage } from '../base/types';
 import type { BaseStorage } from '../base/types';
-import { type AgentNameEnum, llmProviderModelNames } from './types';
+import type { AgentNameEnum } from './types';
 
 
 // Interface for a single model configuration
 // Interface for a single model configuration
 export interface ModelConfig {
 export interface ModelConfig {
@@ -36,12 +36,6 @@ function validateModelConfig(config: ModelConfig) {
   if (!config.provider || !config.modelName) {
   if (!config.provider || !config.modelName) {
     throw new Error('Provider and model name must be specified');
     throw new Error('Provider and model name must be specified');
   }
   }
-
-  // Check if the provider exists in our predefined providers
-  const validModels = llmProviderModelNames[config.provider as keyof typeof llmProviderModelNames];
-  if (!validModels || !validModels.includes(config.modelName)) {
-    throw new Error(`Invalid model "${config.modelName}" for provider "${config.provider}"`);
-  }
 }
 }
 
 
 export const agentModelStore: AgentModelStorage = {
 export const agentModelStore: AgentModelStorage = {

+ 17 - 1
packages/storage/lib/settings/llmProviders.ts

@@ -1,7 +1,14 @@
 import { StorageEnum } from '../base/enums';
 import { StorageEnum } from '../base/enums';
 import { createStorage } from '../base/base';
 import { createStorage } from '../base/base';
 import type { BaseStorage } from '../base/types';
 import type { BaseStorage } from '../base/types';
-import { llmProviderModelNames, ProviderTypeEnum, OPENAI_PROVIDER, ANTHROPIC_PROVIDER, GEMINI_PROVIDER } from './types';
+import {
+  llmProviderModelNames,
+  ProviderTypeEnum,
+  OPENAI_PROVIDER,
+  ANTHROPIC_PROVIDER,
+  GEMINI_PROVIDER,
+  OLLAMA_PROVIDER,
+} from './types';
 
 
 // Interface for a single provider configuration
 // Interface for a single provider configuration
 export interface ProviderConfig {
 export interface ProviderConfig {
@@ -10,6 +17,7 @@ export interface ProviderConfig {
   apiKey: string; // Must be provided, but may be empty for local models
   apiKey: string; // Must be provided, but may be empty for local models
   baseUrl?: string; // Optional base URL if provided
   baseUrl?: string; // Optional base URL if provided
   modelNames?: string[]; // Chosen model names, if not provided use hardcoded names from llmProviderModelNames
   modelNames?: string[]; // Chosen model names, if not provided use hardcoded names from llmProviderModelNames
+  createdAt?: number; // Timestamp in milliseconds when the provider was created
 }
 }
 
 
 // Interface for storing multiple LLM provider configurations
 // Interface for storing multiple LLM provider configurations
@@ -44,6 +52,8 @@ function getProviderTypeFromName(provider: string): ProviderTypeEnum {
       return ProviderTypeEnum.Anthropic;
       return ProviderTypeEnum.Anthropic;
     case GEMINI_PROVIDER:
     case GEMINI_PROVIDER:
       return ProviderTypeEnum.Gemini;
       return ProviderTypeEnum.Gemini;
+    case OLLAMA_PROVIDER:
+      return ProviderTypeEnum.Ollama;
     default:
     default:
       return ProviderTypeEnum.CustomOpenAI;
       return ProviderTypeEnum.CustomOpenAI;
   }
   }
@@ -58,6 +68,8 @@ function getDisplayNameFromProvider(provider: string): string {
       return 'Anthropic';
       return 'Anthropic';
     case GEMINI_PROVIDER:
     case GEMINI_PROVIDER:
       return 'Gemini';
       return 'Gemini';
+    case OLLAMA_PROVIDER:
+      return 'Ollama';
     default:
     default:
       return provider; // Use the provider string as display name for custom providers
       return provider; // Use the provider string as display name for custom providers
   }
   }
@@ -84,6 +96,7 @@ export const llmProviderStore: LLMProviderStorage = {
       name: config.name || getDisplayNameFromProvider(provider),
       name: config.name || getDisplayNameFromProvider(provider),
       type: config.type || getProviderTypeFromName(provider),
       type: config.type || getProviderTypeFromName(provider),
       modelNames: config.modelNames,
       modelNames: config.modelNames,
+      createdAt: config.createdAt || Date.now(),
     };
     };
 
 
     const current = (await storage.get()) || { providers: {} };
     const current = (await storage.get()) || { providers: {} };
@@ -109,6 +122,9 @@ export const llmProviderStore: LLMProviderStorage = {
       if (!config.modelNames) {
       if (!config.modelNames) {
         config.modelNames = llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
         config.modelNames = llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
       }
       }
+      if (!config.createdAt) {
+        config.createdAt = Date.now();
+      }
     }
     }
 
 
     return config;
     return config;

+ 4 - 1
packages/storage/lib/settings/types.ts

@@ -8,17 +8,19 @@ export enum AgentNameEnum {
 export const OPENAI_PROVIDER = 'openai';
 export const OPENAI_PROVIDER = 'openai';
 export const ANTHROPIC_PROVIDER = 'anthropic';
 export const ANTHROPIC_PROVIDER = 'anthropic';
 export const GEMINI_PROVIDER = 'gemini';
 export const GEMINI_PROVIDER = 'gemini';
+export const OLLAMA_PROVIDER = 'ollama';
 
 
 // Provider type for determining which LangChain ChatModel package to use
 // Provider type for determining which LangChain ChatModel package to use
 export enum ProviderTypeEnum {
 export enum ProviderTypeEnum {
   OpenAI = 'openai',
   OpenAI = 'openai',
   Anthropic = 'anthropic',
   Anthropic = 'anthropic',
   Gemini = 'gemini',
   Gemini = 'gemini',
+  Ollama = 'ollama',
   CustomOpenAI = 'custom_openai',
   CustomOpenAI = 'custom_openai',
 }
 }
 
 
 export const llmProviderModelNames = {
 export const llmProviderModelNames = {
-  [OPENAI_PROVIDER]: ['gpt-4o', 'gpt-4o-mini', 'o1', 'o1-mini', 'o3-mini', 'deepseek-r1'],
+  [OPENAI_PROVIDER]: ['gpt-4o', 'gpt-4o-mini', 'o1', 'o1-mini', 'o3-mini'],
   [ANTHROPIC_PROVIDER]: ['claude-3-7-sonnet-latest', 'claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest'],
   [ANTHROPIC_PROVIDER]: ['claude-3-7-sonnet-latest', 'claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest'],
   [GEMINI_PROVIDER]: [
   [GEMINI_PROVIDER]: [
     'gemini-2.0-flash',
     'gemini-2.0-flash',
@@ -26,5 +28,6 @@ export const llmProviderModelNames = {
     'gemini-2.0-pro-exp-02-05',
     'gemini-2.0-pro-exp-02-05',
     // 'gemini-2.0-flash-thinking-exp-01-21', // TODO: not support function calling for now
     // 'gemini-2.0-flash-thinking-exp-01-21', // TODO: not support function calling for now
   ],
   ],
+  [OLLAMA_PROVIDER]: [],
   // Custom OpenAI providers don't have predefined models as they are user-defined
   // Custom OpenAI providers don't have predefined models as they are user-defined
 };
 };

+ 719 - 128
pages/options/src/components/ModelSettings.tsx

@@ -1,51 +1,66 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useRef } from 'react';
+import type { KeyboardEvent } from 'react';
 import { Button } from '@extension/ui';
 import { Button } from '@extension/ui';
-import { llmProviderStore, agentModelStore, AgentNameEnum, llmProviderModelNames } from '@extension/storage';
-
-// Provider constants
-const OPENAI_PROVIDER = 'openai';
-const ANTHROPIC_PROVIDER = 'anthropic';
-const GEMINI_PROVIDER = 'gemini';
+import {
+  llmProviderStore,
+  agentModelStore,
+  AgentNameEnum,
+  llmProviderModelNames,
+  ProviderTypeEnum,
+  OPENAI_PROVIDER,
+  ANTHROPIC_PROVIDER,
+  GEMINI_PROVIDER,
+  OLLAMA_PROVIDER,
+} from '@extension/storage';
 
 
 export const ModelSettings = () => {
 export const ModelSettings = () => {
-  const [apiKeys, setApiKeys] = useState<Record<string, { apiKey: string; baseUrl?: string }>>(
-    {} as Record<string, { apiKey: string; baseUrl?: string }>,
-  );
+  const [providers, setProviders] = useState<
+    Record<
+      string,
+      {
+        apiKey: string;
+        baseUrl?: string;
+        name?: string;
+        modelNames?: string[];
+        type?: ProviderTypeEnum;
+        createdAt?: number;
+      }
+    >
+  >({});
   const [modifiedProviders, setModifiedProviders] = useState<Set<string>>(new Set());
   const [modifiedProviders, setModifiedProviders] = useState<Set<string>>(new Set());
+  const [providersFromStorage, setProvidersFromStorage] = useState<Set<string>>(new Set());
   const [selectedModels, setSelectedModels] = useState<Record<AgentNameEnum, string>>({
   const [selectedModels, setSelectedModels] = useState<Record<AgentNameEnum, string>>({
     [AgentNameEnum.Navigator]: '',
     [AgentNameEnum.Navigator]: '',
     [AgentNameEnum.Planner]: '',
     [AgentNameEnum.Planner]: '',
     [AgentNameEnum.Validator]: '',
     [AgentNameEnum.Validator]: '',
   });
   });
+  const [newModelInputs, setNewModelInputs] = useState<Record<string, string>>({});
+  const [isProviderSelectorOpen, setIsProviderSelectorOpen] = useState(false);
+  const newlyAddedProviderRef = useRef<string | null>(null);
+  const [nameErrors, setNameErrors] = useState<Record<string, string>>({});
 
 
   useEffect(() => {
   useEffect(() => {
-    const loadApiKeys = async () => {
+    const loadProviders = async () => {
       try {
       try {
-        const providers = await llmProviderStore.getConfiguredProviders();
+        const allProviders = await llmProviderStore.getAllProviders();
+        console.log('allProviders', allProviders);
 
 
-        const keys: Record<string, { apiKey: string; baseUrl?: string }> = {} as Record<
-          string,
-          { apiKey: string; baseUrl?: string }
-        >;
+        // Track which providers are from storage
+        const fromStorage = new Set(Object.keys(allProviders));
+        setProvidersFromStorage(fromStorage);
 
 
-        for (const provider of providers) {
-          const config = await llmProviderStore.getProvider(provider);
-          console.log('config', config);
-          if (config) {
-            keys[provider] = {
-              apiKey: config.apiKey,
-              baseUrl: config.baseUrl,
-            };
-          }
-        }
-        setApiKeys(keys);
+        // Only use providers from storage, don't add default ones
+        setProviders(allProviders);
       } catch (error) {
       } catch (error) {
-        console.error('Error loading API keys:', error);
-        setApiKeys({} as Record<string, { apiKey: string; baseUrl?: string }>);
+        console.error('Error loading providers:', error);
+        // Set empty providers on error
+        setProviders({});
+        // No providers from storage on error
+        setProvidersFromStorage(new Set());
       }
       }
     };
     };
 
 
-    loadApiKeys();
+    loadProviders();
   }, []);
   }, []);
 
 
   // Load existing agent models on mount
   // Load existing agent models on mount
@@ -73,25 +88,201 @@ export const ModelSettings = () => {
     loadAgentModels();
     loadAgentModels();
   }, []);
   }, []);
 
 
+  // Auto-focus the input field when a new provider is added
+  useEffect(() => {
+    // Only focus if we have a newly added provider reference
+    if (newlyAddedProviderRef.current && providers[newlyAddedProviderRef.current]) {
+      const providerId = newlyAddedProviderRef.current;
+      const config = providers[providerId];
+
+      // For custom providers, focus on the name input
+      if (config.type === ProviderTypeEnum.CustomOpenAI) {
+        const nameInput = document.getElementById(`${providerId}-name`);
+        if (nameInput) {
+          nameInput.focus();
+        }
+      } else {
+        // For default providers, focus on the API key input
+        const apiKeyInput = document.getElementById(`${providerId}-api-key`);
+        if (apiKeyInput) {
+          apiKeyInput.focus();
+        }
+      }
+
+      // Clear the ref after focusing
+      newlyAddedProviderRef.current = null;
+    }
+  }, [providers]);
+
+  // Add a click outside handler to close the dropdown
+  useEffect(() => {
+    const handleClickOutside = (event: MouseEvent) => {
+      const target = event.target as HTMLElement;
+      if (isProviderSelectorOpen && !target.closest('.provider-selector-container')) {
+        setIsProviderSelectorOpen(false);
+      }
+    };
+
+    document.addEventListener('mousedown', handleClickOutside);
+    return () => {
+      document.removeEventListener('mousedown', handleClickOutside);
+    };
+  }, [isProviderSelectorOpen]);
+
   const handleApiKeyChange = (provider: string, apiKey: string, baseUrl?: string) => {
   const handleApiKeyChange = (provider: string, apiKey: string, baseUrl?: string) => {
     setModifiedProviders(prev => new Set(prev).add(provider));
     setModifiedProviders(prev => new Set(prev).add(provider));
-    setApiKeys(prev => ({
+    setProviders(prev => ({
       ...prev,
       ...prev,
       [provider]: {
       [provider]: {
+        ...prev[provider],
         apiKey: apiKey.trim(),
         apiKey: apiKey.trim(),
         baseUrl: baseUrl !== undefined ? baseUrl.trim() : prev[provider]?.baseUrl,
         baseUrl: baseUrl !== undefined ? baseUrl.trim() : prev[provider]?.baseUrl,
       },
       },
     }));
     }));
   };
   };
 
 
+  const handleNameChange = (provider: string, name: string) => {
+    console.log('handleNameChange called with:', provider, name);
+
+    setModifiedProviders(prev => new Set(prev).add(provider));
+    setProviders(prev => {
+      const updated = {
+        ...prev,
+        [provider]: {
+          ...prev[provider],
+          name: name.trim(),
+        },
+      };
+      console.log('Updated providers state:', updated);
+      return updated;
+    });
+  };
+
+  const handleModelsChange = (provider: string, modelsString: string) => {
+    setNewModelInputs(prev => ({
+      ...prev,
+      [provider]: modelsString,
+    }));
+  };
+
+  const addModel = (provider: string, model: string) => {
+    if (!model.trim()) return;
+
+    setModifiedProviders(prev => new Set(prev).add(provider));
+    setProviders(prev => {
+      const providerData = prev[provider] || {};
+
+      // Get current models - either from provider config or default models
+      let currentModels = providerData.modelNames;
+      if (currentModels === undefined) {
+        currentModels = [...(llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [])];
+      }
+
+      // Don't add duplicates
+      if (currentModels.includes(model.trim())) return prev;
+
+      return {
+        ...prev,
+        [provider]: {
+          ...providerData,
+          modelNames: [...currentModels, model.trim()],
+        },
+      };
+    });
+
+    // Clear the input
+    setNewModelInputs(prev => ({
+      ...prev,
+      [provider]: '',
+    }));
+  };
+
+  const removeModel = (provider: string, modelToRemove: string) => {
+    setModifiedProviders(prev => new Set(prev).add(provider));
+
+    setProviders(prev => {
+      const providerData = prev[provider] || {};
+
+      // If modelNames doesn't exist in the provider data yet, we need to initialize it
+      // with the default models from llmProviderModelNames first
+      if (!providerData.modelNames) {
+        const defaultModels = llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
+        const filteredModels = defaultModels.filter(model => model !== modelToRemove);
+
+        return {
+          ...prev,
+          [provider]: {
+            ...providerData,
+            modelNames: filteredModels,
+          },
+        };
+      }
+
+      // If modelNames already exists, just filter out the model to remove
+      return {
+        ...prev,
+        [provider]: {
+          ...providerData,
+          modelNames: providerData.modelNames.filter(model => model !== modelToRemove),
+        },
+      };
+    });
+  };
+
+  const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>, provider: string) => {
+    if (e.key === 'Enter' || e.key === ' ') {
+      e.preventDefault();
+      const value = newModelInputs[provider] || '';
+      addModel(provider, value);
+    }
+  };
+
   const handleSave = async (provider: string) => {
   const handleSave = async (provider: string) => {
     try {
     try {
+      // Check if name contains spaces for custom providers
+      if (providers[provider].type === ProviderTypeEnum.CustomOpenAI && providers[provider].name?.includes(' ')) {
+        setNameErrors(prev => ({
+          ...prev,
+          [provider]: 'Spaces are not allowed in provider names. Please use underscores or other characters instead.',
+        }));
+        return;
+      }
+
+      // Check if base URL is required but missing for custom_openai
+      if (
+        providers[provider].type === ProviderTypeEnum.CustomOpenAI &&
+        (!providers[provider].baseUrl || !providers[provider].baseUrl.trim())
+      ) {
+        alert('Base URL is required for custom OpenAI providers');
+        return;
+      }
+
+      // Ensure modelNames is provided
+      let modelNames = providers[provider].modelNames;
+      if (!modelNames) {
+        // Use default model names if not explicitly set
+        modelNames = [...(llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [])];
+      }
+
       // The provider store will handle filling in the missing fields
       // The provider store will handle filling in the missing fields
       await llmProviderStore.setProvider(provider, {
       await llmProviderStore.setProvider(provider, {
-        apiKey: apiKeys[provider].apiKey,
-        baseUrl: apiKeys[provider].baseUrl,
+        apiKey: providers[provider].apiKey,
+        baseUrl: providers[provider].baseUrl,
+        name: providers[provider].name,
+        modelNames: modelNames,
+        type: providers[provider].type,
+      });
+
+      // Clear any name errors on successful save
+      setNameErrors(prev => {
+        const newErrors = { ...prev };
+        delete newErrors[provider];
+        return newErrors;
       });
       });
 
 
+      // Add to providersFromStorage since it's now saved
+      setProvidersFromStorage(prev => new Set(prev).add(provider));
+
       setModifiedProviders(prev => {
       setModifiedProviders(prev => {
         const next = new Set(prev);
         const next = new Set(prev);
         next.delete(provider);
         next.delete(provider);
@@ -105,7 +296,15 @@ export const ModelSettings = () => {
   const handleDelete = async (provider: string) => {
   const handleDelete = async (provider: string) => {
     try {
     try {
       await llmProviderStore.removeProvider(provider);
       await llmProviderStore.removeProvider(provider);
-      setApiKeys(prev => {
+
+      // Remove from providersFromStorage
+      setProvidersFromStorage(prev => {
+        const next = new Set(prev);
+        next.delete(provider);
+        return next;
+      });
+
+      setProviders(prev => {
         const next = { ...prev };
         const next = { ...prev };
         delete next[provider];
         delete next[provider];
         return next;
         return next;
@@ -116,9 +315,9 @@ export const ModelSettings = () => {
   };
   };
 
 
   const getButtonProps = (provider: string) => {
   const getButtonProps = (provider: string) => {
-    const hasStoredKey = Boolean(apiKeys[provider]?.apiKey);
+    const hasStoredKey = Boolean(providers[provider]?.apiKey);
     const isModified = modifiedProviders.has(provider);
     const isModified = modifiedProviders.has(provider);
-    const hasInput = Boolean(apiKeys[provider]?.apiKey?.trim());
+    const hasInput = Boolean(providers[provider]?.apiKey?.trim());
 
 
     if (hasStoredKey && !isModified) {
     if (hasStoredKey && !isModified) {
       return {
       return {
@@ -135,16 +334,46 @@ export const ModelSettings = () => {
     };
     };
   };
   };
 
 
+  const handleCancelProvider = (providerId: string) => {
+    // Remove the provider from the state
+    setProviders(prev => {
+      const next = { ...prev };
+      delete next[providerId];
+      return next;
+    });
+
+    // Remove from modified providers
+    setModifiedProviders(prev => {
+      const next = new Set(prev);
+      next.delete(providerId);
+      return next;
+    });
+  };
+
   const getAvailableModels = () => {
   const getAvailableModels = () => {
     const models: string[] = [];
     const models: string[] = [];
 
 
-    for (const [provider, config] of Object.entries(apiKeys)) {
+    // First add models from configured providers
+    for (const [provider, config] of Object.entries(providers)) {
       if (config.apiKey) {
       if (config.apiKey) {
-        const providerModels = llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
+        const providerModels =
+          config.modelNames || llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
         models.push(...providerModels);
         models.push(...providerModels);
       }
       }
     }
     }
 
 
+    // If no models are available, return default models for the "Add Provider" buttons
+    if (models.length === 0) {
+      // Include default models for the default providers
+      const defaultProviders = [OPENAI_PROVIDER, ANTHROPIC_PROVIDER, GEMINI_PROVIDER, OLLAMA_PROVIDER];
+      for (const provider of defaultProviders) {
+        if (!providersFromStorage.has(provider) && !modifiedProviders.has(provider)) {
+          const defaultModels = llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
+          models.push(...defaultModels);
+        }
+      }
+    }
+
     return models.length ? models : [''];
     return models.length ? models : [''];
   };
   };
 
 
@@ -158,13 +387,16 @@ export const ModelSettings = () => {
       if (model) {
       if (model) {
         // Determine provider from model name
         // Determine provider from model name
         let provider: string | undefined;
         let provider: string | undefined;
-        for (const [providerKey, models] of Object.entries(llmProviderModelNames)) {
-          if (models.includes(model)) {
+        for (const [providerKey, providerConfig] of Object.entries(providers)) {
+          const modelNames =
+            providerConfig.modelNames || llmProviderModelNames[providerKey as keyof typeof llmProviderModelNames] || [];
+          if (modelNames.includes(model)) {
             provider = providerKey;
             provider = providerKey;
             break;
             break;
           }
           }
         }
         }
 
 
+        console.log('handleModelChange', provider, model);
         if (provider) {
         if (provider) {
           await agentModelStore.setAgentModel(agentName, {
           await agentModelStore.setAgentModel(agentName, {
             provider,
             provider,
@@ -219,101 +451,460 @@ export const ModelSettings = () => {
     }
     }
   };
   };
 
 
+  const getMaxCustomProviderNumber = () => {
+    let maxNumber = 0;
+    for (const providerId of Object.keys(providers)) {
+      if (providerId.startsWith('custom_openai_')) {
+        const match = providerId.match(/custom_openai_(\d+)/);
+        if (match) {
+          const number = Number.parseInt(match[1], 10);
+          maxNumber = Math.max(maxNumber, number);
+        }
+      }
+    }
+    return maxNumber;
+  };
+
+  const addCustomProvider = () => {
+    const nextNumber = getMaxCustomProviderNumber() + 1;
+    const providerId = `custom_openai_${nextNumber}`;
+
+    setProviders(prev => ({
+      ...prev,
+      [providerId]: {
+        apiKey: '',
+        name: `CustomProvider${nextNumber}`,
+        type: ProviderTypeEnum.CustomOpenAI,
+        baseUrl: '',
+        modelNames: [],
+        createdAt: Date.now(),
+      },
+    }));
+
+    setModifiedProviders(prev => new Set(prev).add(providerId));
+
+    // Set the newly added provider ref
+    newlyAddedProviderRef.current = providerId;
+
+    // Scroll to the newly added provider after render
+    setTimeout(() => {
+      const providerElement = document.getElementById(`provider-${providerId}`);
+      if (providerElement) {
+        providerElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
+      }
+    }, 100);
+  };
+
+  const addDefaultProvider = (provider: string) => {
+    // Get the default provider configuration
+    let config: {
+      apiKey: string;
+      name: string;
+      type: ProviderTypeEnum;
+      modelNames: string[];
+      baseUrl?: string;
+      createdAt: number;
+    };
+
+    switch (provider) {
+      case OPENAI_PROVIDER:
+        config = {
+          apiKey: '',
+          name: 'OpenAI',
+          type: ProviderTypeEnum.OpenAI,
+          modelNames: [...(llmProviderModelNames[OPENAI_PROVIDER] || [])],
+          createdAt: Date.now(),
+        };
+        break;
+      case ANTHROPIC_PROVIDER:
+        config = {
+          apiKey: '',
+          name: 'Anthropic',
+          type: ProviderTypeEnum.Anthropic,
+          modelNames: [...(llmProviderModelNames[ANTHROPIC_PROVIDER] || [])],
+          createdAt: Date.now(),
+        };
+        break;
+      case GEMINI_PROVIDER:
+        config = {
+          apiKey: '',
+          name: 'Gemini',
+          type: ProviderTypeEnum.Gemini,
+          modelNames: [...(llmProviderModelNames[GEMINI_PROVIDER] || [])],
+          createdAt: Date.now(),
+        };
+        break;
+      case OLLAMA_PROVIDER:
+        config = {
+          apiKey: 'ollama',
+          name: 'Ollama',
+          type: ProviderTypeEnum.Ollama,
+          modelNames: [],
+          baseUrl: 'http://localhost:11434',
+          createdAt: Date.now(),
+        };
+        break;
+      default:
+        return;
+    }
+
+    // Add the provider to the state
+    setProviders(prev => ({
+      ...prev,
+      [provider]: config,
+    }));
+
+    // Mark as modified so it shows up in the UI
+    setModifiedProviders(prev => new Set(prev).add(provider));
+
+    // Set the newly added provider ref
+    newlyAddedProviderRef.current = provider;
+
+    // Scroll to the newly added provider after render
+    setTimeout(() => {
+      const providerElement = document.getElementById(`provider-${provider}`);
+      if (providerElement) {
+        providerElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
+      }
+    }, 100);
+  };
+
+  // Sort providers to ensure newly added providers appear at the bottom
+  const getSortedProviders = () => {
+    // Filter providers to only include those from storage and newly added providers
+    const filteredProviders = Object.entries(providers).filter(([providerId]) => {
+      // Include if it's from storage
+      if (providersFromStorage.has(providerId)) {
+        return true;
+      }
+
+      // Include if it's a newly added provider (has been modified)
+      if (modifiedProviders.has(providerId)) {
+        return true;
+      }
+
+      // Exclude providers that aren't from storage and haven't been modified
+      return false;
+    });
+
+    // Sort the filtered providers
+    return filteredProviders.sort(([keyA, configA], [keyB, configB]) => {
+      // First, separate newly added providers from stored providers
+      const isNewA = !providersFromStorage.has(keyA) && modifiedProviders.has(keyA);
+      const isNewB = !providersFromStorage.has(keyB) && modifiedProviders.has(keyB);
+
+      // If one is new and one is stored, new ones go to the end
+      if (isNewA && !isNewB) return 1;
+      if (!isNewA && isNewB) return -1;
+
+      // If both are new or both are stored, sort by createdAt
+      if (configA.createdAt && configB.createdAt) {
+        return configA.createdAt - configB.createdAt; // Sort in ascending order (oldest first)
+      }
+
+      // If only one has createdAt, put the one without createdAt at the end
+      if (configA.createdAt) return -1;
+      if (configB.createdAt) return 1;
+
+      // If neither has createdAt, sort by type and then name
+      const isCustomA = configA.type === ProviderTypeEnum.CustomOpenAI;
+      const isCustomB = configB.type === ProviderTypeEnum.CustomOpenAI;
+
+      if (isCustomA && !isCustomB) {
+        return 1; // Custom providers come after non-custom
+      }
+
+      if (!isCustomA && isCustomB) {
+        return -1; // Non-custom providers come before custom
+      }
+
+      // Sort alphabetically by name within each group
+      return (configA.name || keyA).localeCompare(configB.name || keyB);
+    });
+  };
+
+  const handleProviderSelection = (providerType: string) => {
+    // Close the dropdown immediately
+    setIsProviderSelectorOpen(false);
+
+    if (providerType === 'custom') {
+      addCustomProvider();
+      return;
+    }
+
+    // Handle default providers
+    switch (providerType) {
+      case OPENAI_PROVIDER:
+      case ANTHROPIC_PROVIDER:
+      case GEMINI_PROVIDER:
+      case OLLAMA_PROVIDER:
+        addDefaultProvider(providerType);
+        break;
+      default:
+        console.error('Unknown provider type:', providerType);
+    }
+  };
+
   return (
   return (
     <section className="space-y-6">
     <section className="space-y-6">
-      {/* API Keys Section */}
+      {/* LLM Providers Section */}
       <div className="bg-white rounded-lg p-6 shadow-sm border border-blue-100 text-left">
       <div className="bg-white rounded-lg p-6 shadow-sm border border-blue-100 text-left">
-        <h2 className="text-xl font-semibold mb-4 text-gray-800 text-left">API Keys</h2>
+        <h2 className="text-xl font-semibold mb-4 text-gray-800 text-left">LLM Providers</h2>
         <div className="space-y-6">
         <div className="space-y-6">
-          {/* OpenAI Section */}
-          <div className="space-y-4">
-            <div className="flex items-center justify-between">
-              <h3 className="text-lg font-medium text-gray-700">OpenAI</h3>
-              <Button
-                variant={getButtonProps(OPENAI_PROVIDER).variant}
-                disabled={getButtonProps(OPENAI_PROVIDER).disabled}
-                onClick={() =>
-                  apiKeys[OPENAI_PROVIDER]?.apiKey && !modifiedProviders.has(OPENAI_PROVIDER)
-                    ? handleDelete(OPENAI_PROVIDER)
-                    : handleSave(OPENAI_PROVIDER)
-                }>
-                {getButtonProps(OPENAI_PROVIDER).children}
-              </Button>
-            </div>
-            <div className="space-y-3">
-              <input
-                type="password"
-                placeholder="OpenAI API key"
-                value={apiKeys[OPENAI_PROVIDER]?.apiKey || ''}
-                onChange={e => handleApiKeyChange(OPENAI_PROVIDER, e.target.value)}
-                className="w-full p-2 rounded-md bg-gray-50 border border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-200 outline-none"
-              />
-              <input
-                type="text"
-                placeholder="Custom Base URL (Optional)"
-                value={apiKeys[OPENAI_PROVIDER]?.baseUrl || ''}
-                onChange={e =>
-                  handleApiKeyChange(OPENAI_PROVIDER, apiKeys[OPENAI_PROVIDER]?.apiKey || '', e.target.value)
-                }
-                className="w-full p-2 rounded-md bg-gray-50 border border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-200 outline-none"
-              />
+          {getSortedProviders().length === 0 ? (
+            <div className="text-center py-8 text-gray-500">
+              <p className="mb-4">No providers configured yet. Add a provider to get started.</p>
             </div>
             </div>
-          </div>
+          ) : (
+            getSortedProviders().map(([providerId, providerConfig]) => (
+              <div
+                key={providerId}
+                id={`provider-${providerId}`}
+                className={`space-y-4 ${modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) ? 'bg-blue-50 p-4 rounded-lg border border-blue-100' : ''}`}>
+                <div className="flex items-center justify-between">
+                  {providerConfig.type === ProviderTypeEnum.CustomOpenAI ? (
+                    <button
+                      type="button"
+                      className="text-lg font-medium text-gray-700 flex items-center cursor-pointer bg-transparent border-0 p-0 text-left"
+                      onClick={() => {
+                        const nameInput = document.getElementById(`${providerId}-name`);
+                        if (nameInput) {
+                          nameInput.focus();
+                        }
+                      }}
+                      onKeyDown={e => {
+                        if (e.key === 'Enter' || e.key === ' ') {
+                          const nameInput = document.getElementById(`${providerId}-name`);
+                          if (nameInput) {
+                            nameInput.focus();
+                          }
+                        }
+                      }}>
+                      {(() => {
+                        console.log('Provider header name:', providerId, providerConfig.name);
+                        return providerConfig.name || providerId;
+                      })()}
+                      <span className="ml-2 text-xs text-blue-500">(click to edit)</span>
+                    </button>
+                  ) : (
+                    <h3 className="text-lg font-medium text-gray-700">{providerConfig.name || providerId}</h3>
+                  )}
+                  <div className="flex space-x-2">
+                    {/* Show Cancel button for newly added providers */}
+                    {modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) && (
+                      <Button variant="secondary" onClick={() => handleCancelProvider(providerId)}>
+                        Cancel
+                      </Button>
+                    )}
+                    <Button
+                      variant={getButtonProps(providerId).variant}
+                      disabled={getButtonProps(providerId).disabled}
+                      onClick={() =>
+                        providers[providerId]?.apiKey && !modifiedProviders.has(providerId)
+                          ? handleDelete(providerId)
+                          : handleSave(providerId)
+                      }>
+                      {getButtonProps(providerId).children}
+                    </Button>
+                  </div>
+                </div>
 
 
-          <div className="border-t border-gray-200" />
-
-          {/* Anthropic Section */}
-          <div className="space-y-4">
-            <div className="flex items-center justify-between">
-              <h3 className="text-lg font-medium text-gray-700">Anthropic</h3>
-              <Button
-                variant={getButtonProps(ANTHROPIC_PROVIDER).variant}
-                disabled={getButtonProps(ANTHROPIC_PROVIDER).disabled}
-                onClick={() =>
-                  apiKeys[ANTHROPIC_PROVIDER]?.apiKey && !modifiedProviders.has(ANTHROPIC_PROVIDER)
-                    ? handleDelete(ANTHROPIC_PROVIDER)
-                    : handleSave(ANTHROPIC_PROVIDER)
-                }>
-                {getButtonProps(ANTHROPIC_PROVIDER).children}
-              </Button>
-            </div>
-            <div className="space-y-3">
-              <input
-                type="password"
-                placeholder="Anthropic API key"
-                value={apiKeys[ANTHROPIC_PROVIDER]?.apiKey || ''}
-                onChange={e => handleApiKeyChange(ANTHROPIC_PROVIDER, e.target.value)}
-                className="w-full p-2 rounded-md bg-gray-50 border border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-200 outline-none"
-              />
-            </div>
-          </div>
+                {/* Show message for newly added providers */}
+                {modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) && (
+                  <div className="text-sm text-blue-600 mb-2">
+                    <p>This provider is newly added. Enter your API key and click Save to configure it.</p>
+                  </div>
+                )}
 
 
-          <div className="border-t border-gray-200" />
-
-          {/* Gemini Section */}
-          <div className="space-y-4">
-            <div className="flex items-center justify-between">
-              <h3 className="text-lg font-medium text-gray-700">Gemini</h3>
-              <Button
-                variant={getButtonProps(GEMINI_PROVIDER).variant}
-                disabled={getButtonProps(GEMINI_PROVIDER).disabled}
-                onClick={() =>
-                  apiKeys[GEMINI_PROVIDER]?.apiKey && !modifiedProviders.has(GEMINI_PROVIDER)
-                    ? handleDelete(GEMINI_PROVIDER)
-                    : handleSave(GEMINI_PROVIDER)
-                }>
-                {getButtonProps(GEMINI_PROVIDER).children}
-              </Button>
-            </div>
-            <div className="space-y-3">
-              <input
-                type="password"
-                placeholder="Gemini API key"
-                value={apiKeys[GEMINI_PROVIDER]?.apiKey || ''}
-                onChange={e => handleApiKeyChange(GEMINI_PROVIDER, e.target.value)}
-                className="w-full p-2 rounded-md bg-gray-50 border border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-200 outline-none"
-              />
-            </div>
+                <div className="space-y-3">
+                  {/* Name input (only for custom_openai) - moved to top for prominence */}
+                  {providerConfig.type === ProviderTypeEnum.CustomOpenAI && (
+                    <div className="flex flex-col">
+                      <div className="flex items-center">
+                        <label htmlFor={`${providerId}-name`} className="w-20 text-sm font-medium text-gray-700">
+                          Name
+                        </label>
+                        <input
+                          id={`${providerId}-name`}
+                          type="text"
+                          placeholder="Provider name"
+                          value={providerConfig.name || ''}
+                          onChange={e => {
+                            console.log('Name input changed:', e.target.value);
+                            handleNameChange(providerId, e.target.value);
+                          }}
+                          className={`flex-1 p-2 rounded-md bg-gray-50 border ${nameErrors[providerId] ? 'border-red-300 focus:border-red-400 focus:ring-2 focus:ring-red-200' : 'border-blue-300 focus:border-blue-400 focus:ring-2 focus:ring-blue-200'} outline-none`}
+                        />
+                      </div>
+                      {nameErrors[providerId] ? (
+                        <p className="text-xs text-red-500 ml-20 mt-1">{nameErrors[providerId]}</p>
+                      ) : (
+                        <p className="text-xs text-blue-500 ml-20 mt-1">
+                          Provider name (spaces are not allowed when saving)
+                        </p>
+                      )}
+                    </div>
+                  )}
+
+                  {/* API Key input with label */}
+                  <div className="flex items-center">
+                    <label htmlFor={`${providerId}-api-key`} className="w-20 text-sm font-medium text-gray-700">
+                      Key
+                    </label>
+                    <input
+                      id={`${providerId}-api-key`}
+                      type="password"
+                      placeholder={`${providerConfig.name || providerId} API key`}
+                      value={providerConfig.apiKey || ''}
+                      onChange={e => handleApiKeyChange(providerId, e.target.value, providerConfig.baseUrl)}
+                      className="flex-1 p-2 rounded-md bg-gray-50 border border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-200 outline-none"
+                    />
+                  </div>
+
+                  {/* Base URL input (for custom_openai and ollama) */}
+                  {(providerConfig.type === ProviderTypeEnum.CustomOpenAI ||
+                    providerConfig.type === ProviderTypeEnum.Ollama) && (
+                    <div className="flex items-center">
+                      <label htmlFor={`${providerId}-base-url`} className="w-20 text-sm font-medium text-gray-700">
+                        Base URL{providerConfig.type === ProviderTypeEnum.CustomOpenAI ? '*' : ''}
+                      </label>
+                      <input
+                        id={`${providerId}-base-url`}
+                        type="text"
+                        placeholder={
+                          providerConfig.type === ProviderTypeEnum.CustomOpenAI
+                            ? 'Required for custom OpenAI providers'
+                            : 'Ollama base URL'
+                        }
+                        value={providerConfig.baseUrl || ''}
+                        onChange={e => handleApiKeyChange(providerId, providerConfig.apiKey || '', e.target.value)}
+                        className="flex-1 p-2 rounded-md bg-gray-50 border border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-200 outline-none"
+                      />
+                    </div>
+                  )}
+
+                  {/* Models input field with tags */}
+                  <div className="flex items-start">
+                    <label htmlFor={`${providerId}-models`} className="w-20 text-sm font-medium text-gray-700 pt-2">
+                      Models
+                    </label>
+                    <div className="flex-1">
+                      <div className="flex flex-wrap items-center gap-2 p-2 bg-gray-50 border border-gray-200 rounded-md min-h-[42px]">
+                        {/* Display existing models as tags */}
+                        {(() => {
+                          // Get models from provider config or default models
+                          const models =
+                            providerConfig.modelNames !== undefined
+                              ? providerConfig.modelNames
+                              : llmProviderModelNames[providerId as keyof typeof llmProviderModelNames] || [];
+
+                          return models.map(model => (
+                            <div
+                              key={model}
+                              className="flex items-center bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-sm">
+                              <span>{model}</span>
+                              <button
+                                type="button"
+                                onClick={() => removeModel(providerId, model)}
+                                className="ml-1 text-blue-600 hover:text-blue-800 font-bold"
+                                aria-label={`Remove ${model}`}>
+                                ×
+                              </button>
+                            </div>
+                          ));
+                        })()}
+
+                        {/* Input for new models */}
+                        <input
+                          id={`${providerId}-models`}
+                          type="text"
+                          placeholder=""
+                          value={newModelInputs[providerId] || ''}
+                          onChange={e => handleModelsChange(providerId, e.target.value)}
+                          onKeyDown={e => handleKeyDown(e, providerId)}
+                          className="flex-1 min-w-[150px] outline-none bg-transparent border-none p-1"
+                        />
+                      </div>
+                      <p className="text-xs text-gray-500 mt-1">Type and Press Enter or Space to add a model</p>
+                    </div>
+                  </div>
+                </div>
+
+                {/* Add divider except for the last item */}
+                {Object.keys(providers).indexOf(providerId) < Object.keys(providers).length - 1 && (
+                  <div className="border-t border-gray-200 mt-4" />
+                )}
+              </div>
+            ))
+          )}
+
+          {/* Add Provider button and dropdown */}
+          <div className="pt-4 relative provider-selector-container">
+            <Button
+              variant="secondary"
+              onClick={() => setIsProviderSelectorOpen(prev => !prev)}
+              className="w-full flex items-center justify-center">
+              <span className="mr-2">+</span> Add Provider
+            </Button>
+
+            {isProviderSelectorOpen && (
+              <div className="absolute z-10 mt-2 w-full bg-white rounded-md shadow-lg border border-gray-200 overflow-hidden">
+                <div className="py-1">
+                  {/* Check if all default providers are already added */}
+                  {(providersFromStorage.has(OPENAI_PROVIDER) || modifiedProviders.has(OPENAI_PROVIDER)) &&
+                    (providersFromStorage.has(ANTHROPIC_PROVIDER) || modifiedProviders.has(ANTHROPIC_PROVIDER)) &&
+                    (providersFromStorage.has(GEMINI_PROVIDER) || modifiedProviders.has(GEMINI_PROVIDER)) &&
+                    (providersFromStorage.has(OLLAMA_PROVIDER) || modifiedProviders.has(OLLAMA_PROVIDER)) && (
+                      <div className="px-4 py-2 text-sm text-gray-500">
+                        All default providers already added. You can still add a custom provider.
+                      </div>
+                    )}
+
+                  {!providersFromStorage.has(OPENAI_PROVIDER) && !modifiedProviders.has(OPENAI_PROVIDER) && (
+                    <button
+                      type="button"
+                      className="w-full text-left px-4 py-3 text-sm text-gray-700 hover:bg-blue-50 transition-colors duration-150 flex items-center"
+                      onClick={() => handleProviderSelection(OPENAI_PROVIDER)}>
+                      <span className="font-medium">OpenAI</span>
+                    </button>
+                  )}
+
+                  {!providersFromStorage.has(ANTHROPIC_PROVIDER) && !modifiedProviders.has(ANTHROPIC_PROVIDER) && (
+                    <button
+                      type="button"
+                      className="w-full text-left px-4 py-3 text-sm text-gray-700 hover:bg-blue-50 transition-colors duration-150 flex items-center"
+                      onClick={() => handleProviderSelection(ANTHROPIC_PROVIDER)}>
+                      <span className="font-medium">Anthropic</span>
+                    </button>
+                  )}
+
+                  {!providersFromStorage.has(GEMINI_PROVIDER) && !modifiedProviders.has(GEMINI_PROVIDER) && (
+                    <button
+                      type="button"
+                      className="w-full text-left px-4 py-3 text-sm text-gray-700 hover:bg-blue-50 transition-colors duration-150 flex items-center"
+                      onClick={() => handleProviderSelection(GEMINI_PROVIDER)}>
+                      <span className="font-medium">Gemini</span>
+                    </button>
+                  )}
+
+                  {!providersFromStorage.has(OLLAMA_PROVIDER) && !modifiedProviders.has(OLLAMA_PROVIDER) && (
+                    <button
+                      type="button"
+                      className="w-full text-left px-4 py-3 text-sm text-gray-700 hover:bg-blue-50 transition-colors duration-150 flex items-center"
+                      onClick={() => handleProviderSelection(OLLAMA_PROVIDER)}>
+                      <span className="font-medium">Ollama</span>
+                    </button>
+                  )}
+
+                  <button
+                    type="button"
+                    className="w-full text-left px-4 py-3 text-sm text-gray-700 hover:bg-blue-50 transition-colors duration-150 flex items-center"
+                    onClick={() => handleProviderSelection('custom')}>
+                    <span className="font-medium">Custom OpenAI-compatible</span>
+                  </button>
+                </div>
+              </div>
+            )}
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>

+ 29 - 0
pnpm-lock.yaml

@@ -120,6 +120,9 @@ importers:
       '@langchain/google-genai':
       '@langchain/google-genai':
         specifier: 0.1.10
         specifier: 0.1.10
         version: 0.1.10(@langchain/core@0.3.37(openai@4.82.0(ws@8.18.0)(zod@3.24.1)))(zod@3.24.1)
         version: 0.1.10(@langchain/core@0.3.37(openai@4.82.0(ws@8.18.0)(zod@3.24.1)))(zod@3.24.1)
+      '@langchain/ollama':
+        specifier: ^0.2.0
+        version: 0.2.0(@langchain/core@0.3.37(openai@4.82.0(ws@8.18.0)(zod@3.24.1)))
       '@langchain/openai':
       '@langchain/openai':
         specifier: ^0.4.2
         specifier: ^0.4.2
         version: 0.4.2(@langchain/core@0.3.37(openai@4.82.0(ws@8.18.0)(zod@3.24.1)))(ws@8.18.0)
         version: 0.4.2(@langchain/core@0.3.37(openai@4.82.0(ws@8.18.0)(zod@3.24.1)))(ws@8.18.0)
@@ -750,6 +753,12 @@ packages:
     peerDependencies:
     peerDependencies:
       '@langchain/core': '>=0.3.17 <0.4.0'
       '@langchain/core': '>=0.3.17 <0.4.0'
 
 
+  '@langchain/ollama@0.2.0':
+    resolution: {integrity: sha512-jLlYFqt+nbhaJKLakk7lRTWHZJ7wHeJLM6yuv4jToQ8zPzpL//372+MjggDoW0mnw8ofysg1T2C6mEJspKJtiA==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@langchain/core': '>=0.2.21 <0.4.0'
+
   '@langchain/openai@0.4.2':
   '@langchain/openai@0.4.2':
     resolution: {integrity: sha512-Cuj7qbVcycALTP0aqZuPpEc7As8cwiGaU21MhXRyZFs+dnWxKYxZ1Q1z4kcx6cYkq/I+CNwwmk+sP+YruU73Aw==}
     resolution: {integrity: sha512-Cuj7qbVcycALTP0aqZuPpEc7As8cwiGaU21MhXRyZFs+dnWxKYxZ1Q1z4kcx6cYkq/I+CNwwmk+sP+YruU73Aw==}
     engines: {node: '>=18'}
     engines: {node: '>=18'}
@@ -2490,6 +2499,9 @@ packages:
     resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==}
     resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==}
     engines: {node: '>= 0.4'}
     engines: {node: '>= 0.4'}
 
 
+  ollama@0.5.14:
+    resolution: {integrity: sha512-pvOuEYa2WkkAumxzJP0RdEYHkbZ64AYyyUszXVX7ruLvk5L+EiO2G71da2GqEQ4IAk4j6eLoUbGk5arzFT1wJA==}
+
   once@1.4.0:
   once@1.4.0:
     resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
     resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
 
 
@@ -3295,6 +3307,9 @@ packages:
       webpack-cli:
       webpack-cli:
         optional: true
         optional: true
 
 
+  whatwg-fetch@3.6.20:
+    resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
+
   whatwg-url@5.0.0:
   whatwg-url@5.0.0:
     resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
     resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
 
 
@@ -3659,6 +3674,14 @@ snapshots:
     transitivePeerDependencies:
     transitivePeerDependencies:
       - zod
       - zod
 
 
+  '@langchain/ollama@0.2.0(@langchain/core@0.3.37(openai@4.82.0(ws@8.18.0)(zod@3.24.1)))':
+    dependencies:
+      '@langchain/core': 0.3.37(openai@4.82.0(ws@8.18.0)(zod@3.24.1))
+      ollama: 0.5.14
+      uuid: 10.0.0
+      zod: 3.24.1
+      zod-to-json-schema: 3.24.1(zod@3.24.1)
+
   '@langchain/openai@0.4.2(@langchain/core@0.3.37(openai@4.82.0(ws@8.18.0)(zod@3.24.1)))(ws@8.18.0)':
   '@langchain/openai@0.4.2(@langchain/core@0.3.37(openai@4.82.0(ws@8.18.0)(zod@3.24.1)))(ws@8.18.0)':
     dependencies:
     dependencies:
       '@langchain/core': 0.3.37(openai@4.82.0(ws@8.18.0)(zod@3.24.1))
       '@langchain/core': 0.3.37(openai@4.82.0(ws@8.18.0)(zod@3.24.1))
@@ -5601,6 +5624,10 @@ snapshots:
       define-properties: 1.2.1
       define-properties: 1.2.1
       es-object-atoms: 1.0.0
       es-object-atoms: 1.0.0
 
 
+  ollama@0.5.14:
+    dependencies:
+      whatwg-fetch: 3.6.20
+
   once@1.4.0:
   once@1.4.0:
     dependencies:
     dependencies:
       wrappy: 1.0.2
       wrappy: 1.0.2
@@ -6488,6 +6515,8 @@ snapshots:
       - esbuild
       - esbuild
       - uglify-js
       - uglify-js
 
 
+  whatwg-fetch@3.6.20: {}
+
   whatwg-url@5.0.0:
   whatwg-url@5.0.0:
     dependencies:
     dependencies:
       tr46: 0.0.3
       tr46: 0.0.3