diff --git a/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx
index eb692afd..df9e5afb 100644
--- a/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx
+++ b/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx
@@ -1,6 +1,6 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai"
-import { ChevronDown, MoreHorizontal, Plus, Trash2 } from "lucide-react"
-import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { ChevronDown, Edit2, MoreHorizontal, Plus, Trash2, Check, Settings } from "lucide-react"
+import { useCallback, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import {
agentsLoginModalOpenAtom,
@@ -9,11 +9,13 @@ import {
codexLoginModalOpenAtom,
codexOnboardingAuthMethodAtom,
codexOnboardingCompletedAtom,
- customClaudeConfigAtom,
hiddenModelsAtom,
+ modelProfilesAtom,
+ activeProfileIdAtom,
normalizeCodexApiKey,
openaiApiKeyAtom,
- type CustomClaudeConfig,
+ type ModelProfile,
+ type CustomModelConfig,
} from "../../../lib/atoms"
import { ClaudeCodeIcon, CodexIcon, SearchIcon } from "../../ui/icons"
import { CLAUDE_MODELS, CODEX_MODELS } from "../../../features/agents/lib/models"
@@ -52,10 +54,262 @@ function useIsNarrowScreen(): boolean {
return isNarrow
}
-const EMPTY_CONFIG: CustomClaudeConfig = {
- model: "",
- token: "",
- baseUrl: "",
+// Helper to generate unique IDs
+const generateProfileId = () => `custom-${crypto.randomUUID()}`
+const generateModelId = () => `model-${crypto.randomUUID()}`
+
+// Custom Model Profile Row Component - shows profile with models
+function CustomProfileRow({
+ profile,
+ onEdit,
+ onRemove,
+}: {
+ profile: ModelProfile
+ onEdit: () => void
+ onRemove: () => void
+}) {
+ const modelNames = profile.models.map(m => m.name).join(', ')
+ return (
+
+
+
{profile.name || 'Custom Model'}
+
+ {profile.models.length} model{profile.models.length !== 1 ? 's' : ''}: {modelNames || 'No models set'}
+
+
+
+
+
+
+
+
+
+
+
+ Edit
+
+
+
+ Remove
+
+
+
+
+
+ )
+}
+
+// Add/Edit Custom Profile Dialog with multiple models support
+function CustomProfileDialog({
+ open,
+ onOpenChange,
+ profile,
+ onSave,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ profile: ModelProfile | null // null = new profile
+ onSave: (profile: ModelProfile) => void
+}) {
+ const [name, setName] = useState('')
+ const [token, setToken] = useState('')
+ const [baseUrl, setBaseUrl] = useState('')
+ const [models, setModels] = useState([])
+
+ useEffect(() => {
+ if (profile) {
+ setName(profile.name)
+ setToken(profile.token)
+ setBaseUrl(profile.baseUrl)
+ setModels(profile.models.length > 0 ? profile.models : [])
+ } else {
+ setName('')
+ setToken('')
+ setBaseUrl('')
+ setModels([])
+ }
+ }, [profile, open])
+
+ const handleAddModel = () => {
+ setModels(prev => [...prev, {
+ id: generateModelId(),
+ name: '',
+ modelId: '',
+ }])
+ }
+
+ const handleRemoveModel = (modelId: string) => {
+ setModels(prev => prev.filter(m => m.id !== modelId))
+ }
+
+ const handleUpdateModel = (modelId: string, field: 'name' | 'modelId', value: string) => {
+ setModels(prev => prev.map(m =>
+ m.id === modelId ? { ...m, [field]: value } : m
+ ))
+ }
+
+ const handleSave = () => {
+ const trimmedName = name.trim()
+ const trimmedToken = token.trim()
+ const trimmedBaseUrl = baseUrl.trim()
+
+ if (!trimmedName) {
+ toast.error('Profile name is required')
+ return
+ }
+ if (!trimmedToken) {
+ toast.error('API token is required')
+ return
+ }
+ if (!trimmedBaseUrl) {
+ toast.error('Base URL is required')
+ return
+ }
+ if (models.length === 0) {
+ toast.error('At least one model is required')
+ return
+ }
+
+ // Validate all models have required fields
+ const validModels = models.filter(m => m.name.trim() && m.modelId.trim())
+ if (validModels.length === 0) {
+ toast.error('At least one complete model is required')
+ return
+ }
+
+ onSave({
+ id: profile?.id || generateProfileId(),
+ name: trimmedName,
+ token: trimmedToken,
+ baseUrl: trimmedBaseUrl,
+ models: validModels.map(m => ({
+ ...m,
+ name: m.name.trim(),
+ modelId: m.modelId.trim(),
+ })),
+ })
+ onOpenChange(false)
+ }
+
+ return (
+ onOpenChange(false)}
+ >
+
e.stopPropagation()}
+ >
+
+
+ {profile ? 'Edit Custom Model Profile' : 'Add Custom Model Profile'}
+
+
+
+
+
+ setName(e.target.value)}
+ placeholder="e.g., OpenRouter, Together AI"
+ />
+
+
+
+ setBaseUrl(e.target.value)}
+ placeholder="https://openrouter.ai/api/v1"
+ />
+
+
+
+ setToken(e.target.value)}
+ placeholder="sk-..."
+ />
+
+
+ {/* Models Section */}
+
+
+
+
+
+
+ {models.length === 0 ? (
+
+ No models added. Click "Add Model" to add one.
+
+ ) : (
+
+ {models.map((model, index) => (
+
+
+ Model {index + 1}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+ )
}
// Account row component
@@ -261,10 +515,6 @@ function AnthropicAccountsSection() {
}
export function AgentsModelsTab() {
- const [storedConfig, setStoredConfig] = useAtom(customClaudeConfigAtom)
- const [model, setModel] = useState(storedConfig.model)
- const [baseUrl, setBaseUrl] = useState(storedConfig.baseUrl)
- const [token, setToken] = useState(storedConfig.token)
const setClaudeLoginModalConfig = useSetAtom(claudeLoginModalConfigAtom)
const setClaudeLoginModalOpen = useSetAtom(agentsLoginModalOpenAtom)
const setCodexLoginModalOpen = useSetAtom(codexLoginModalOpenAtom)
@@ -275,6 +525,54 @@ export function AgentsModelsTab() {
const { data: codexIntegration, isLoading: isCodexLoading } =
trpc.codex.getIntegration.useQuery()
+ // Custom model profiles state
+ const [modelProfiles, setModelProfiles] = useAtom(modelProfilesAtom)
+ const [activeProfileId, setActiveProfileId] = useAtom(activeProfileIdAtom)
+ const [editingProfile, setEditingProfile] = useState(null)
+ const [isProfileDialogOpen, setIsProfileDialogOpen] = useState(false)
+
+ // Filter out offline profile - only show custom profiles
+ const customProfiles = useMemo(() =>
+ modelProfiles.filter(p => !p.isOffline),
+ [modelProfiles]
+ )
+
+ // Custom profile handlers
+ const handleAddProfile = useCallback(() => {
+ setEditingProfile(null)
+ setIsProfileDialogOpen(true)
+ }, [])
+
+ const handleEditProfile = useCallback((profile: ModelProfile) => {
+ setEditingProfile(profile)
+ setIsProfileDialogOpen(true)
+ }, [])
+
+ const handleSaveProfile = useCallback((profile: ModelProfile) => {
+ setModelProfiles(prev => {
+ const existing = prev.find(p => p.id === profile.id)
+ if (existing) {
+ return prev.map(p => p.id === profile.id ? profile : p)
+ }
+ return [...prev, profile]
+ })
+ toast.success(editingProfile ? 'Profile updated' : 'Profile added')
+ }, [editingProfile, setModelProfiles])
+
+ const handleRemoveProfile = useCallback((profileId: string) => {
+ const profile = modelProfiles.find(p => p.id === profileId)
+ const confirmed = window.confirm(
+ `Are you sure you want to remove "${profile?.name || 'this profile'}"?`
+ )
+ if (confirmed) {
+ setModelProfiles(prev => prev.filter(p => p.id !== profileId))
+ if (activeProfileId === profileId) {
+ setActiveProfileId(null)
+ }
+ toast.success('Profile removed')
+ }
+ }, [modelProfiles, activeProfileId, setModelProfiles, setActiveProfileId])
+
// OpenAI API key state
const [storedCodexApiKey, setStoredCodexApiKey] = useAtom(codexApiKeyAtom)
const [codexApiKey, setCodexApiKey] = useState(storedCodexApiKey)
@@ -287,12 +585,6 @@ export function AgentsModelsTab() {
const codexLogoutMutation = trpc.codex.logout.useMutation()
const trpcUtils = trpc.useUtils()
- useEffect(() => {
- setModel(storedConfig.model)
- setBaseUrl(storedConfig.baseUrl)
- setToken(storedConfig.token)
- }, [storedConfig.model, storedConfig.baseUrl, storedConfig.token])
-
useEffect(() => {
setOpenaiKey(storedOpenAIKey)
}, [storedOpenAIKey])
@@ -301,48 +593,6 @@ export function AgentsModelsTab() {
setCodexApiKey(storedCodexApiKey)
}, [storedCodexApiKey])
- const savedConfigRef = useRef(storedConfig)
-
- const handleBlurSave = useCallback(() => {
- const trimmedModel = model.trim()
- const trimmedBaseUrl = baseUrl.trim()
- const trimmedToken = token.trim()
-
- // Only save if all fields are filled
- if (trimmedModel && trimmedBaseUrl && trimmedToken) {
- const next: CustomClaudeConfig = {
- model: trimmedModel,
- token: trimmedToken,
- baseUrl: trimmedBaseUrl,
- }
- if (
- next.model !== savedConfigRef.current.model ||
- next.token !== savedConfigRef.current.token ||
- next.baseUrl !== savedConfigRef.current.baseUrl
- ) {
- setStoredConfig(next)
- savedConfigRef.current = next
- }
- } else if (!trimmedModel && !trimmedBaseUrl && !trimmedToken) {
- // All cleared — reset
- if (savedConfigRef.current.model || savedConfigRef.current.token || savedConfigRef.current.baseUrl) {
- setStoredConfig(EMPTY_CONFIG)
- savedConfigRef.current = EMPTY_CONFIG
- }
- }
- }, [model, baseUrl, token, setStoredConfig])
-
- const handleReset = () => {
- setStoredConfig(EMPTY_CONFIG)
- savedConfigRef.current = EMPTY_CONFIG
- setModel("")
- setBaseUrl("")
- setToken("")
- toast.success("Model settings reset")
- }
-
- const canReset = Boolean(model.trim() || baseUrl.trim() || token.trim())
-
const handleClaudeCodeSetup = () => {
setClaudeLoginModalConfig({
hideCustomModelSettingsLink: true,
@@ -502,6 +752,17 @@ export function AgentsModelsTab() {
return allModels.filter((m) => m.name.toLowerCase().includes(q))
}, [allModels, modelSearch])
+ // Filter custom models by search
+ const filteredCustomModels = useMemo(() => {
+ if (!modelSearch.trim()) return customProfiles
+ const q = modelSearch.toLowerCase().trim()
+ return customProfiles.filter((profile) => {
+ // Match profile name or any model name within the profile
+ if (profile.name.toLowerCase().includes(q)) return true
+ return profile.models.some(m => m.name.toLowerCase().includes(q))
+ })
+ }, [customProfiles, modelSearch])
+
const [isApiKeysOpen, setIsApiKeysOpen] = useState(false)
return (
@@ -529,7 +790,7 @@ export function AgentsModelsTab() {
- {/* Model list */}
+ {/* Standard Model list */}
{filteredModels.map((m) => {
const isEnabled = !hiddenModels.includes(m.id)
@@ -553,12 +814,51 @@ export function AgentsModelsTab() {
)
})}
- {filteredModels.length === 0 && (
+ {filteredModels.length === 0 && modelSearch.trim() && (
No models found
)}
+
+ {/* Custom Models sub-section */}
+ {filteredCustomModels.length > 0 && (
+ <>
+
+
+ Custom Models
+
+
+
+ {filteredCustomModels.map((profile) =>
+ profile.models.map((model) => {
+ const customModelId = `custom-${profile.id}-${model.id}`
+ const isEnabled = !hiddenModels.includes(customModelId)
+ return (
+
+
+
+ {model.name}
+
+ {profile.name}
+
+
+
+
+
toggleModelVisibility(customModelId)}
+ />
+
+ )
+ })
+ )}
+
+ >
+ )}
@@ -734,74 +1034,56 @@ export function AgentsModelsTab() {
- {/* Override Model */}
+ {/* Custom Model Profiles */}
-
- Override Model
-
- {canReset && (
-
- )}
+
+
+ Custom Models
+
+
+ Add custom API endpoints and models
+
+
+
-
-
-
-
-
- Model identifier to use for requests
-
-
-
-
setModel(e.target.value)}
- onBlur={handleBlurSave}
- className="w-full"
- placeholder="claude-3-7-sonnet-20250219"
+
+ {customProfiles.length > 0 ? (
+
+ {customProfiles.map((profile) => (
+ handleEditProfile(profile)}
+ onRemove={() => handleRemoveProfile(profile.id)}
/>
-
+ ))}
-
-
-
-
-
- ANTHROPIC_AUTH_TOKEN env
-
-
-
- setToken(e.target.value)}
- onBlur={handleBlurSave}
- className="w-full"
- placeholder="sk-ant-..."
- />
-
+ ) : (
+
+
+ No custom models configured
+
+
+ Add a custom model to use alternative API endpoints
+
+ )}
-
-
-
-
- ANTHROPIC_BASE_URL env
-
-
-
- setBaseUrl(e.target.value)}
- onBlur={handleBlurSave}
- className="w-full"
- placeholder="https://api.anthropic.com"
- />
-
-
-
+ {/* Profile Edit Dialog */}
+
diff --git a/src/renderer/features/agents/components/agent-model-selector.tsx b/src/renderer/features/agents/components/agent-model-selector.tsx
index 56cc4333..4963a6e9 100644
--- a/src/renderer/features/agents/components/agent-model-selector.tsx
+++ b/src/renderer/features/agents/components/agent-model-selector.tsx
@@ -1,6 +1,6 @@
"use client"
-import { Brain, ChevronRight, Zap } from "lucide-react"
+import { Brain, ChevronRight, Settings, Zap } from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { AnimatePresence, motion } from "motion/react"
@@ -25,6 +25,7 @@ import {
import { cn } from "../../../lib/utils"
import type { CodexThinkingLevel } from "../lib/models"
import { formatCodexThinkingLabel } from "../lib/models"
+import type { ModelProfile, CustomModelConfig } from "../../../lib/atoms"
const CROSS_PROVIDER_DIALOG_DISMISSED_KEY = "agent-model-selector:skip-cross-provider-dialog"
@@ -59,6 +60,7 @@ interface AgentModelSelectorProps {
contentClassName?: string
onOpenModelsSettings?: () => void
onContinueWithProvider?: (provider: AgentProviderId) => void
+ hiddenModelIds?: string[] // IDs of hidden models to filter out
claude: {
models: ClaudeModelOption[]
selectedModelId?: string
@@ -72,6 +74,12 @@ interface AgentModelSelectorProps {
isConnected: boolean
thinkingEnabled: boolean
onThinkingChange: (enabled: boolean) => void
+ // Custom profiles support - multiple models per profile
+ customProfiles: ModelProfile[]
+ selectedProfileId: string | null
+ selectedCustomModelId: string | null
+ onSelectCustomModel: (profileId: string, modelId: string) => void
+ onClearCustomModel: () => void
}
codex: {
models: CodexModelOption[]
@@ -87,7 +95,7 @@ type FlatModelItem =
| { type: "claude"; model: ClaudeModelOption }
| { type: "codex"; model: CodexModelOption }
| { type: "ollama"; modelName: string; isRecommended: boolean }
- | { type: "custom" }
+ | { type: "customModel"; profile: ModelProfile; model: CustomModelConfig }
function CodexThinkingSubMenu({
thinkings,
@@ -102,7 +110,7 @@ function CodexThinkingSubMenu({
const subMenuRef = useRef
(null)
const [showSub, setShowSub] = useState(false)
const [subPos, setSubPos] = useState({ top: 0, left: 0 })
- const closeTimeout = useRef>()
+ const closeTimeout = useRef | undefined>(undefined)
const scheduleClose = useCallback(() => {
closeTimeout.current = setTimeout(() => setShowSub(false), 150)
@@ -321,6 +329,7 @@ export function AgentModelSelector({
contentClassName,
onOpenModelsSettings,
onContinueWithProvider,
+ hiddenModelIds = [],
claude,
codex,
}: AgentModelSelectorProps) {
@@ -343,20 +352,35 @@ export function AgentModelSelector({
isRecommended: m === claude.recommendedOllamaModel,
})
}
- } else if (claude.hasCustomModelConfig) {
- items.push({ type: "custom" })
} else {
+ // Add standard Claude models (filter by hidden)
for (const m of claude.models) {
- items.push({ type: "claude", model: m })
+ if (!hiddenModelIds.includes(m.id)) {
+ items.push({ type: "claude", model: m })
+ }
+ }
+ // Add custom models from profiles (filter by hidden)
+ for (const profile of claude.customProfiles) {
+ if (!profile.isOffline) {
+ for (const model of profile.models) {
+ const customModelId = `custom-${profile.id}-${model.id}`
+ if (!hiddenModelIds.includes(customModelId)) {
+ items.push({ type: "customModel", profile, model })
+ }
+ }
+ }
}
}
+ // Add Codex models (filter by hidden)
for (const m of codex.models) {
- items.push({ type: "codex", model: m })
+ if (!hiddenModelIds.includes(m.id)) {
+ items.push({ type: "codex", model: m })
+ }
}
return items
- }, [claude, codex])
+ }, [claude, codex, hiddenModelIds])
// Filter by search
const filteredModels = useMemo(() => {
@@ -374,8 +398,10 @@ export function AgentModelSelector({
return item.model.name.toLowerCase().includes(q)
case "ollama":
return item.modelName.toLowerCase().includes(q)
- case "custom":
- return "custom model".includes(q)
+ case "customModel":
+ return item.profile.name.toLowerCase().includes(q) ||
+ item.model.name.toLowerCase().includes(q) ||
+ item.model.modelId.toLowerCase().includes(q)
}
})
}, [allModels, search])
@@ -404,13 +430,15 @@ export function AgentModelSelector({
const isItemSelected = (item: FlatModelItem): boolean => {
switch (item.type) {
case "claude":
- return selectedAgentId === "claude-code" && claude.selectedModelId === item.model.id
+ return selectedAgentId === "claude-code" && claude.selectedModelId === item.model.id && !claude.selectedProfileId
case "codex":
return selectedAgentId === "codex" && codex.selectedModelId === item.model.id
case "ollama":
return selectedAgentId === "claude-code" && claude.selectedOllamaModel === item.modelName
- case "custom":
- return selectedAgentId === "claude-code"
+ case "customModel":
+ return selectedAgentId === "claude-code" &&
+ claude.selectedProfileId === item.profile.id &&
+ claude.selectedCustomModelId === item.model.id
}
}
@@ -474,6 +502,7 @@ export function AgentModelSelector({
if (!canSelectProvider("claude-code")) return
onSelectedAgentIdChange("claude-code")
claude.onSelectModel(item.model.id)
+ claude.onClearCustomModel()
break
case "codex":
if (!canSelectProvider("codex")) return
@@ -484,10 +513,12 @@ export function AgentModelSelector({
if (!canSelectProvider("claude-code")) return
onSelectedAgentIdChange("claude-code")
claude.onSelectOllamaModel(item.modelName)
+ claude.onClearCustomModel()
break
- case "custom":
+ case "customModel":
if (!canSelectProvider("claude-code")) return
onSelectedAgentIdChange("claude-code")
+ claude.onSelectCustomModel(item.profile.id, item.model.id)
break
}
handleOpenChange(false)
@@ -501,8 +532,8 @@ export function AgentModelSelector({
return
case "ollama":
return
- case "custom":
- return
+ case "customModel":
+ return
}
}
@@ -514,9 +545,17 @@ export function AgentModelSelector({
return item.model.name
case "ollama":
return item.modelName + (item.isRecommended ? " (recommended)" : "")
- case "custom":
- return "Custom Model"
+ case "customModel":
+ return item.model.name
+ }
+ }
+
+ // Get sub-label for items that need it (custom models show profile name)
+ const getItemSubLabel = (item: FlatModelItem): string | null => {
+ if (item.type === "customModel") {
+ return item.profile.name
}
+ return null
}
const getItemKey = (item: FlatModelItem): string => {
@@ -527,8 +566,8 @@ export function AgentModelSelector({
return `codex-${item.model.id}`
case "ollama":
return `ollama-${item.modelName}`
- case "custom":
- return "custom"
+ case "customModel":
+ return `custom-${item.profile.id}-${item.model.id}`
}
}
@@ -561,7 +600,7 @@ export function AgentModelSelector({
{/* Claude thinking toggle */}
{selectedAgentId === "claude-code" &&
!claude.isOffline &&
- !claude.hasCustomModelConfig && (
+ !claude.selectedProfileId && (
<>
{getItemIcon(item)}
-
{getItemLabel(item)}
+
+ {getItemLabel(item)}
+ {subLabel && (
+
+ {subLabel}
+
+ )}
+
{crossProvider && (
New chat
)}
diff --git a/src/renderer/features/agents/lib/ipc-chat-transport.ts b/src/renderer/features/agents/lib/ipc-chat-transport.ts
index c014f151..728674c7 100644
--- a/src/renderer/features/agents/lib/ipc-chat-transport.ts
+++ b/src/renderer/features/agents/lib/ipc-chat-transport.ts
@@ -5,12 +5,10 @@ import {
claudeLoginModalConfigAtom,
agentsLoginModalOpenAtom,
autoOfflineModeAtom,
- type CustomClaudeConfig,
- customClaudeConfigAtom,
+ activeConfigAtom,
enableTasksAtom,
extendedThinkingEnabledAtom,
historyEnabledAtom,
- normalizeCustomClaudeConfig,
selectedOllamaModelAtom,
sessionInfoAtom,
showOfflineModeFeaturesAtom,
@@ -173,10 +171,8 @@ export class IPCChatTransport implements ChatTransport
{
const selectedModelId = appStore.get(subChatModelIdAtomFamily(this.config.subChatId))
const modelString = MODEL_ID_MAP[selectedModelId] || MODEL_ID_MAP["opus"]
- const storedCustomConfig = appStore.get(
- customClaudeConfigAtom,
- ) as CustomClaudeConfig
- const customConfig = normalizeCustomClaudeConfig(storedCustomConfig)
+ // Use activeConfigAtom which considers both legacy config and new model profiles
+ const customConfig = appStore.get(activeConfigAtom)
// Get selected Ollama model for offline mode
const selectedOllamaModel = appStore.get(selectedOllamaModelAtom)
diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx
index cf85ed17..49ebacdd 100644
--- a/src/renderer/features/agents/main/active-chat.tsx
+++ b/src/renderer/features/agents/main/active-chat.tsx
@@ -64,10 +64,9 @@ import { trackMessageSent } from "../../../lib/analytics"
import { apiFetch } from "../../../lib/api-fetch"
import {
chatSourceModeAtom,
- customClaudeConfigAtom,
+ activeConfigAtom,
defaultAgentModeAtom,
isDesktopAtom, isFullscreenAtom,
- normalizeCustomClaudeConfig,
sessionInfoAtom,
selectedOllamaModelAtom,
soundNotificationsEnabledAtom
@@ -4871,11 +4870,9 @@ export function ChatView({
const isDesktop = useAtomValue(isDesktopAtom)
const isFullscreen = useAtomValue(isFullscreenAtom)
const sidebarOpen = useAtomValue(agentsSidebarOpenAtom)
- const customClaudeConfig = useAtomValue(customClaudeConfigAtom)
+ const customClaudeConfig = useAtomValue(activeConfigAtom)
const selectedOllamaModel = useAtomValue(selectedOllamaModelAtom)
- const normalizedCustomClaudeConfig =
- normalizeCustomClaudeConfig(customClaudeConfig)
- const hasCustomClaudeConfig = Boolean(normalizedCustomClaudeConfig)
+ const hasCustomClaudeConfig = Boolean(customClaudeConfig)
const setLoadingSubChats = useSetAtom(loadingSubChatsAtom)
const unseenChanges = useAtomValue(agentsUnseenChangesAtom)
const setUnseenChanges = useSetAtom(agentsUnseenChangesAtom)
diff --git a/src/renderer/features/agents/main/chat-input-area.tsx b/src/renderer/features/agents/main/chat-input-area.tsx
index 30ad3f25..7614cd2b 100644
--- a/src/renderer/features/agents/main/chat-input-area.tsx
+++ b/src/renderer/features/agents/main/chat-input-area.tsx
@@ -44,11 +44,13 @@ import {
apiKeyOnboardingCompletedAtom,
codexApiKeyAtom,
codexOnboardingCompletedAtom,
- customClaudeConfigAtom,
+ activeConfigAtom,
extendedThinkingEnabledAtom,
hiddenModelsAtom,
+ modelProfilesAtom,
+ activeProfileIdAtom,
+ activeCustomModelIdAtom,
normalizeCodexApiKey,
- normalizeCustomClaudeConfig,
selectedOllamaModelAtom,
showOfflineModeFeaturesAtom,
} from "../../../lib/atoms"
@@ -575,10 +577,23 @@ export const ChatInputArea = memo(function ChatInputArea({
setSelectedSubChatCodexThinking,
])
- const customClaudeConfig = useAtomValue(customClaudeConfigAtom)
- const normalizedCustomClaudeConfig =
- normalizeCustomClaudeConfig(customClaudeConfig)
- const hasCustomClaudeConfig = Boolean(normalizedCustomClaudeConfig)
+ const customClaudeConfig = useAtomValue(activeConfigAtom)
+ const hasCustomClaudeConfig = Boolean(customClaudeConfig)
+
+ // Custom model profiles
+ const [modelProfiles, setModelProfiles] = useAtom(modelProfilesAtom)
+ const [activeProfileId, setActiveProfileId] = useAtom(activeProfileIdAtom)
+ const [activeCustomModelId, setActiveCustomModelId] = useAtom(activeCustomModelIdAtom)
+ const customProfiles = modelProfiles.filter(p => !p.isOffline)
+
+ // Helper to find active custom model
+ const activeCustomModel = useMemo(() => {
+ if (!activeProfileId || !activeCustomModelId) return null
+ const profile = customProfiles.find(p => p.id === activeProfileId)
+ if (!profile) return null
+ return profile.models.find(m => m.id === activeCustomModelId) || null
+ }, [activeProfileId, activeCustomModelId, customProfiles])
+
const isClaudeConnected =
Boolean(claudeCodeIntegration?.isConnected) ||
anthropicOnboardingCompleted ||
@@ -607,8 +622,9 @@ export const ChatInputArea = memo(function ChatInputArea({
return currentOllamaModel || "Ollama"
}
- if (hasCustomClaudeConfig) {
- return "Custom Model"
+ // Show custom model name with profile if selected
+ if (activeProfileId && activeCustomModel) {
+ return activeCustomModel.name
}
if (!selectedModel) {
@@ -622,7 +638,8 @@ export const ChatInputArea = memo(function ChatInputArea({
availableModels.isOffline,
availableModels.hasOllama,
currentOllamaModel,
- hasCustomClaudeConfig,
+ activeProfileId,
+ activeCustomModel,
selectedModel,
])
const canSwitchProvider =
@@ -1560,6 +1577,7 @@ export const ChatInputArea = memo(function ChatInputArea({
setSettingsTab("models")
setSettingsOpen(true)
}}
+ hiddenModelIds={hiddenModels}
claude={{
models: availableModels.models.filter((m) => !hiddenModels.includes(m.id)),
selectedModelId: selectedModel?.id,
@@ -1571,6 +1589,8 @@ export const ChatInputArea = memo(function ChatInputArea({
setSelectedModel(model)
setSelectedSubChatModelId(model.id)
setLastSelectedModelId(model.id)
+ // Clear profile selection when selecting standard model
+ setActiveProfileId(null)
},
hasCustomModelConfig: hasCustomClaudeConfig,
isOffline: availableModels.isOffline && availableModels.hasOllama,
@@ -1581,6 +1601,18 @@ export const ChatInputArea = memo(function ChatInputArea({
isConnected: isClaudeConnected,
thinkingEnabled,
onThinkingChange: setThinkingEnabled,
+ // Custom profiles
+ customProfiles,
+ selectedProfileId: activeProfileId,
+ selectedCustomModelId: activeCustomModelId,
+ onSelectCustomModel: (profileId, modelId) => {
+ setActiveProfileId(profileId)
+ setActiveCustomModelId(modelId)
+ },
+ onClearCustomModel: () => {
+ setActiveProfileId(null)
+ setActiveCustomModelId(null)
+ },
}}
codex={{
models: codexUiModels,
diff --git a/src/renderer/features/agents/main/new-chat-form.tsx b/src/renderer/features/agents/main/new-chat-form.tsx
index 6f0f6138..fb8f77cf 100644
--- a/src/renderer/features/agents/main/new-chat-form.tsx
+++ b/src/renderer/features/agents/main/new-chat-form.tsx
@@ -57,11 +57,13 @@ import {
apiKeyOnboardingCompletedAtom,
codexApiKeyAtom,
codexOnboardingCompletedAtom,
- customClaudeConfigAtom,
+ activeConfigAtom,
extendedThinkingEnabledAtom,
hiddenModelsAtom,
+ modelProfilesAtom,
+ activeProfileIdAtom,
+ activeCustomModelIdAtom,
normalizeCodexApiKey,
- normalizeCustomClaudeConfig,
showOfflineModeFeaturesAtom,
selectedOllamaModelAtom,
customHotkeysAtom,
@@ -236,10 +238,23 @@ export function NewChatForm({
}, [])
const [workMode, setWorkMode] = useAtom(lastSelectedWorkModeAtom)
const debugMode = useAtomValue(agentsDebugModeAtom)
- const customClaudeConfig = useAtomValue(customClaudeConfigAtom)
- const normalizedCustomClaudeConfig =
- normalizeCustomClaudeConfig(customClaudeConfig)
- const hasCustomClaudeConfig = Boolean(normalizedCustomClaudeConfig)
+ const customClaudeConfig = useAtomValue(activeConfigAtom)
+ const hasCustomClaudeConfig = Boolean(customClaudeConfig)
+
+ // Custom model profiles
+ const [modelProfiles, setModelProfiles] = useAtom(modelProfilesAtom)
+ const [activeProfileId, setActiveProfileId] = useAtom(activeProfileIdAtom)
+ const [activeCustomModelId, setActiveCustomModelId] = useAtom(activeCustomModelIdAtom)
+ const customProfiles = modelProfiles.filter(p => !p.isOffline)
+
+ // Helper to find active custom model
+ const activeCustomModel = useMemo(() => {
+ if (!activeProfileId || !activeCustomModelId) return null
+ const profile = customProfiles.find(p => p.id === activeProfileId)
+ if (!profile) return null
+ return profile.models.find(m => m.id === activeCustomModelId) || null
+ }, [activeProfileId, activeCustomModelId, customProfiles])
+
// Connection status for providers
const anthropicOnboardingCompleted = useAtomValue(anthropicOnboardingCompletedAtom)
const apiKeyOnboardingCompleted = useAtomValue(apiKeyOnboardingCompletedAtom)
@@ -422,8 +437,9 @@ export function NewChatForm({
return currentOllamaModel || "Ollama"
}
- if (hasCustomClaudeConfig) {
- return "Custom Model"
+ // Show custom model name with profile if selected
+ if (activeProfileId && activeCustomModel) {
+ return activeCustomModel.name
}
if (!selectedModel) {
@@ -437,7 +453,8 @@ export function NewChatForm({
availableModels.isOffline,
availableModels.hasOllama,
currentOllamaModel,
- hasCustomClaudeConfig,
+ activeProfileId,
+ activeCustomModel,
selectedModel,
])
const [repoPopoverOpen, setRepoPopoverOpen] = useState(false)
@@ -1887,6 +1904,7 @@ export function NewChatForm({
setSettingsActiveTab("models")
setSettingsDialogOpen(true)
}}
+ hiddenModelIds={hiddenModels}
claude={{
models: availableModels.models.filter((m) => !hiddenModels.includes(m.id)),
selectedModelId: selectedModel?.id,
@@ -1897,6 +1915,8 @@ export function NewChatForm({
if (!model) return
setSelectedModel(model)
setLastSelectedModelId(model.id)
+ // Clear profile selection when selecting standard model
+ setActiveProfileId(null)
},
hasCustomModelConfig: hasCustomClaudeConfig,
isOffline: availableModels.isOffline && availableModels.hasOllama,
@@ -1907,6 +1927,18 @@ export function NewChatForm({
isConnected: isClaudeConnected,
thinkingEnabled,
onThinkingChange: setThinkingEnabled,
+ // Custom profiles
+ customProfiles,
+ selectedProfileId: activeProfileId,
+ selectedCustomModelId: activeCustomModelId,
+ onSelectCustomModel: (profileId, modelId) => {
+ setActiveProfileId(profileId)
+ setActiveCustomModelId(modelId)
+ },
+ onClearCustomModel: () => {
+ setActiveProfileId(null)
+ setActiveCustomModelId(null)
+ },
}}
codex={{
models: codexUiModels,
diff --git a/src/renderer/lib/atoms/index.ts b/src/renderer/lib/atoms/index.ts
index e2899995..b74b07e6 100644
--- a/src/renderer/lib/atoms/index.ts
+++ b/src/renderer/lib/atoms/index.ts
@@ -210,12 +210,21 @@ export type CustomClaudeConfig = {
baseUrl: string
}
-// Model profile system - support multiple configs
+// Custom model configuration for a single model within a profile
+export type CustomModelConfig = {
+ id: string
+ name: string // Display name (e.g., "Claude 3 Opus")
+ modelId: string // API model ID (e.g., "anthropic/claude-3-opus")
+}
+
+// Model profile system - support multiple models per profile
export type ModelProfile = {
id: string
- name: string
- config: CustomClaudeConfig
- isOffline?: boolean // Mark as offline/Ollama profile
+ name: string // Profile name (e.g., "OpenRouter")
+ baseUrl: string // API endpoint
+ token: string // API key
+ models: CustomModelConfig[] // Multiple models
+ isOffline?: boolean // Mark as offline/Ollama profile
}
// Selected Ollama model for offline mode
@@ -226,28 +235,35 @@ export const selectedOllamaModelAtom = atomWithStorage(
{ getOnInit: true },
)
+// Helper to generate unique IDs
+const generateId = () => crypto.randomUUID()
+
// Helper to get offline profile with selected model
export const getOfflineProfile = (modelName?: string | null): ModelProfile => ({
id: 'offline-ollama',
name: 'Offline (Ollama)',
+ baseUrl: 'http://localhost:11434',
+ token: 'ollama',
+ models: [{
+ id: 'ollama-default',
+ name: modelName || 'Qwen 2.5 Coder',
+ modelId: modelName || 'qwen2.5-coder:7b',
+ }],
isOffline: true,
- config: {
- model: modelName || 'qwen2.5-coder:7b',
- token: 'ollama',
- baseUrl: 'http://localhost:11434',
- },
})
// Predefined offline profile for Ollama (legacy, uses default model)
export const OFFLINE_PROFILE: ModelProfile = {
id: 'offline-ollama',
name: 'Offline (Ollama)',
+ baseUrl: 'http://localhost:11434',
+ token: 'ollama',
+ models: [{
+ id: 'ollama-default',
+ name: 'Qwen 2.5 Coder',
+ modelId: 'qwen2.5-coder:7b',
+ }],
isOffline: true,
- config: {
- model: 'qwen2.5-coder:7b',
- token: 'ollama',
- baseUrl: 'http://localhost:11434',
- },
}
// Legacy single config (deprecated, kept for backwards compatibility)
@@ -270,11 +286,60 @@ export const openaiApiKeyAtom = atomWithStorage(
{ getOnInit: true },
)
-// New: Model profiles storage
+// Helper to migrate old profile structure to new structure
+function migrateProfile(profile: any): ModelProfile {
+ // If already has models array, it's the new structure
+ if (profile.models && Array.isArray(profile.models)) {
+ return profile as ModelProfile
+ }
+
+ // If has old config structure, migrate to new structure
+ if (profile.config) {
+ return {
+ id: profile.id,
+ name: profile.name,
+ baseUrl: profile.config.baseUrl || '',
+ token: profile.config.token || '',
+ models: [{
+ id: `migrated-${profile.id}`,
+ name: profile.config.model || profile.name,
+ modelId: profile.config.model || '',
+ }],
+ isOffline: profile.isOffline,
+ }
+ }
+
+ // Fallback for malformed profiles
+ return profile as ModelProfile
+}
+
+// New: Model profiles storage with migration
export const modelProfilesAtom = atomWithStorage(
"agents:model-profiles",
[OFFLINE_PROFILE], // Start with offline profile
- undefined,
+ {
+ getItem: (key, initialValue) => {
+ const storedValue = localStorage.getItem(key)
+ if (!storedValue) return initialValue
+
+ try {
+ const parsed = JSON.parse(storedValue)
+ // Migrate old profile structures
+ if (Array.isArray(parsed)) {
+ return parsed.map(migrateProfile)
+ }
+ return initialValue
+ } catch {
+ return initialValue
+ }
+ },
+ setItem: (key, value) => {
+ localStorage.setItem(key, JSON.stringify(value))
+ },
+ removeItem: (key) => {
+ localStorage.removeItem(key)
+ },
+ },
{ getOnInit: true },
)
@@ -286,6 +351,14 @@ export const activeProfileIdAtom = atomWithStorage(
{ getOnInit: true },
)
+// Active custom model ID within a profile (null = no custom model selected)
+export const activeCustomModelIdAtom = atomWithStorage(
+ "agents:active-custom-model-id",
+ null,
+ undefined,
+ { getOnInit: true },
+)
+
// Auto-fallback to offline mode when internet is unavailable
export const autoOfflineModeAtom = atomWithStorage(
"agents:auto-offline-mode",
@@ -328,6 +401,7 @@ export function normalizeCustomClaudeConfig(
// Get active config (considering network status and auto-fallback)
export const activeConfigAtom = atom((get) => {
const activeProfileId = get(activeProfileIdAtom)
+ const activeCustomModelId = get(activeCustomModelIdAtom)
const profiles = get(modelProfilesAtom)
const legacyConfig = get(customClaudeConfigAtom)
const networkOnline = get(networkOnlineAtom)
@@ -336,16 +410,34 @@ export const activeConfigAtom = atom((get) => {
// If auto-offline enabled and no internet, use offline profile
if (!networkOnline && autoOffline) {
const offlineProfile = profiles.find(p => p.isOffline)
- if (offlineProfile) {
- return offlineProfile.config
+ if (offlineProfile && offlineProfile.models.length > 0) {
+ const model = activeCustomModelId
+ ? offlineProfile.models.find(m => m.id === activeCustomModelId)
+ : offlineProfile.models[0]
+ if (model) {
+ return {
+ model: model.modelId,
+ token: offlineProfile.token,
+ baseUrl: offlineProfile.baseUrl,
+ }
+ }
}
}
// If specific profile is selected, use it
if (activeProfileId) {
const profile = profiles.find(p => p.id === activeProfileId)
- if (profile) {
- return profile.config
+ if (profile && profile.models.length > 0) {
+ const model = activeCustomModelId
+ ? profile.models.find(m => m.id === activeCustomModelId)
+ : profile.models[0]
+ if (model) {
+ return {
+ model: model.modelId,
+ token: profile.token,
+ baseUrl: profile.baseUrl,
+ }
+ }
}
}