Skip to content
Merged
41 changes: 41 additions & 0 deletions src/composables/useFeatureFlags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,47 @@ describe('useFeatureFlags', () => {
})
})

describe('supportsModelTypeTags', () => {
afterEach(() => {
remoteConfig.value = {}
})

it('uses the remote config value', () => {
remoteConfig.value = { supports_model_type_tags: true }

const { flags } = useFeatureFlags()

expect(flags.supportsModelTypeTags).toBe(true)
})

it('falls back to the server feature flag when remote config omits it', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS) return true
return defaultValue
}
)

const { flags } = useFeatureFlags()

expect(flags.supportsModelTypeTags).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS,
false
)
})

it('defaults to false when neither source has the flag', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue
)

const { flags } = useFeatureFlags()

expect(flags.supportsModelTypeTags).toBe(false)
})
})

describe('churnkeyAppId', () => {
afterEach(() => {
vi.mocked(distributionTypes).isCloud = false
Expand Down
3 changes: 2 additions & 1 deletion src/composables/useFeatureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,9 @@ export function useFeatureFlags() {
)
},
get supportsModelTypeTags() {
return api.getServerFeature(
return resolveFlag(
ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS,
remoteConfig.value.supports_model_type_tags,
false
)
}
Expand Down
1 change: 1 addition & 0 deletions src/platform/remoteConfig/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export type RemoteConfig = {
partner_node_governance_enabled?: boolean
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
supports_model_type_tags?: boolean
free_tier_credits?: number
free_tier_balance?: {
allowance: number
Expand Down
59 changes: 59 additions & 0 deletions src/stores/modelStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'

import { assetService } from '@/platform/assets/services/assetService'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import {
Expand All @@ -27,6 +29,9 @@ vi.mock('@/scripts/api', () => ({
api: {
getModels: vi.fn(),
getModelFolders: vi.fn(),
getServerFeature: vi.fn(
(_path: string, defaultValue?: unknown) => defaultValue
),
viewMetadata: vi.fn(),
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`),
addEventListener: vi.fn(),
Expand Down Expand Up @@ -110,6 +115,7 @@ describe('useModelStore', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
isCloudRef.value = false
remoteConfig.value = {}
})

it('should load models', async () => {
Expand Down Expand Up @@ -599,6 +605,59 @@ describe('useModelStore', () => {
})
})

describe('model-type capability change', () => {
it('rebuilds the library when the capability turns on', async () => {
enableMocks(true)
store = useModelStore()
await store.loadModelFolders()
await store.getLoadedModelFolder('checkpoints')
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(1)

remoteConfig.value = { supports_model_type_tags: true }

await vi.waitFor(() => {
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(2)
})
expect(assetService.invalidateModelBuckets).toHaveBeenCalled()
expect(assetService.seedModelAssets).not.toHaveBeenCalled()
})

it('rebuilds again when the capability rolls back', async () => {
enableMocks(true)
remoteConfig.value = { supports_model_type_tags: true }
store = useModelStore()
await store.loadModelFolders()
await store.getLoadedModelFolder('checkpoints')
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(1)

remoteConfig.value = { supports_model_type_tags: false }

await vi.waitFor(() => {
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(2)
})
expect(assetService.invalidateModelBuckets).toHaveBeenCalled()
})

it('does not reload on the legacy listing path', async () => {
enableMocks(false)
store = useModelStore()
await store.loadModelFolders()
await store.getLoadedModelFolder('checkpoints')
expect(api.getModelFolders).toHaveBeenCalledTimes(1)

remoteConfig.value = { supports_model_type_tags: true }

await nextTick()
await nextTick()
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
expect(assetService.invalidateModelBuckets).not.toHaveBeenCalled()
})
})

describe('visibleModelFolders', () => {
it('hides folders that loaded empty in asset mode', async () => {
enableMocks(true)
Expand Down
17 changes: 16 additions & 1 deletion src/stores/modelStore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { debounce } from 'es-toolkit'
import { defineStore } from 'pinia'
import { computed, onScopeDispose, ref } from 'vue'
import { computed, onScopeDispose, ref, watch } from 'vue'

import { useFeatureFlags } from '@/composables/useFeatureFlags'
import type { ModelFile } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
Expand Down Expand Up @@ -532,6 +533,20 @@ export const useModelStore = defineStore('models', () => {
unsubscribeModelsScanned()
})

const { flags } = useFeatureFlags()

watch(
() => flags.supportsModelTypeTags,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — The async watcher on flags.supportsModelTypeTags does not cancel or serialize an in-flight reloadModels(); if the capability flaps (true→false→true, e.g. during deploy thrash) overlapping reloads can interleave and leave the store inconsistent. Guard with an in-flight token or debounce the handler. Raised by 2 of 8 reviewers (gemini-3.1-pro adversarial, kimi-k2.7-code adversarial).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The store already serializes this: overlapping reloadModels() calls cannot interleave commits because prepareModelFolders bumps and re-checks a monotonic request id (src/stores/modelStore.ts:351-353), and reloadModels re-checks it again right before its single atomic commit (src/stores/modelStore.ts:473-474). A superseded reload builds detached folder objects off-screen and discards them without touching live state, so a true→false→true flap wastes requests but ends with only the newest-started reload committing. No change needed.


Generated by Claude Code

() =>
usesAssetApi() &&
reloadModels().catch((error) => {
console.error(
'Failed to reload the model library after a capability change',
error
)
})
)

return {
models,
modelFolders,
Expand Down
Loading