fix: can't debug model plugins (#38500)

This commit is contained in:
非法操作 2026-07-07 21:03:32 +08:00 committed by GitHub
parent 3ddfba5ca5
commit 6edce14e88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 648 additions and 55 deletions

View File

@ -92,6 +92,7 @@ class PluginService:
PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX = "plugin_model_providers:tenant_id:"
PLUGIN_MODEL_PROVIDERS_GENERATION_REDIS_KEY_PREFIX = "plugin_model_providers_generation:tenant_id:"
PLUGIN_MODEL_PROVIDERS_LOCK_REDIS_KEY_PREFIX = "plugin_model_providers_refresh_lock:tenant_id:"
PLUGIN_MODEL_PROVIDERS_REMOTE_DEBUG_REDIS_KEY_PREFIX = "plugin_model_providers_remote_debug:tenant_id:"
PLUGIN_MODEL_PROVIDERS_LOCK_TTL = 30
PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT = 2.0
PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL = 0.05
@ -117,6 +118,10 @@ class PluginService:
def _get_plugin_model_providers_lock_key(cls, tenant_id: str, generation: int) -> str:
return f"{cls.PLUGIN_MODEL_PROVIDERS_LOCK_REDIS_KEY_PREFIX}{tenant_id}:generation:{generation}"
@classmethod
def _get_plugin_model_providers_remote_debug_cache_key(cls, tenant_id: str) -> str:
return f"{cls.PLUGIN_MODEL_PROVIDERS_REMOTE_DEBUG_REDIS_KEY_PREFIX}{tenant_id}"
@staticmethod
def _get_provider_short_name_alias(provider: PluginModelProviderEntity) -> str:
"""
@ -259,6 +264,111 @@ class PluginService:
except (RedisError, RuntimeError):
logger.warning("Failed to cache plugin model providers for tenant %s.", tenant_id, exc_info=True)
@classmethod
def _get_remote_model_plugin_cache_marker(cls, plugins: Sequence[PluginEntity]) -> str | None:
remote_model_plugins = sorted(
f"{plugin.plugin_id}:{plugin.plugin_unique_identifier}"
for plugin in plugins
if plugin.source == PluginInstallationSource.Remote
)
if not remote_model_plugins:
return None
return "\n".join(remote_model_plugins)
@classmethod
def _load_cached_remote_model_plugin_marker(cls, tenant_id: str) -> str | None:
cache_key = cls._get_plugin_model_providers_remote_debug_cache_key(tenant_id)
try:
cached_marker = redis_client.get(cache_key)
except (RedisError, RuntimeError):
logger.warning("Failed to read remote debug model plugin marker for tenant %s.", tenant_id, exc_info=True)
return None
if cached_marker is None:
return None
if isinstance(cached_marker, bytes):
try:
return cached_marker.decode()
except UnicodeDecodeError:
logger.warning(
"Invalid remote debug model plugin marker for tenant %s; deleting cache marker.",
tenant_id,
exc_info=True,
)
try:
redis_client.delete(cache_key)
except (RedisError, RuntimeError):
logger.warning(
"Failed to delete invalid remote debug model plugin marker for tenant %s.",
tenant_id,
exc_info=True,
)
return None
if isinstance(cached_marker, str):
return cached_marker
logger.warning("Invalid remote debug model plugin marker for tenant %s; deleting cache marker.", tenant_id)
try:
redis_client.delete(cache_key)
except (RedisError, RuntimeError):
logger.warning(
"Failed to delete invalid remote debug model plugin marker for tenant %s.",
tenant_id,
exc_info=True,
)
return None
@classmethod
def _store_cached_remote_model_plugin_marker(cls, tenant_id: str, marker: str | None) -> None:
cache_key = cls._get_plugin_model_providers_remote_debug_cache_key(tenant_id)
try:
if marker is None:
redis_client.delete(cache_key)
else:
redis_client.setex(cache_key, dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL, marker)
except (RedisError, RuntimeError):
logger.warning("Failed to cache remote debug model plugin marker for tenant %s.", tenant_id, exc_info=True)
@classmethod
def _load_cached_plugin_model_provider_plugin_ids(cls, tenant_id: str) -> set[str] | None:
"""Return plugin ids represented by the current provider cache, or None when no usable cache exists."""
generation = cls._load_plugin_model_providers_generation(tenant_id)
cached_providers, _ = cls._load_cached_plugin_model_providers_for_generation(tenant_id, generation)
if cached_providers is None:
return None
plugin_ids: set[str] = set()
for provider in cached_providers:
last_slash = provider.provider.rfind("/")
if last_slash > 0:
plugin_ids.add(provider.provider[:last_slash])
return plugin_ids
@classmethod
def _should_invalidate_model_provider_cache_for_remote_model_plugins(
cls,
tenant_id: str,
plugins: Sequence[PluginEntity],
) -> bool:
remote_model_plugin_marker = cls._get_remote_model_plugin_cache_marker(plugins)
cached_remote_model_plugin_marker = cls._load_cached_remote_model_plugin_marker(tenant_id)
if remote_model_plugin_marker is None:
return cached_remote_model_plugin_marker is not None
if remote_model_plugin_marker != cached_remote_model_plugin_marker:
return True
remote_model_plugin_ids = {
plugin.plugin_id for plugin in plugins if plugin.source == PluginInstallationSource.Remote
}
cached_plugin_ids = cls._load_cached_plugin_model_provider_plugin_ids(tenant_id)
if cached_plugin_ids is None:
return False
return not remote_model_plugin_ids.issubset(cached_plugin_ids)
@classmethod
@contextmanager
def _plugin_model_providers_refresh_lock(
@ -571,7 +681,21 @@ class PluginService:
This keeps pagination usable before category is persisted on installation rows.
"""
manager = PluginInstaller()
return manager.list_plugins_by_category(tenant_id, category, page, page_size)
plugins = manager.list_plugins_by_category(tenant_id, category, page, page_size)
if category == PluginCategory.Model:
should_invalidate_model_provider_cache = (
PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins(
tenant_id,
plugins.list,
)
)
if should_invalidate_model_provider_cache:
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
remote_model_plugin_marker = PluginService._get_remote_model_plugin_cache_marker(plugins.list)
PluginService._store_cached_remote_model_plugin_marker(tenant_id, remote_model_plugin_marker)
return plugins
@staticmethod
def _normalize_endpoint_count(value: object) -> int:

