diff --git a/src/renderer/features/agents/lib/ipc-chat-transport.ts b/src/renderer/features/agents/lib/ipc-chat-transport.ts
index 9a36a7544..1d87d986f 100644
--- a/src/renderer/features/agents/lib/ipc-chat-transport.ts
+++ b/src/renderer/features/agents/lib/ipc-chat-transport.ts
@@ -2,17 +2,20 @@ import * as Sentry from "@sentry/electron/renderer"
import type { ChatTransport, UIMessage } from "ai"
import { toast } from "sonner"
import {
+ activeProviderIdAtom,
agentsLoginModalOpenAtom,
autoOfflineModeAtom,
type CustomClaudeConfig,
- customClaudeConfigAtom,
enableTasksAtom,
extendedThinkingEnabledAtom,
historyEnabledAtom,
- normalizeCustomClaudeConfig,
+ getOfflineProvider,
+ modelProvidersAtom,
+ networkOnlineAtom,
selectedOllamaModelAtom,
sessionInfoAtom,
showOfflineModeFeaturesAtom,
+ simulateOfflineAtom,
} from "../../../lib/atoms"
import { appStore } from "../../../lib/jotai-store"
import { trpcClient } from "../../../lib/trpc"
@@ -171,20 +174,70 @@ export class IPCChatTransport implements ChatTransport
{
// Read model selection dynamically (so model changes apply to existing chats)
const selectedModelId = appStore.get(lastSelectedModelIdAtom)
- const modelString = MODEL_ID_MAP[selectedModelId] || MODEL_ID_MAP["opus"]
+ const activeProviderId = appStore.get(activeProviderIdAtom)
+ const providers = appStore.get(modelProvidersAtom)
+ const activeProvider = activeProviderId
+ ? providers.find((p) => p.id === activeProviderId)
+ : undefined
- const storedCustomConfig = appStore.get(
- customClaudeConfigAtom,
- ) as CustomClaudeConfig
- const customConfig = normalizeCustomClaudeConfig(storedCustomConfig)
-
- // Get selected Ollama model for offline mode
+ // Offline mode detection
const selectedOllamaModel = appStore.get(selectedOllamaModelAtom)
- // Check if offline mode is enabled in settings
const showOfflineFeatures = appStore.get(showOfflineModeFeaturesAtom)
const autoOfflineMode = appStore.get(autoOfflineModeAtom)
+ const networkOnline = appStore.get(networkOnlineAtom)
+ const simulateOffline = appStore.get(simulateOfflineAtom)
+ const isOffline = simulateOffline || !networkOnline
const offlineModeEnabled = showOfflineFeatures && autoOfflineMode
+ let customConfig: CustomClaudeConfig | undefined
+ if (offlineModeEnabled && isOffline) {
+ const offlineProvider = getOfflineProvider(selectedOllamaModel)
+ customConfig = {
+ model: offlineProvider.models[0],
+ token: offlineProvider.token,
+ baseUrl: offlineProvider.baseUrl,
+ }
+ } else if (activeProvider) {
+ const modelId = activeProvider.models.includes(selectedModelId)
+ ? selectedModelId
+ : activeProvider.models[0]
+ customConfig = {
+ model: modelId,
+ token: activeProvider.token,
+ baseUrl: activeProvider.baseUrl,
+ }
+ }
+
+ const modelString =
+ customConfig?.model ||
+ MODEL_ID_MAP[selectedModelId] ||
+ selectedModelId ||
+ "opus"
+
+ if (
+ customConfig?.token &&
+ !customConfig.token.startsWith("enc:") &&
+ customConfig.token !== "ollama"
+ ) {
+ try {
+ const { encrypted } = await trpcClient.claude.encryptToken.mutate({
+ token: customConfig.token,
+ })
+ customConfig = { ...customConfig, token: encrypted }
+ if (activeProviderId) {
+ const providers = appStore.get(modelProvidersAtom)
+ const updatedProviders = providers.map((provider) =>
+ provider.id === activeProviderId
+ ? { ...provider, token: encrypted }
+ : provider,
+ )
+ appStore.set(modelProvidersAtom, updatedProviders)
+ }
+ } catch (err) {
+ console.error("[models] Failed to encrypt custom model token:", err)
+ }
+ }
+
const currentMode =
useAgentSubChatStore
.getState()
diff --git a/src/renderer/features/agents/lib/models.ts b/src/renderer/features/agents/lib/models.ts
index fb43b940d..039c2fc31 100644
--- a/src/renderer/features/agents/lib/models.ts
+++ b/src/renderer/features/agents/lib/models.ts
@@ -1,5 +1,35 @@
+import type { ModelProvider } from "../../../lib/atoms"
+
export const CLAUDE_MODELS = [
{ id: "opus", name: "Opus" },
{ id: "sonnet", name: "Sonnet" },
{ id: "haiku", name: "Haiku" },
]
+
+export type ClaudeModel = {
+ id: string
+ name: string
+ isCustom?: boolean
+ providerId?: string
+}
+
+export function getAvailableModels(providers: ModelProvider[]): ClaudeModel[] {
+ const baseModels: ClaudeModel[] = CLAUDE_MODELS.map((m) => ({
+ id: m.id,
+ name: m.name,
+ isCustom: false,
+ }))
+
+ const customModels: ClaudeModel[] = providers
+ .filter((p) => !p.isOffline)
+ .flatMap((provider) =>
+ provider.models.map((modelId) => ({
+ id: modelId,
+ name: modelId,
+ isCustom: true,
+ providerId: provider.id,
+ })),
+ )
+
+ return [...baseModels, ...customModels]
+}
diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx
index e57be3598..d1be64bfb 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,
defaultAgentModeAtom,
isDesktopAtom, isFullscreenAtom,
- normalizeCustomClaudeConfig,
+ activeProviderIdAtom,
selectedOllamaModelAtom,
soundNotificationsEnabledAtom
} from "../../../lib/atoms"
@@ -4298,11 +4297,8 @@ export function ChatView({
const isDesktop = useAtomValue(isDesktopAtom)
const isFullscreen = useAtomValue(isFullscreenAtom)
- const customClaudeConfig = useAtomValue(customClaudeConfigAtom)
const selectedOllamaModel = useAtomValue(selectedOllamaModelAtom)
- const normalizedCustomClaudeConfig =
- normalizeCustomClaudeConfig(customClaudeConfig)
- const hasCustomClaudeConfig = Boolean(normalizedCustomClaudeConfig)
+ const activeProviderId = useAtomValue(activeProviderIdAtom)
const setLoadingSubChats = useSetAtom(loadingSubChatsAtom)
const unseenChanges = useAtomValue(agentsUnseenChangesAtom)
const setUnseenChanges = useSetAtom(agentsUnseenChangesAtom)
@@ -6711,9 +6707,7 @@ Make sure to preserve all functionality from both branches when resolving confli
>
- {hasCustomClaudeConfig ? (
- "Custom Model"
- ) : (
+ {activeProviderId ? "Custom Model" : (
<>
Sonnet{" "}
diff --git a/src/renderer/features/agents/main/chat-input-area.tsx b/src/renderer/features/agents/main/chat-input-area.tsx
index 8e98ede18..58fb3b20e 100644
--- a/src/renderer/features/agents/main/chat-input-area.tsx
+++ b/src/renderer/features/agents/main/chat-input-area.tsx
@@ -1,7 +1,7 @@
"use client"
import { useAtom, useAtomValue, useSetAtom } from "jotai"
-import { ChevronDown, Loader2, RefreshCw, Zap } from "lucide-react"
+import { ChevronDown, Zap, Globe } from "lucide-react"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
@@ -12,6 +12,7 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
+ DropdownMenuLabel,
} from "../../../components/ui/dropdown-menu"
import {
AgentIcon,
@@ -44,11 +45,11 @@ import {
agentsSettingsDialogActiveTabAtom,
agentsSettingsDialogOpenAtom,
autoOfflineModeAtom,
- customClaudeConfigAtom,
extendedThinkingEnabledAtom,
- normalizeCustomClaudeConfig,
selectedOllamaModelAtom,
showOfflineModeFeaturesAtom,
+ modelProvidersAtom,
+ activeProviderIdAtom,
} from "../../../lib/atoms"
import { trpc } from "../../../lib/trpc"
import { cn } from "../../../lib/utils"
@@ -61,7 +62,7 @@ import {
clearSubChatDraft,
saveSubChatDraftWithAttachments,
} from "../lib/drafts"
-import { CLAUDE_MODELS } from "../lib/models"
+import { getAvailableModels, type ClaudeModel } from "../lib/models"
import type { DiffTextContext, SelectedTextContext } from "../lib/queue-utils"
import {
AgentsFileMention,
@@ -89,13 +90,15 @@ import { customHotkeysAtom } from "../../../lib/atoms"
// Hook to get available models (including offline models if Ollama is available and debug enabled)
function useAvailableModels() {
+ const [providers] = useAtom(modelProvidersAtom)
const showOfflineFeatures = useAtomValue(showOfflineModeFeaturesAtom)
const { data: ollamaStatus } = trpc.ollama.getStatus.useQuery(undefined, {
refetchInterval: showOfflineFeatures ? 30000 : false,
enabled: showOfflineFeatures, // Only query Ollama when offline mode is enabled
})
- const baseModels = CLAUDE_MODELS
+ // Combine standard models + custom providers
+ const baseModels = useMemo(() => getAvailableModels(providers), [providers])
const isOffline = ollamaStatus ? !ollamaStatus.internet.online : false
const hasOllama = ollamaStatus?.ollama.available && (ollamaStatus.ollama.models?.length ?? 0) > 0
@@ -408,26 +411,44 @@ export const ChatInputArea = memo(function ChatInputArea({
// Model dropdown state
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false)
const [lastSelectedModelId, setLastSelectedModelId] = useAtom(lastSelectedModelIdAtom)
+ const [activeProviderId, setActiveProviderId] = useAtom(activeProviderIdAtom)
const [selectedOllamaModel, setSelectedOllamaModel] = useAtom(selectedOllamaModelAtom)
+ const providers = useAtomValue(modelProvidersAtom)
const availableModels = useAvailableModels()
const autoOfflineMode = useAtomValue(autoOfflineModeAtom)
const showOfflineFeatures = useAtomValue(showOfflineModeFeaturesAtom)
const [selectedModel, setSelectedModel] = useState(
() => availableModels.models.find((m) => m.id === lastSelectedModelId) || availableModels.models[0],
)
+ const providerNameById = useMemo(() => {
+ const entries = providers.map((provider) => [provider.id, provider.name] as const)
+ return new Map(entries)
+ }, [providers])
// Sync selectedModel when atom value changes (e.g., after localStorage hydration)
useEffect(() => {
- const model = availableModels.models.find((m) => m.id === lastSelectedModelId)
+ // Priority: 1. Active Provider, 2. Last Selected Model
+ let model: ClaudeModel | undefined
+ if (activeProviderId) {
+ model = availableModels.models.find((m) => m.providerId === activeProviderId && m.id === lastSelectedModelId)
+ if (!model) {
+ model = availableModels.models.find((m) => m.providerId === activeProviderId)
+ }
+ }
+
+ if (!model) {
+ model = availableModels.models.find((m) => m.id === lastSelectedModelId)
+ }
+
+ if (!model && activeProviderId) {
+ setActiveProviderId(null)
+ model = availableModels.models[0]
+ }
+
if (model && model.id !== selectedModel.id) {
setSelectedModel(model)
}
- }, [lastSelectedModelId])
-
- const customClaudeConfig = useAtomValue(customClaudeConfigAtom)
- const normalizedCustomClaudeConfig =
- normalizeCustomClaudeConfig(customClaudeConfig)
- const hasCustomClaudeConfig = Boolean(normalizedCustomClaudeConfig)
+ }, [lastSelectedModelId, activeProviderId, availableModels.models, setActiveProviderId, selectedModel.id])
// Determine current Ollama model (selected or recommended)
const currentOllamaModel = selectedOllamaModel || availableModels.recommendedModel || availableModels.ollamaModels[0]
@@ -554,15 +575,13 @@ export const ChatInputArea = memo(function ChatInputArea({
if (e.metaKey && e.key === "/") {
e.preventDefault()
e.stopPropagation()
- if (!hasCustomClaudeConfig) {
- setIsModelDropdownOpen(true)
- }
+ setIsModelDropdownOpen(true)
}
}
window.addEventListener("keydown", handleKeyDown, true)
return () => window.removeEventListener("keydown", handleKeyDown, true)
- }, [hasCustomClaudeConfig])
+ }, [])
// Voice input handlers
const handleVoiceMouseDown = useCallback(async () => {
@@ -941,7 +960,6 @@ export const ChatInputArea = memo(function ChatInputArea({
// Process other files - for text files, read content and add as file mention
for (const file of otherFiles) {
// Get file path using Electron's webUtils API (more reliable than file.path)
- // @ts-expect-error - Electron's webUtils API
const filePath: string | undefined = window.webUtils?.getPathForFile?.(file) || (file as File & { path?: string }).path
let mentionId: string
@@ -1358,45 +1376,35 @@ export const ChatInputArea = memo(function ChatInputArea({
) : (
// Online mode: show Claude model selector
{
- if (!hasCustomClaudeConfig) {
- setIsModelDropdownOpen(open)
- }
- }}
+ open={isModelDropdownOpen}
+ onOpenChange={setIsModelDropdownOpen}
>
-
- {availableModels.models.map((model) => {
- const isSelected = selectedModel?.id === model.id
+
+ Standard Models
+ {availableModels.models.filter(m => !m.isCustom).map((model) => {
+ const isSelected = selectedModel?.id === model.id && !activeProviderId
return (
{
setSelectedModel(model)
+ setActiveProviderId(null)
setLastSelectedModelId(model.id)
}}
className="gap-2 justify-between"
@@ -1414,6 +1422,39 @@ export const ChatInputArea = memo(function ChatInputArea({
)
})}
+
+ {availableModels.models.some(m => m.isCustom) && (
+ <>
+
+ Custom Providers
+ {availableModels.models.filter(m => m.isCustom).map((model) => {
+ const isSelected = activeProviderId === model.providerId && lastSelectedModelId === model.id
+ const providerName = model.providerId ? providerNameById.get(model.providerId) : undefined
+ return (
+ {
+ setSelectedModel(model)
+ if (model.providerId) setActiveProviderId(model.providerId)
+ setLastSelectedModelId(model.id)
+ }}
+ className="gap-2 justify-between"
+ >
+
+
+
+ {providerName ? `${providerName} • ${model.name}` : model.name}
+
+
+ {isSelected && (
+
+ )}
+
+ )
+ })}
+ >
+ )}
+
(null)
import {
agentsSettingsDialogOpenAtom,
agentsSettingsDialogActiveTabAtom,
- customClaudeConfigAtom,
- normalizeCustomClaudeConfig,
showOfflineModeFeaturesAtom,
selectedOllamaModelAtom,
customHotkeysAtom,
chatSourceModeAtom,
+ modelProvidersAtom,
+ activeProviderIdAtom,
} from "../../../lib/atoms"
// Desktop uses real tRPC
import { toast } from "sonner"
@@ -110,7 +112,7 @@ import {
markDraftVisible,
type DraftProject,
} from "../lib/drafts"
-import { CLAUDE_MODELS } from "../lib/models"
+import { getAvailableModels } from "../lib/models"
// import type { PlanType } from "@/lib/config/subscription-plans"
type PlanType = string
@@ -123,13 +125,14 @@ const CodexIcon = (props: React.SVGProps
) => (
// Hook to get available models (including offline models if Ollama is available and debug enabled)
function useAvailableModels() {
+ const [providers] = useAtom(modelProvidersAtom)
const showOfflineFeatures = useAtomValue(showOfflineModeFeaturesAtom)
const { data: ollamaStatus } = trpc.ollama.getStatus.useQuery(undefined, {
refetchInterval: showOfflineFeatures ? 30000 : false,
enabled: showOfflineFeatures, // Only query Ollama when offline mode is enabled
})
- const baseModels = CLAUDE_MODELS
+ const baseModels = getAvailableModels(providers)
const isOffline = ollamaStatus ? !ollamaStatus.internet.online : false
const hasOllama = ollamaStatus?.ollama.available && (ollamaStatus.ollama.models?.length ?? 0) > 0
@@ -231,10 +234,7 @@ 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 [activeProviderId, setActiveProviderId] = useAtom(activeProviderIdAtom)
const setSettingsDialogOpen = useSetAtom(agentsSettingsDialogOpenAtom)
const setSettingsActiveTab = useSetAtom(agentsSettingsDialogActiveTabAtom)
const setJustCreatedIds = useSetAtom(justCreatedIdsAtom)
@@ -287,7 +287,12 @@ export function NewChatForm({
// Get available models (with offline support)
const availableModels = useAvailableModels()
+ const providers = useAtomValue(modelProvidersAtom)
const [selectedOllamaModel, setSelectedOllamaModel] = useAtom(selectedOllamaModelAtom)
+ const providerNameById = useMemo(() => {
+ const entries = providers.map((provider) => [provider.id, provider.name] as const)
+ return new Map(entries)
+ }, [providers])
const [selectedModel, setSelectedModel] = useState(
() =>
@@ -296,11 +301,18 @@ export function NewChatForm({
// Sync selectedModel when atom value changes (e.g., after localStorage hydration)
useEffect(() => {
- const model = availableModels.models.find((m) => m.id === lastSelectedModelId)
+ let model = availableModels.models.find((m) => m.id === lastSelectedModelId)
+ if (activeProviderId) {
+ model =
+ availableModels.models.find(
+ (m) => m.providerId === activeProviderId && m.id === lastSelectedModelId,
+ ) || availableModels.models.find((m) => m.providerId === activeProviderId)
+ }
+
if (model && model.id !== selectedModel.id) {
setSelectedModel(model)
}
- }, [lastSelectedModelId])
+ }, [lastSelectedModelId, activeProviderId, availableModels.models, selectedModel.id])
// Determine current Ollama model (selected or recommended)
const currentOllamaModel = selectedOllamaModel || availableModels.recommendedModel || availableModels.ollamaModels[0]
@@ -1779,45 +1791,35 @@ export function NewChatForm({
) : (
// Online mode: show Claude model selector
{
- if (!hasCustomClaudeConfig) {
- setIsModelDropdownOpen(open)
- }
- }}
+ open={isModelDropdownOpen}
+ onOpenChange={setIsModelDropdownOpen}
>
-
- {availableModels.models.map((model) => {
- const isSelected = selectedModel?.id === model.id
+
+ Standard Models
+ {availableModels.models.filter((m) => !m.isCustom).map((model) => {
+ const isSelected = selectedModel?.id === model.id && !activeProviderId
return (
{
setSelectedModel(model)
+ setActiveProviderId(null)
setLastSelectedModelId(model.id)
}}
className="gap-2 justify-between"
@@ -1835,6 +1837,38 @@ export function NewChatForm({
)
})}
+
+ {availableModels.models.some((m) => m.isCustom) && (
+ <>
+
+ Custom Providers
+ {availableModels.models.filter((m) => m.isCustom).map((model) => {
+ const isSelected = activeProviderId === model.providerId && lastSelectedModelId === model.id
+ const providerName = model.providerId ? providerNameById.get(model.providerId) : undefined
+ return (
+ {
+ setSelectedModel(model)
+ if (model.providerId) setActiveProviderId(model.providerId)
+ setLastSelectedModelId(model.id)
+ }}
+ className="gap-2 justify-between"
+ >
+
+
+
+ {providerName ? `${providerName} • ${model.name}` : model.name}
+
+
+ {isSelected && (
+
+ )}
+
+ )
+ })}
+ >
+ )}
)}
diff --git a/src/renderer/features/onboarding/api-key-onboarding-page.tsx b/src/renderer/features/onboarding/api-key-onboarding-page.tsx
index 8bb0f18ac..f677f3874 100644
--- a/src/renderer/features/onboarding/api-key-onboarding-page.tsx
+++ b/src/renderer/features/onboarding/api-key-onboarding-page.tsx
@@ -1,7 +1,7 @@
"use client"
import { useAtom, useAtomValue, useSetAtom } from "jotai"
-import { useState, useEffect } from "react"
+import { useState } from "react"
import { ChevronLeft } from "lucide-react"
import { IconSpinner, KeyFilledIcon, SettingsFilledIcon } from "../../components/ui/icons"
@@ -11,10 +11,13 @@ import { Logo } from "../../components/ui/logo"
import {
apiKeyOnboardingCompletedAtom,
billingMethodAtom,
- customClaudeConfigAtom,
- type CustomClaudeConfig,
+ activeProviderIdAtom,
+ modelProvidersAtom,
} from "../../lib/atoms"
+import { lastSelectedModelIdAtom } from "../agents/atoms"
+import { trpc } from "../../lib/trpc"
import { cn } from "../../lib/utils"
+import { toast } from "sonner"
// Check if the key looks like a valid Anthropic API key
const isValidApiKey = (key: string) => {
@@ -23,7 +26,9 @@ const isValidApiKey = (key: string) => {
}
export function ApiKeyOnboardingPage() {
- const [storedConfig, setStoredConfig] = useAtom(customClaudeConfigAtom)
+ const [providers, setProviders] = useAtom(modelProvidersAtom)
+ const [, setActiveProviderId] = useAtom(activeProviderIdAtom)
+ const [, setLastSelectedModelId] = useAtom(lastSelectedModelIdAtom)
const billingMethod = useAtomValue(billingMethodAtom)
const setBillingMethod = useSetAtom(billingMethodAtom)
const setApiKeyOnboardingCompleted = useSetAtom(apiKeyOnboardingCompletedAtom)
@@ -34,45 +39,51 @@ export function ApiKeyOnboardingPage() {
const defaultModel = "claude-sonnet-4-20250514"
const defaultBaseUrl = "https://api.anthropic.com"
- const [apiKey, setApiKey] = useState(storedConfig.token)
- const [model, setModel] = useState(storedConfig.model || "")
- const [token, setToken] = useState(storedConfig.token)
- const [baseUrl, setBaseUrl] = useState(storedConfig.baseUrl || "")
+ const [apiKey, setApiKey] = useState("")
+ const [model, setModel] = useState("")
+ const [token, setToken] = useState("")
+ const [baseUrl, setBaseUrl] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
-
- // Sync from stored config on mount
- useEffect(() => {
- if (storedConfig.token) {
- setApiKey(storedConfig.token)
- setToken(storedConfig.token)
- }
- if (storedConfig.model) setModel(storedConfig.model)
- if (storedConfig.baseUrl) setBaseUrl(storedConfig.baseUrl)
- }, [])
+ const encryptTokenMutation = trpc.claude.encryptToken.useMutation()
const handleBack = () => {
setBillingMethod(null)
}
// Submit for API key mode (simple - just the key)
- const submitApiKey = (key: string) => {
+ const submitApiKey = async (key: string) => {
if (!isValidApiKey(key)) return
setIsSubmitting(true)
- const config: CustomClaudeConfig = {
- model: defaultModel,
- token: key.trim(),
- baseUrl: defaultBaseUrl,
+ try {
+ const { encrypted } = await encryptTokenMutation.mutateAsync({
+ token: key.trim(),
+ })
+ const providerId = `provider-${Date.now()}`
+ setProviders([
+ ...providers,
+ {
+ id: providerId,
+ name: "Anthropic API",
+ baseUrl: defaultBaseUrl,
+ token: encrypted,
+ models: [defaultModel],
+ isOffline: false,
+ },
+ ])
+ setActiveProviderId(providerId)
+ setLastSelectedModelId(defaultModel)
+ setApiKeyOnboardingCompleted(true)
+ } catch (error) {
+ toast.error("Failed to secure API token")
+ } finally {
+ setIsSubmitting(false)
}
- setStoredConfig(config)
- setApiKeyOnboardingCompleted(true)
-
- setIsSubmitting(false)
}
// Submit for custom model mode (all three fields)
- const submitCustomModel = () => {
+ const submitCustomModel = async () => {
const trimmedModel = model.trim()
const trimmedToken = token.trim()
const trimmedBaseUrl = baseUrl.trim()
@@ -81,15 +92,30 @@ export function ApiKeyOnboardingPage() {
setIsSubmitting(true)
- const config: CustomClaudeConfig = {
- model: trimmedModel,
- token: trimmedToken,
- baseUrl: trimmedBaseUrl,
+ try {
+ const { encrypted } = await encryptTokenMutation.mutateAsync({
+ token: trimmedToken,
+ })
+ const providerId = `provider-${Date.now()}`
+ setProviders([
+ ...providers,
+ {
+ id: providerId,
+ name: "Custom Provider",
+ baseUrl: trimmedBaseUrl,
+ token: encrypted,
+ models: [trimmedModel],
+ isOffline: false,
+ },
+ ])
+ setActiveProviderId(providerId)
+ setLastSelectedModelId(trimmedModel)
+ setApiKeyOnboardingCompleted(true)
+ } catch (error) {
+ toast.error("Failed to secure API token")
+ } finally {
+ setIsSubmitting(false)
}
- setStoredConfig(config)
- setApiKeyOnboardingCompleted(true)
-
- setIsSubmitting(false)
}
const handleApiKeyChange = (e: React.ChangeEvent) => {
diff --git a/src/renderer/lib/atoms/index.ts b/src/renderer/lib/atoms/index.ts
index 178d5645d..108632a01 100644
--- a/src/renderer/lib/atoms/index.ts
+++ b/src/renderer/lib/atoms/index.ts
@@ -210,11 +210,13 @@ export type CustomClaudeConfig = {
baseUrl: string
}
-// Model profile system - support multiple configs
-export type ModelProfile = {
+// Model provider system - support multiple models per provider
+export type ModelProvider = {
id: string
name: string
- config: CustomClaudeConfig
+ baseUrl: string
+ token: string
+ models: string[]
isOffline?: boolean // Mark as offline/Ollama profile
}
@@ -227,40 +229,92 @@ export const selectedOllamaModelAtom = atomWithStorage(
)
// Helper to get offline profile with selected model
-export const getOfflineProfile = (modelName?: string | null): ModelProfile => ({
+export const getOfflineProvider = (modelName?: string | null): ModelProvider => ({
id: 'offline-ollama',
name: 'Offline (Ollama)',
isOffline: true,
- config: {
- model: modelName || 'qwen2.5-coder:7b',
- token: 'ollama',
- baseUrl: 'http://localhost:11434',
- },
+ models: [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 = {
+export const OFFLINE_PROVIDER: ModelProvider = {
id: 'offline-ollama',
name: 'Offline (Ollama)',
isOffline: true,
- config: {
- model: 'qwen2.5-coder:7b',
- token: 'ollama',
- baseUrl: 'http://localhost:11434',
- },
+ models: ['qwen2.5-coder:7b'],
+ token: 'ollama',
+ baseUrl: 'http://localhost:11434',
}
-// Legacy single config (deprecated, kept for backwards compatibility)
-export const customClaudeConfigAtom = atomWithStorage(
- "agents:claude-custom-config",
- {
- model: "",
- token: "",
- baseUrl: "",
- },
- undefined,
- { getOnInit: true },
-)
+const LEGACY_CUSTOM_CONFIG_KEY = "agents:claude-custom-config"
+const PROVIDERS_STORAGE_KEY = "agents:model-providers"
+const ACTIVE_PROVIDER_KEY = "agents:active-provider-id"
+const LAST_SELECTED_MODEL_KEY = "agents:lastSelectedModelId"
+
+const runLegacyCustomConfigMigration = (): void => {
+ if (typeof window === "undefined") return
+ if (!("localStorage" in window)) return
+
+ try {
+ const legacyRaw = window.localStorage.getItem(LEGACY_CUSTOM_CONFIG_KEY)
+ if (!legacyRaw) return
+
+ const providersRaw = window.localStorage.getItem(PROVIDERS_STORAGE_KEY)
+ const providers = providersRaw
+ ? (JSON.parse(providersRaw) as ModelProvider[])
+ : null
+
+ const legacy = JSON.parse(legacyRaw) as CustomClaudeConfig
+ if (!legacy?.model || !legacy?.baseUrl) {
+ window.localStorage.removeItem(LEGACY_CUSTOM_CONFIG_KEY)
+ return
+ }
+ const legacyToken = legacy.token ?? ""
+
+ const hasExistingCustomProvider = Array.isArray(providers)
+ ? providers.some(
+ (provider) =>
+ !provider?.isOffline &&
+ provider?.baseUrl === legacy.baseUrl &&
+ provider?.token === legacyToken &&
+ provider?.models?.includes(legacy.model),
+ )
+ : false
+
+ if (hasExistingCustomProvider) {
+ window.localStorage.removeItem(LEGACY_CUSTOM_CONFIG_KEY)
+ return
+ }
+
+ const providerId = `legacy-${crypto.randomUUID()}`
+ const nextProviders = Array.isArray(providers)
+ ? [...providers]
+ : [OFFLINE_PROVIDER]
+
+ nextProviders.push({
+ id: providerId,
+ name: "Legacy Custom Config",
+ baseUrl: legacy.baseUrl,
+ token: legacyToken,
+ models: [legacy.model],
+ isOffline: false,
+ })
+
+ window.localStorage.setItem(
+ PROVIDERS_STORAGE_KEY,
+ JSON.stringify(nextProviders),
+ )
+ window.localStorage.setItem(ACTIVE_PROVIDER_KEY, providerId)
+ window.localStorage.setItem(LAST_SELECTED_MODEL_KEY, legacy.model)
+ window.localStorage.removeItem(LEGACY_CUSTOM_CONFIG_KEY)
+ } catch (error) {
+ console.warn("[models] Failed to migrate legacy custom config:", error)
+ }
+}
+
+runLegacyCustomConfigMigration()
// OpenAI API key for voice transcription (for users without paid subscription)
export const openaiApiKeyAtom = atomWithStorage(
@@ -270,17 +324,17 @@ export const openaiApiKeyAtom = atomWithStorage(
{ getOnInit: true },
)
-// New: Model profiles storage
-export const modelProfilesAtom = atomWithStorage(
- "agents:model-profiles",
- [OFFLINE_PROFILE], // Start with offline profile
+// New: Model providers storage
+export const modelProvidersAtom = atomWithStorage(
+ "agents:model-providers",
+ [OFFLINE_PROVIDER], // Start with offline provider
undefined,
{ getOnInit: true },
)
// Active profile ID (null = use Claude Code default)
-export const activeProfileIdAtom = atomWithStorage(
- "agents:active-profile-id",
+export const activeProviderIdAtom = atomWithStorage(
+ "agents:active-provider-id",
null,
undefined,
{ getOnInit: true },
@@ -313,51 +367,6 @@ export const showOfflineModeFeaturesAtom = atomWithStorage(
// Network status (updated from main process)
export const networkOnlineAtom = atom(true)
-export function normalizeCustomClaudeConfig(
- config: CustomClaudeConfig,
-): CustomClaudeConfig | undefined {
- const model = config.model.trim()
- const token = config.token.trim()
- const baseUrl = config.baseUrl.trim()
-
- if (!model || !token || !baseUrl) return undefined
-
- return { model, token, baseUrl }
-}
-
-// Get active config (considering network status and auto-fallback)
-export const activeConfigAtom = atom((get) => {
- const activeProfileId = get(activeProfileIdAtom)
- const profiles = get(modelProfilesAtom)
- const legacyConfig = get(customClaudeConfigAtom)
- const networkOnline = get(networkOnlineAtom)
- const autoOffline = get(autoOfflineModeAtom)
-
- // 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 specific profile is selected, use it
- if (activeProfileId) {
- const profile = profiles.find(p => p.id === activeProfileId)
- if (profile) {
- return profile.config
- }
- }
-
- // Fallback to legacy config if set
- const normalized = normalizeCustomClaudeConfig(legacyConfig)
- if (normalized) {
- return normalized
- }
-
- // No custom config
- return undefined
-})
// Preferences - Extended Thinking
// When enabled, Claude will use extended thinking for deeper reasoning (128K tokens)