Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions web-app/src/containers/DropdownModelProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useTranslation } from '@/i18n/react-i18next-compat'
import { useFavoriteModel } from '@/hooks/useFavoriteModel'
import { predefinedProviders } from '@/constants/providers'
import { providerHasRemoteApiKeys } from '@/lib/provider-api-keys'
import { isManuallyAdded } from '@/lib/models'
import { useServiceHub } from '@/hooks/useServiceHub'
import { getLastUsedModel } from '@/utils/getModelToStart'
import { ChevronsUpDown } from 'lucide-react'
Expand Down Expand Up @@ -264,10 +265,17 @@ const DropdownModelProvider = memo(function DropdownModelProvider({
providers.forEach((provider) => {
if (!provider.active) return

// Check if this provider has any manually-added/pinned models.
// If so, only show those in the dropdown to keep the list manageable.
const hasManualModels = provider.models.some(isManuallyAdded)

provider.models.forEach((modelItem) => {
// Skip embedding models - they can't be used for chat
if (modelItem.embedding) return

// If the provider has pinned models, hide auto-fetched ones
if (hasManualModels && !isManuallyAdded(modelItem)) return

// Skip models that require API key but don't have one (except llamacpp)
// For custom providers, allow if they have at least one model loaded
const isPredefined = predefinedProviders.some((e) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import '@testing-library/jest-dom'
import DropdownModelProvider from '../DropdownModelProvider'
import { useModelProvider } from '@/hooks/useModelProvider'

// Minimal local types to avoid pulling ambient declarations into the test.
type Model = {
id: string
displayName?: string
capabilities?: string[]
manuallyAdded?: boolean
imported?: boolean
embedding?: boolean
}

type ModelProvider = {
provider: string
active: boolean
models: Model[]
settings: unknown[]
}

type MockHookReturn = {
providers: ModelProvider[]
selectedProvider: string
selectedModel: Model
getProviderByName: (name: string) => ModelProvider | undefined
selectModelProvider: () => void
getModelBy: (id: string) => Model | undefined
updateProvider: () => void
}

vi.mock('@/hooks/useModelProvider', () => ({
useModelProvider: vi.fn(),
}))

vi.mock('@/hooks/useThreads', () => ({
useThreads: vi.fn(() => ({ updateCurrentThreadModel: vi.fn() })),
}))

vi.mock('@/hooks/useServiceHub', () => ({
useServiceHub: vi.fn(() => ({
models: () => ({
checkMmprojExists: vi.fn(() => Promise.resolve(false)),
checkMmprojExistsAndUpdateOffloadMMprojSetting: vi.fn(() =>
Promise.resolve()
),
}),
})),
}))

vi.mock('@/i18n/react-i18next-compat', () => ({
useTranslation: vi.fn(() => ({ t: (key: string) => key })),
}))

vi.mock('@tanstack/react-router', () => ({
useNavigate: vi.fn(() => vi.fn()),
}))

vi.mock('@/hooks/useFavoriteModel', () => ({
useFavoriteModel: vi.fn(() => ({ favoriteModels: [] })),
}))

vi.mock('@/lib/platform/const', () => ({
PlatformFeatures: {
WEB_AUTO_MODEL_SELECTION: false,
MODEL_PROVIDER_SETTINGS: true,
projects: true,
},
}))

vi.mock('@/components/ui/popover', () => ({
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: React.ReactNode }) => (
<div data-testid="popover-trigger">{children}</div>
),
PopoverContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="popover-content">{children}</div>
),
}))

vi.mock('../ProvidersAvatar', () => ({
default: ({ provider }: { provider: any }) => (
<div data-testid={`provider-avatar-${provider.provider}`} />
),
}))

vi.mock('../Capabilities', () => ({
default: ({ capabilities }: { capabilities: string[] }) => (
<div data-testid="capabilities">{capabilities.join(',')}</div>
),
}))

vi.mock('../ModelSetting', () => ({
ModelSetting: () => <div data-testid="model-setting" />,
}))

vi.mock('../ModelSupportStatus', () => ({
ModelSupportStatus: () => <div data-testid="model-support-status" />,
}))

const mockHook = (providers: ModelProvider[], selectedModel: Model) =>
vi.mocked(useModelProvider).mockReturnValue({
providers,
selectedProvider: providers[0]?.provider,
selectedModel,
getProviderByName: vi.fn((name: string) =>
providers.find((p) => p.provider === name)
),
selectModelProvider: vi.fn(),
getModelBy: vi.fn((id: string) =>
providers.flatMap((p) => p.models).find((m) => m.id === id)
),
updateProvider: vi.fn(),
} as MockHookReturn)

describe('DropdownModelProvider - manual model filter', () => {
beforeEach(() => vi.clearAllMocks())
afterEach(() => cleanup())

it('hides auto-fetched models when the provider has pinned models', () => {
const provider: ModelProvider = {
provider: 'llamacpp',
active: true,
settings: [],
models: [
{ id: 'pinned.gguf', capabilities: ['completion'], manuallyAdded: true },
{ id: 'auto-1.gguf', capabilities: ['completion'] },
{ id: 'auto-2.gguf', capabilities: ['completion'] },
],
}
mockHook([provider], provider.models[0])

render(<DropdownModelProvider />)

expect(screen.getAllByText('pinned.gguf').length).toBeGreaterThanOrEqual(1)
expect(screen.queryByText('auto-1.gguf')).not.toBeInTheDocument()
expect(screen.queryByText('auto-2.gguf')).not.toBeInTheDocument()
})

it('treats imported models as pinned and hides auto-fetched ones', () => {
const provider: ModelProvider = {
provider: 'llamacpp',
active: true,
settings: [],
models: [
{ id: 'local.gguf', capabilities: ['completion'], imported: true },
{ id: 'auto-1.gguf', capabilities: ['completion'] },
],
}
mockHook([provider], provider.models[0])

render(<DropdownModelProvider />)

expect(screen.getAllByText('local.gguf').length).toBeGreaterThanOrEqual(1)
expect(screen.queryByText('auto-1.gguf')).not.toBeInTheDocument()
})

it('shows all models when the provider has no pinned models', () => {
const provider: ModelProvider = {
provider: 'llamacpp',
active: true,
settings: [],
models: [
{ id: 'auto-1.gguf', capabilities: ['completion'] },
{ id: 'auto-2.gguf', capabilities: ['completion'] },
{ id: 'auto-3.gguf', capabilities: ['completion'] },
],
}
mockHook([provider], provider.models[0])

render(<DropdownModelProvider />)

expect(screen.getByText('auto-2.gguf')).toBeInTheDocument()
expect(screen.getByText('auto-3.gguf')).toBeInTheDocument()
})

it('does not treat a catalog displayName as a pin', () => {
// A displayName alone must NOT activate the filter — otherwise catalogs
// that ship displayName would wrongly hide every other model.
const provider: ModelProvider = {
provider: 'llamacpp',
active: true,
settings: [],
models: [
{ id: 'auto-1.gguf', displayName: 'Auto One', capabilities: ['completion'] },
{ id: 'auto-2.gguf', displayName: 'Auto Two', capabilities: ['completion'] },
],
}
mockHook([provider], provider.models[0])

render(<DropdownModelProvider />)

expect(screen.getByText('Auto Two')).toBeInTheDocument()
})
})
17 changes: 14 additions & 3 deletions web-app/src/containers/dialogs/AddModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,20 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => {
if (!modelId.trim()) return // Don't submit if model ID is empty

if (provider.models.some((e) => e.id === modelId)) {
toast.error(t('providers:addModel.modelExists'), {
description: t('providers:addModel.modelExistsDesc'),
// Model already exists — upgrade it to manually-added so it appears
// in the "manual only" filter. This handles the common case where a
// user wants to "pin" a model that was auto-fetched from a remote catalog.
const updatedModels = provider.models.map((m) =>
m.id === modelId ? { ...m, manuallyAdded: true } : m
)
updateProvider(provider.provider, {
...provider,
models: updatedModels,
})
return // Don't submit if model ID already exists
toast.success(t('providers:addModel.modelPinned'))
setModelId('')
setOpen(false)
return
}

// Create the new model
Expand All @@ -53,6 +63,7 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => {
name: modelId,
capabilities: getModelCapabilities(provider.provider, modelId),
version: '1.0',
manuallyAdded: true,
}

// Update the provider with the new model
Expand Down
7 changes: 6 additions & 1 deletion web-app/src/containers/dialogs/EditModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export const DialogEditModel = ({
const capabilitiesChanged = JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities)

// Build the update object for the selected model
const modelUpdate: Partial<Model> & { _userConfiguredCapabilities?: boolean } = {}
const modelUpdate: Partial<Model> = {}

if (nameChanged) {
modelUpdate.displayName = displayName
Expand All @@ -132,6 +132,11 @@ export const DialogEditModel = ({
modelUpdate._userConfiguredCapabilities = true
}

// Mark as manually added so it shows in the "manual only" filter
if (nameChanged || capabilitiesChanged) {
modelUpdate.manuallyAdded = true
}

// Update the model in the provider models array
const updatedModels = provider.models.map((m: Model) =>
m.id === selectedModelId ? { ...m, ...modelUpdate } : m
Expand Down
51 changes: 51 additions & 0 deletions web-app/src/hooks/__tests__/useModelProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,4 +435,55 @@ describe('useModelProvider migrations', () => {
// Pre-set api_type must round-trip untouched.
expect(customAnthropic.api_type).toBe('anthropic')
})

it('backfills manuallyAdded on curated models (v17 → v18)', () => {
const persistApi = (useModelProvider as any).persist
const migrate = persistApi?.getOptions().migrate as
| ((state: unknown, version: number) => any)
| undefined

expect(migrate).toBeDefined()

const persistedState = {
providers: [
{
provider: 'openrouter',
base_url: 'https://openrouter.ai/api/v1',
settings: [],
models: [
// User toggled capabilities → curated.
{ id: 'cap', name: 'cap', _userConfiguredCapabilities: true },
// User renamed (displayName differs from name) → curated.
{ id: 'renamed', name: 'renamed', displayName: 'My Model' },
// Catalog echoes displayName === name → NOT curated.
{ id: 'echo-name', name: 'echo-name', displayName: 'echo-name' },
// Catalog echoes displayName === id → NOT curated.
{ id: 'echo-id', name: 'Some Name', displayName: 'echo-id' },
// Plain auto-fetched model → untouched.
{ id: 'plain', name: 'plain' },
// Already flagged → preserved.
{ id: 'already', name: 'already', manuallyAdded: true },
// Imported models are recognized at read time, not backfilled here.
{ id: 'imported', name: 'imported', imported: true },
],
},
],
selectedProvider: 'openrouter',
selectedModel: null,
deletedModels: [],
}

const migratedState = migrate!(persistedState, 17)
const byId = Object.fromEntries(
migratedState.providers[0].models.map((m: Model) => [m.id, m])
)

expect(byId.cap.manuallyAdded).toBe(true)
expect(byId.renamed.manuallyAdded).toBe(true)
expect(byId['echo-name'].manuallyAdded).toBeUndefined()
expect(byId['echo-id'].manuallyAdded).toBeUndefined()
expect(byId.plain.manuallyAdded).toBeUndefined()
expect(byId.already.manuallyAdded).toBe(true)
expect(byId.imported.manuallyAdded).toBeUndefined()
})
})
27 changes: 26 additions & 1 deletion web-app/src/hooks/useModelProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,9 +819,34 @@ export const useModelProvider = create<ModelProviderState>()(
})
}

if (version <= 17 && state?.providers) {
// One-time backfill of `manuallyAdded` for users upgrading from
// before the manual-filter feature. Imported models are recognized
// at read time (see `isManuallyAdded`), so they need no backfill.
//
// Heuristic: a model is user-curated if it has user-configured
// capabilities, or a displayName that differs from its name/id
// (i.e. the user renamed it). We deliberately ignore a displayName
// that merely mirrors name/id, because remote catalogs sometimes
// populate it verbatim — counting those would wrongly hide
// auto-fetched models from the chat dropdown.
state.providers.forEach((provider) => {
provider.models?.forEach((model) => {
if (model.manuallyAdded === true) return
const userRenamed =
!!model.displayName &&
model.displayName !== model.name &&
model.displayName !== model.id
if (model._userConfiguredCapabilities === true || userRenamed) {
model.manuallyAdded = true
}
})
})
}

return state
},
version: 17,
version: 18,
}
)
)
Loading