View File

@ -8,7 +8,7 @@ import zstandard
from pydantic import TypeAdapter
from redis import RedisError
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource
from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginInstallTaskStatus, PluginModelProviderEntity
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
@ -71,6 +71,16 @@ def _build_install_task(*, task_id: str = "task-1", status: PluginInstallTaskSta
)
def _build_remote_model_plugin(
*, plugin_id: str = "langgenius/debug-model", plugin_unique_identifier: str = "langgenius/debug-model:1.0.0"
) -> SimpleNamespace:
return SimpleNamespace(
plugin_id=plugin_id,
plugin_unique_identifier=plugin_unique_identifier,
source=PluginInstallationSource.Remote,
)
def _provider_cache_key(tenant_id: str, generation: int | None = None) -> str:
if generation is None:
return f"plugin_model_providers:tenant_id:{tenant_id}"
@ -797,6 +807,144 @@ class TestPluginListEndpointCounts:
class TestPluginModelProviderCacheInvalidation:
def test_get_debugging_key_does_not_invalidate_model_provider_cache(self) -> None:
"""Reading a debug key does not mean a debug runtime has registered a model provider."""
with (
patch(f"{MODULE}.PluginDebuggingClient") as debugging_client_cls,
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
):
debugging_client_cls.return_value.get_debugging_key.return_value = "debug-key"
from core.plugin.plugin_service import PluginService
result = PluginService.get_debugging_key("tenant-1")
assert result == "debug-key"
debugging_client_cls.return_value.get_debugging_key.assert_called_once_with("tenant-1")
invalidate_cache.assert_not_called()
def test_list_model_category_invalidates_when_remote_model_plugin_is_missing_from_provider_cache(self) -> None:
"""Remote model plugins are daemon-registered, so category reads repair a stale provider cache."""
remote_plugin = _build_remote_model_plugin()
remote_plugin_marker = "langgenius/debug-model:langgenius/debug-model:1.0.0"
plugins = SimpleNamespace(list=[remote_plugin], has_more=False)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
patch(
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
return_value=remote_plugin_marker,
),
patch(
f"{MODULE}.PluginService._load_cached_plugin_model_provider_plugin_ids",
return_value={"langgenius/openai"},
),
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
):
installer_cls.return_value.list_plugins_by_category.return_value = plugins
from core.plugin.plugin_service import PluginService
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
assert result is plugins
installer_cls.return_value.list_plugins_by_category.assert_called_once_with(
"tenant-1", PluginCategory.Model, 1, 100
)
invalidate_cache.assert_called_once_with("tenant-1")
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
def test_list_model_category_invalidates_when_remote_model_plugin_identity_changes(self) -> None:
"""A debug model plugin can share plugin_id with an installed plugin, so identity changes bust cache too."""
remote_plugin = _build_remote_model_plugin(
plugin_id="langgenius/openai",
plugin_unique_identifier="langgenius/openai:debug",
)
remote_plugin_marker = "langgenius/openai:langgenius/openai:debug"
plugins = SimpleNamespace(list=[remote_plugin], has_more=False)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
patch(
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
return_value="langgenius/openai:langgenius/openai:1.0.0",
),
patch(
f"{MODULE}.PluginService._load_cached_plugin_model_provider_plugin_ids",
return_value={"langgenius/openai"},
) as load_cached_provider_plugin_ids,
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
):
installer_cls.return_value.list_plugins_by_category.return_value = plugins
from core.plugin.plugin_service import PluginService
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
assert result is plugins
invalidate_cache.assert_called_once_with("tenant-1")
load_cached_provider_plugin_ids.assert_not_called()
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
def test_list_model_category_keeps_provider_cache_when_remote_model_plugin_is_already_cached(self) -> None:
"""A connected remote model plugin should not force provider cache churn once represented."""
remote_plugin = _build_remote_model_plugin()
remote_plugin_marker = "langgenius/debug-model:langgenius/debug-model:1.0.0"
plugins = SimpleNamespace(list=[remote_plugin], has_more=False)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
patch(
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
return_value=remote_plugin_marker,
),
patch(
f"{MODULE}.PluginService._load_cached_plugin_model_provider_plugin_ids",
return_value={"langgenius/debug-model"},
),
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
):
installer_cls.return_value.list_plugins_by_category.return_value = plugins
from core.plugin.plugin_service import PluginService
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
assert result is plugins
invalidate_cache.assert_not_called()
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
def test_list_model_category_invalidates_when_remote_model_plugin_disconnects(self) -> None:
"""The current model category result clears provider cache when the previous debug model disappears."""
installed_plugin = SimpleNamespace(
plugin_id="langgenius/openai",
plugin_unique_identifier="langgenius/openai:1.0.0",
source=PluginInstallationSource.Marketplace,
)
plugins = SimpleNamespace(list=[installed_plugin], has_more=True)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
patch(
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
return_value="langgenius/debug-model:langgenius/debug-model:1.0.0",
),
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
):
installer_cls.return_value.list_plugins_by_category.return_value = plugins
from core.plugin.plugin_service import PluginService
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
assert result is plugins
invalidate_cache.assert_called_once_with("tenant-1")
store_marker.assert_called_once_with("tenant-1", None)
def test_fetch_install_task_invalidates_model_provider_cache_when_finished(self) -> None:
"""Finished plugin install tasks invalidate tenant provider cache."""
task = _build_install_task(status=PluginInstallTaskStatus.Success)

View File

@ -41,6 +41,7 @@ vi.mock('@/context/provider-context', () => ({
vi.mock('../hooks', () => ({
useDefaultModel: () => ({ data: null, isLoading: false }),
useLanguage: () => 'en_US',
}))
vi.mock('../provider-added-card', () => ({
@ -84,9 +85,11 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
}))
vi.mock('@/service/use-plugins', () => ({
useCheckInstalled: () => ({
useInstalledPluginList: () => ({
data: { plugins: [] },
}),
useInvalidateInstalledPluginList: () => vi.fn(),
useInvalidateCheckInstalled: () => vi.fn(),
usePluginAutoUpgradeSettings: () => ({
data: {
category: 'model',

View File

@ -1,7 +1,9 @@
import type { ReactNode } from 'react'
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
import { act, fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
@ -41,10 +43,18 @@ const { mockReferenceSetting, mockAutoUpgradeError } = vi.hoisted(() => ({
},
}))
const { mockProviderContextState } = vi.hoisted(() => ({
const { mockProviderContextState, mockRefreshModelProviders } = vi.hoisted(() => ({
mockProviderContextState: {
isLoadingModelProviders: false,
},
mockRefreshModelProviders: vi.fn(),
}))
const { mockInstalledModelPlugins, mockUseInstalledPluginList } = vi.hoisted(() => ({
mockInstalledModelPlugins: {
value: [] as PluginDetail[],
},
mockUseInstalledPluginList: vi.fn(),
}))
const mockQuotaConfig = {
@ -79,6 +89,70 @@ const saveUpdateSettings = () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
}
const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
plugin_unique_identifier: 'langgenius/debug-model:1.0.0',
version: '1.0.0',
author: 'langgenius',
icon: 'debug-model.png',
icon_dark: 'debug-model-dark.png',
name: 'debug-model',
category: PluginCategoryEnum.model,
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
description: { en_US: 'Debug model provider' } as unknown as PluginDeclaration['description'],
created_at: '2024-01-01',
resource: null,
plugins: null,
verified: false,
endpoint: null,
tool: undefined,
datasource: undefined,
model: {},
tags: [],
agent_strategy: null,
meta: {
version: '1.0.0',
},
trigger: {} as unknown as PluginDeclaration['trigger'],
...overrides,
})
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => {
const {
declaration: overrideDeclaration,
plugin_id: overridePluginId,
...restOverrides
} = overrides
const declaration = overrideDeclaration ?? createPluginDeclaration()
const pluginId = overridePluginId ?? 'langgenius/debug-model'
return {
id: 'plugin-installation-id',
created_at: '2024-01-01',
updated_at: '2024-01-01',
name: declaration.name,
plugin_id: pluginId,
plugin_unique_identifier: declaration.plugin_unique_identifier,
declaration,
installation_id: 'plugin-installation-id',
tenant_id: 'tenant-id',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: declaration.plugin_unique_identifier,
source: PluginSource.debugging,
meta: {
repo: '',
version: '1.0.0',
package: '',
},
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
...restOverrides,
}
}
const mockProviders = [
{
provider: 'openai',
@ -106,6 +180,7 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: mockProviders,
isLoadingModelProviders: mockProviderContextState.isLoadingModelProviders,
refreshModelProviders: mockRefreshModelProviders,
}),
}))
@ -119,6 +194,7 @@ const mockDefaultModels: Record<string, { data: unknown, isLoading: boolean }> =
vi.mock('../hooks', () => ({
useDefaultModel: (type: string) => mockDefaultModels[type] ?? { data: null, isLoading: false },
useLanguage: () => 'en_US',
}))
vi.mock('../install-from-marketplace', () => ({
@ -126,7 +202,24 @@ vi.mock('../install-from-marketplace', () => ({
}))
vi.mock('../provider-added-card', () => ({
default: ({ provider }: { provider: { provider: string } }) => <div data-testid="provider-card">{provider.provider}</div>,
default: ({
notConfigured,
provider,
pluginDetail,
}: {
notConfigured?: boolean
provider: { provider: string }
pluginDetail?: { plugin_id: string, source?: string }
}) => (
<div
data-testid="provider-card"
data-not-configured={String(!!notConfigured)}
data-plugin-id={pluginDetail?.plugin_id ?? ''}
data-plugin-source={pluginDetail?.source ?? ''}
>
{provider.provider}
</div>
),
}))
vi.mock('../provider-added-card/quota-panel', () => ({
@ -160,12 +253,12 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
}))
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { plugins: [] },
}),
useCheckInstalled: () => ({
data: { plugins: [] },
}),
useInstalledPluginList: (...args: unknown[]) => {
mockUseInstalledPluginList(...args)
return {
data: { plugins: mockInstalledModelPlugins.value },
}
},
usePluginAutoUpgradeSettings: () => ({
data: mockReferenceSetting.auto_upgrade
? {
@ -280,6 +373,9 @@ describe('ModelProviderPage', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockUseInstalledPluginList.mockClear()
mockRefreshModelProviders.mockClear()
mockInstalledModelPlugins.value = []
mockProviderContextState.isLoadingModelProviders = false
mockAutoUpgradeError.value = undefined
mockReferenceSetting.auto_upgrade = {
@ -418,6 +514,107 @@ describe('ModelProviderPage', () => {
expect(screen.getByText('anthropic')).toBeInTheDocument()
})
it('should use the model plugin installation list to attach plugin detail to provider cards', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'langgenius/openai/openai',
label: { en_US: 'OpenAI' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/openai',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/openai:1.0.0',
name: 'openai',
label: { en_US: 'OpenAI Plugin' } as unknown as PluginDeclaration['label'],
}),
}),
]
renderModelProviderPage()
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, { category: PluginCategoryEnum.model })
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-id', 'langgenius/openai')
expect(screen.queryByText('OpenAI Plugin')).not.toBeInTheDocument()
})
it('should not render installed model plugins that are not registered as model providers', () => {
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/debug-model',
declaration: createPluginDeclaration({
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
description: { en_US: 'Debug model provider' } as unknown as PluginDeclaration['description'],
}),
}),
]
renderModelProviderPage()
expect(screen.queryByText('Debug Model')).not.toBeInTheDocument()
expect(screen.queryByText('langgenius/debug-model')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'plugin actions langgenius/debug-model' })).not.toBeInTheDocument()
})
it('should refresh model providers once when a debugging model plugin is missing from providers', () => {
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/debug-model',
declaration: createPluginDeclaration({
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
}),
}),
]
renderModelProviderPage()
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
})
it('should prefer debugging plugin detail when an installed model plugin shares the same plugin id', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'langgenius/openai/openai',
label: { en_US: 'OpenAI' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/openai',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/openai:debug',
name: 'openai',
label: { en_US: 'OpenAI Debug Plugin' } as unknown as PluginDeclaration['label'],
}),
source: PluginSource.debugging,
}),
createPluginDetail({
plugin_id: 'langgenius/openai',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/openai:1.0.0',
name: 'openai',
label: { en_US: 'OpenAI Installed Plugin' } as unknown as PluginDeclaration['label'],
}),
source: PluginSource.marketplace,
}),
]
renderModelProviderPage()
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-id', 'langgenius/openai')
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-source', PluginSource.debugging)
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
})
it('should show provider placeholders while model providers are loading', () => {
mockProviderContextState.isLoadingModelProviders = true
@ -572,4 +769,66 @@ describe('ModelProviderPage', () => {
])
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
})
it('should prioritize debugging model plugins within their provider section', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'langgenius/openai/openai',
label: { en_US: 'OpenAI Fixed' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}, {
provider: 'zeta-provider',
label: { en_US: 'Zeta Provider' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}, {
provider: 'langgenius/normal-model/normal-model',
label: { en_US: 'Normal Model' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}, {
provider: 'langgenius/debug-model/debug-model',
label: { en_US: 'Debug Model' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/debug-model',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/debug-model:1.0.0',
name: 'debug-model',
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
}),
}),
]
renderModelProviderPage()
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
expect(renderedProviders).toEqual([
'langgenius/openai/openai',
'zeta-provider',
'langgenius/debug-model/debug-model',
'langgenius/normal-model/normal-model',
])
expect(screen.getAllByTestId('provider-card')[2]).toHaveAttribute('data-not-configured', 'true')
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
})
})

View File

@ -6,15 +6,15 @@ import type { PluginDetail } from '@/app/components/plugins/types'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useMemo } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useCheckInstalled } from '@/service/use-plugins'
import { useInstalledPluginList } from '@/service/use-plugins'
import UpdateSettingDialog from '../update-setting-dialog'
import {
CustomConfigurationStatusEnum,
@ -25,7 +25,6 @@ import {
} from './hooks'
import ModelProviderPageBody from './model-provider-page-body'
import SystemModelSelector from './system-model-selector'
import { providerToPluginId } from './utils'
type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-configured' | 'fully-configured'
@ -58,23 +57,43 @@ const ModelProviderPage = ({
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } = useDefaultModel(ModelTypeEnum.speech2text)
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts)
const { modelProviders: providers, isLoadingModelProviders } = useProviderContext()
const { modelProviders: providers, isLoadingModelProviders, refreshModelProviders } = useProviderContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const allPluginIds = useMemo(() => {
return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))]
}, [providers])
const { data: installedPlugins } = useCheckInstalled({
pluginIds: allPluginIds,
enabled: allPluginIds.length > 0,
const { data: installedModelPlugins } = useInstalledPluginList(false, 100, {
category: PluginCategoryEnum.model,
})
const enrichedPlugins = usePluginsWithLatestVersion(installedPlugins?.plugins)
const enrichedPlugins = usePluginsWithLatestVersion(installedModelPlugins?.plugins)
const pluginDetailMap = useMemo(() => {
const map = new Map<string, PluginDetail>()
for (const plugin of enrichedPlugins)
map.set(plugin.plugin_id, plugin)
for (const plugin of enrichedPlugins) {
const existingPlugin = map.get(plugin.plugin_id)
if (!existingPlugin || plugin.source === PluginSource.debugging)
map.set(plugin.plugin_id, plugin)
}
return map
}, [enrichedPlugins])
const debuggingModelPluginKey = useMemo(() => {
const debuggingModelPluginIds = enrichedPlugins
.filter(plugin => plugin.source === PluginSource.debugging)
.map(plugin => `${plugin.plugin_id}:${plugin.plugin_unique_identifier}`)
.sort()
return debuggingModelPluginIds.join(',')
}, [enrichedPlugins])
const refreshedDebuggingModelPluginKeyRef = useRef('')
useEffect(() => {
if (!debuggingModelPluginKey) {
refreshedDebuggingModelPluginKeyRef.current = ''
return
}
if (refreshedDebuggingModelPluginKeyRef.current === debuggingModelPluginKey)
return
refreshedDebuggingModelPluginKeyRef.current = debuggingModelPluginKey
refreshModelProviders?.()
}, [debuggingModelPluginKey, refreshModelProviders])
const enableMarketplace = systemFeatures.enable_marketplace
const isDefaultModelLoading = isTextGenerationDefaultModelLoading
|| isEmbeddingsDefaultModelLoading

View File

@ -3,6 +3,7 @@ import type { ModelProvider } from './declarations'
import type { PluginDetail } from '@/app/components/plugins/types'
import { Trans, useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { PluginSource } from '@/app/components/plugins/types'
import { IS_CLOUD_EDITION } from '@/config'
import InstallFromMarketplace from './install-from-marketplace'
import ProviderAddedCard from './provider-added-card'
@ -99,21 +100,40 @@ type ProviderCardListProps = {
notConfigured?: boolean
}
function isDebuggingProvider(provider: ModelProvider, pluginDetailMap: Map<string, PluginDetail>) {
return pluginDetailMap.get(providerToPluginId(provider.provider))?.source === PluginSource.debugging
}
function ProviderCardList({
providers,
pluginDetailMap,
notConfigured,
}: ProviderCardListProps) {
const sortedProviders = [...providers]
.sort((a, b) => {
const aIsDebuggingPlugin = isDebuggingProvider(a, pluginDetailMap)
const bIsDebuggingPlugin = isDebuggingProvider(b, pluginDetailMap)
if (aIsDebuggingPlugin === bIsDebuggingPlugin)
return 0
return aIsDebuggingPlugin ? -1 : 1
})
return (
<div className="relative flex flex-col gap-2">
{providers.map(provider => (
<ProviderAddedCard
key={provider.provider}
notConfigured={notConfigured}
provider={provider}
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
/>
))}
{sortedProviders.map((provider) => {
const pluginDetail = pluginDetailMap.get(providerToPluginId(provider.provider))
return (
<ProviderAddedCard
key={provider.provider}
notConfigured={notConfigured}
provider={provider}
pluginDetail={pluginDetail}
/>
)
})}
</div>
)
}
@ -157,8 +177,8 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
<div className="flex flex-col gap-2 pt-2">
<div className="flex h-5 items-center system-md-semibold text-text-primary">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
<ProviderCardList
notConfigured
providers={filteredNotConfiguredProviders}
notConfigured
pluginDetailMap={pluginDetailMap}
/>
</div>

View File

@ -158,6 +158,15 @@ describe('ProviderCardActions', () => {
expect(mockHandleUpdate).toHaveBeenCalledWith(true)
})
it('should show a compact debug badge after the version for debugging plugins', () => {
render(<ProviderCardActions detail={createDetail({ source: PluginSource.debugging })} />)
const version = screen.getByText('1.0.0')
const debugBadge = screen.getByText('appDebug.operation.debugConfig')
expect(version.compareDocumentPosition(debugBadge) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('should trigger the latest marketplace update when clicking the update button', () => {
render(<ProviderCardActions detail={createDetail()} />)

View File

@ -30,6 +30,7 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
const { source, version, latest_version, latest_unique_identifier, meta } = detail
const author = detail.declaration?.author ?? ''
const name = detail.declaration?.name ?? detail.name
const isDebuggingPlugin = source === PluginSource.debugging
const {
modalStates,
@ -80,31 +81,41 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
return (
<>
{!!version && (
<PluginVersionPicker
disabled={!isFromMarketplace || !canUpdatePlugin}
isShow={versionPicker.isShow}
onShowChange={versionPicker.setIsShow}
pluginID={detail.plugin_id}
currentVersion={version}
onSelect={handleVersionSelect}
sideOffset={4}
alignOffset={0}
trigger={(
<>
<PluginVersionPicker
disabled={!isFromMarketplace || !canUpdatePlugin}
isShow={versionPicker.isShow}
onShowChange={versionPicker.setIsShow}
pluginID={detail.plugin_id}
currentVersion={version}
onSelect={handleVersionSelect}
sideOffset={4}
alignOffset={0}
trigger={(
<Badge
className={cn(
canUpdatePlugin && isFromMarketplace && 'cursor-pointer hover:bg-state-base-hover',
)}
uppercase={false}
text={(
<>
<span>{version}</span>
{canUpdatePlugin && isFromMarketplace && <span className="ml-1 i-ri-arrow-left-right-line size-3" />}
</>
)}
hasRedCornerMark={hasNewVersion}
/>
)}
/>
{isDebuggingPlugin && (
<Badge
className={cn(
canUpdatePlugin && isFromMarketplace && 'cursor-pointer hover:bg-state-base-hover',
)}
className="border-state-warning-active bg-state-warning-hover text-text-warning"
size="xs"
uppercase={false}
text={(
<>
<span>{version}</span>
{canUpdatePlugin && isFromMarketplace && <span className="ml-1 i-ri-arrow-left-right-line size-3" />}
</>
)}
hasRedCornerMark={hasNewVersion}
text={t('operation.debugConfig', { ns: 'appDebug' })}
/>
)}
/>
</>
)}
{canUpdatePlugin && (hasNewVersion || isFromGitHub) && (