Compare commits

...

16 Commits

11 changed files with 130 additions and 32 deletions

View File

@ -0,0 +1,33 @@
"""Add credential status for provider table
Revision ID: cf7c38a32b2d
Revises: c20211f18133
Create Date: 2025-09-11 15:37:17.771298
"""
from alembic import op
import models as models
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'cf7c38a32b2d'
down_revision = 'c20211f18133'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('providers', schema=None) as batch_op:
batch_op.add_column(sa.Column('credential_status', sa.String(length=20), server_default=sa.text("'active'::character varying"), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('providers', schema=None) as batch_op:
batch_op.drop_column('credential_status')
# ### end Alembic commands ###

View File

@ -9,9 +9,9 @@ from services.errors.base import BaseServiceError
logger = logging.getLogger(__name__)
class PluginCredentialType(enum.IntEnum):
MODEL = enum.auto()
TOOL = enum.auto()
class PluginCredentialType(enum.Enum):
MODEL = 0 # must be 0 for API contract compatibility
TOOL = 1 # must be 1 for API contract compatibility
def to_number(self):
return self.value

View File

@ -375,13 +375,14 @@ class WorkflowService:
def _validate_llm_model_config(self, tenant_id: str, provider: str, model_name: str) -> None:
"""
Validate that an LLM model configuration can fetch valid credentials.
Validate that an LLM model configuration can fetch valid credentials and has active status.
This method attempts to get the model instance and validates that:
1. The provider exists and is configured
2. The model exists in the provider
3. Credentials can be fetched for the model
4. The credentials pass policy compliance checks
5. The model status is ACTIVE (not NO_CONFIGURE, DISABLED, etc.)
:param tenant_id: The tenant ID
:param provider: The provider name
@ -391,6 +392,7 @@ class WorkflowService:
try:
from core.model_manager import ModelManager
from core.model_runtime.entities.model_entities import ModelType
from core.provider_manager import ProviderManager
# Get model instance to validate provider+model combination
model_manager = ModelManager()
@ -402,6 +404,22 @@ class WorkflowService:
# via ProviderConfiguration.get_current_credentials() -> _check_credential_policy_compliance()
# If it fails, an exception will be raised
# Additionally, check the model status to ensure it's ACTIVE
provider_manager = ProviderManager()
provider_configurations = provider_manager.get_configurations(tenant_id)
models = provider_configurations.get_models(provider=provider, model_type=ModelType.LLM)
target_model = None
for model in models:
if model.model == model_name and model.provider.provider == provider:
target_model = model
break
if target_model:
target_model.raise_for_status()
else:
raise ValueError(f"Model {model_name} not found for provider {provider}")
except Exception as e:
raise ValueError(
f"Failed to validate LLM model configuration (provider: {provider}, model: {model_name}): {str(e)}"
@ -434,7 +452,8 @@ class WorkflowService:
)
if not default_provider:
raise ValueError("No default credential found")
# plugin does not require credentials, skip
return
# Check credential policy compliance using the default credential ID
from core.helper.credential_utils import check_credential_policy_compliance

View File

@ -92,10 +92,10 @@ const CredentialItem = ({
)
}
{
showAction && (
showAction && !credential.from_enterprise && (
<div className='ml-2 hidden shrink-0 items-center group-hover:flex'>
{
!disableEdit && !credential.not_allowed_to_use && !credential.from_enterprise && (
!disableEdit && !credential.not_allowed_to_use && (
<Tooltip popupContent={t('common.operation.edit')}>
<ActionButton
disabled={disabled}
@ -110,7 +110,7 @@ const CredentialItem = ({
)
}
{
!disableDelete && !credential.from_enterprise && (
!disableDelete && (
<Tooltip popupContent={disableDeleteWhenSelected ? disableDeleteTip : t('common.operation.delete')}>
<ActionButton
className='hover:bg-transparent'

View File

@ -37,51 +37,57 @@ const SwitchCredentialInLoadBalancing = ({
onRemove,
}: SwitchCredentialInLoadBalancingProps) => {
const { t } = useTranslation()
const notAllowCustomCredential = provider.allow_custom_token === false
const handleItemClick = useCallback((credential: Credential) => {
setCustomModelCredential(credential)
}, [setCustomModelCredential])
const renderTrigger = useCallback(() => {
const selectedCredentialId = customModelCredential?.credential_id
const authRemoved = !selectedCredentialId && !!credentials?.length
const currentCredential = credentials?.find(c => c.credential_id === selectedCredentialId)
const empty = !credentials?.length
const authRemoved = selectedCredentialId && !currentCredential && !empty
const unavailable = currentCredential?.not_allowed_to_use
let color = 'green'
if (authRemoved && !customModelCredential?.not_allowed_to_use)
if (authRemoved || unavailable)
color = 'red'
if (customModelCredential?.not_allowed_to_use)
color = 'gray'
const Item = (
<Button
variant='secondary'
className={cn(
'shrink-0 space-x-1',
authRemoved && 'text-components-button-destructive-secondary-text',
customModelCredential?.not_allowed_to_use && 'cursor-not-allowed opacity-50',
(authRemoved || unavailable) && 'text-components-button-destructive-secondary-text',
empty && 'cursor-not-allowed opacity-50',
)}
>
<Indicator
className='mr-2'
color={color as any}
/>
{
authRemoved && !customModelCredential?.not_allowed_to_use && t('common.modelProvider.auth.authRemoved')
!empty && (
<Indicator
className='mr-2'
color={color as any}
/>
)
}
{
!authRemoved && customModelCredential?.not_allowed_to_use && t('plugin.auth.credentialUnavailable')
authRemoved && t('common.modelProvider.auth.authRemoved')
}
{
!authRemoved && !customModelCredential?.not_allowed_to_use && customModelCredential?.credential_name
(unavailable || empty) && t('plugin.auth.credentialUnavailableInButton')
}
{
customModelCredential?.from_enterprise && (
!authRemoved && !unavailable && !empty && customModelCredential?.credential_name
}
{
currentCredential?.from_enterprise && (
<Badge className='ml-2'>Enterprise</Badge>
)
}
<RiArrowDownSLine className='h-4 w-4' />
</Button>
)
if (customModelCredential?.not_allowed_to_use) {
if (empty && notAllowCustomCredential) {
return (
<Tooltip
asChild
@ -92,7 +98,7 @@ const SwitchCredentialInLoadBalancing = ({
)
}
return Item
}, [customModelCredential, t, credentials])
}, [customModelCredential, t, credentials, notAllowCustomCredential])
return (
<Authorized
@ -123,6 +129,7 @@ const SwitchCredentialInLoadBalancing = ({
enableAddModelCredential
showItemSelectedIcon
popupTitle={t('common.modelProvider.auth.modelCredentials')}
triggerOnlyOpenModal={!credentials?.length}
/>
)
}

View File

@ -114,7 +114,7 @@ const ModelModal: FC<ModelModalProps> = ({
const formRef1 = useRef<FormRefObject>(null)
const [selectedCredential, setSelectedCredential] = useState<Credential & { addNewCredential?: boolean } | undefined>()
const formRef2 = useRef<FormRefObject>(null)
const isEditMode = !!Object.keys(formValues).filter((key) => {
const isEditMode = !!credential && !!Object.keys(formSchemasValue || {}).filter((key) => {
return key !== '__model_name' && key !== '__model_type' && !!formValues[key]
}).length && isCurrentWorkspaceManager
@ -376,16 +376,16 @@ const ModelModal: FC<ModelModalProps> = ({
<a
href={provider.help?.url[language] || provider.help?.url.en_US}
target='_blank' rel='noopener noreferrer'
className='system-xs-regular mt-2 inline-flex items-center text-text-accent'
className='system-xs-regular mt-2 inline-block align-middle text-text-accent'
onClick={e => !provider.help.url && e.preventDefault()}
>
{provider.help.title?.[language] || provider.help.url[language] || provider.help.title?.en_US || provider.help.url.en_US}
<LinkExternal02 className='ml-1 h-3 w-3' />
<LinkExternal02 className='ml-1 mt-[-2px] inline-block h-3 w-3' />
</a>
)
: <div />
}
<div className='flex items-center justify-end space-x-2'>
<div className='ml-2 flex items-center justify-end space-x-2'>
{
isEditMode && (
<Button

View File

@ -36,14 +36,22 @@ const AuthorizedInNode = ({
disabled,
invalidPluginCredentialInfo,
notAllowCustomCredential,
} = usePluginAuth(pluginPayload, isOpen || !!credentialId)
} = usePluginAuth(pluginPayload, true)
const renderTrigger = useCallback((open?: boolean) => {
let label = ''
let removed = false
let unavailable = false
let color = 'green'
let defaultUnavailable = false
if (!credentialId) {
label = t('plugin.auth.workspaceDefault')
const defaultCredential = credentials.find(c => c.is_default)
if (defaultCredential?.not_allowed_to_use) {
color = 'gray'
defaultUnavailable = true
}
}
else {
const credential = credentials.find(c => c.id === credentialId)
@ -63,6 +71,7 @@ const AuthorizedInNode = ({
open && !removed && 'bg-components-button-ghost-bg-hover',
removed && 'bg-transparent text-text-destructive',
)}
variant={(defaultUnavailable || unavailable) ? 'ghost' : 'secondary'}
>
<Indicator
className='mr-1.5'
@ -70,7 +79,12 @@ const AuthorizedInNode = ({
/>
{label}
{
unavailable && t('plugin.auth.unavailable')
(unavailable || defaultUnavailable) && (
<>
&nbsp;
{t('plugin.auth.unavailable')}
</>
)
}
<RiArrowDownSLine
className={cn(
@ -81,6 +95,7 @@ const AuthorizedInNode = ({
</Button>
)
}, [credentialId, credentials, t])
const defaultUnavailable = credentials.find(c => c.is_default)?.not_allowed_to_use
const extraAuthorizationItems: Credential[] = [
{
id: '__workspace_default__',
@ -88,6 +103,7 @@ const AuthorizedInNode = ({
provider: '',
is_default: !credentialId,
isWorkspaceDefault: true,
not_allowed_to_use: defaultUnavailable,
},
]
const handleAuthorizationItemClick = useCallback((id: string) => {

View File

@ -174,6 +174,7 @@ const Authorized = ({
}
}, [updatePluginCredential, notify, t, handleSetDoingAction, onUpdate])
const unavailableCredentials = credentials.filter(credential => credential.not_allowed_to_use)
const unavailableCredential = credentials.find(credential => credential.not_allowed_to_use && credential.is_default)
return (
<>
@ -197,7 +198,7 @@ const Authorized = ({
'w-full',
isOpen && 'bg-components-button-secondary-bg-hover',
)}>
<Indicator className='mr-2' />
<Indicator className='mr-2' color={unavailableCredential ? 'gray' : 'green'} />
{credentials.length}&nbsp;
{
credentials.length > 1

View File

@ -2,6 +2,7 @@
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useFavicon, useTitle } from 'ahooks'
import { basePath } from '@/utils/var'
import { useEffect } from 'react'
export default function useDocumentTitle(title: string) {
const isPending = useGlobalPublicStore(s => s.isGlobalPending)
@ -20,5 +21,24 @@ export default function useDocumentTitle(title: string) {
}
}
useTitle(titleStr)
useEffect(() => {
let apple: HTMLLinkElement | null = null
if (systemFeatures.branding.favicon) {
document
.querySelectorAll(
'link[rel=\'icon\'], link[rel=\'shortcut icon\'], link[rel=\'apple-touch-icon\'], link[rel=\'mask-icon\']',
)
.forEach(n => n.parentNode?.removeChild(n))
apple = document.createElement('link')
apple.rel = 'apple-touch-icon'
apple.href = systemFeatures.branding.favicon
document.head.appendChild(apple)
}
return () => {
apple?.remove()
}
}, [systemFeatures.branding.favicon])
useFavicon(favicon)
}

View File

@ -298,6 +298,7 @@ const translation = {
clientInfo: 'As no system client secrets found for this tool provider, setup it manually is required, for redirect_uri, please use',
oauthClient: 'OAuth Client',
credentialUnavailable: 'Credentials currently unavailable. Please contact admin.',
credentialUnavailableInButton: 'Credential unavailable',
customCredentialUnavailable: 'Custom credentials currently unavailable',
unavailable: 'Unavailable',
},

View File

@ -298,6 +298,7 @@ const translation = {
clientInfo: '由于未找到此工具提供者的系统客户端密钥,因此需要手动设置,对于 redirect_uri请使用',
oauthClient: 'OAuth 客户端',
credentialUnavailable: '自定义凭据当前不可用,请联系管理员。',
credentialUnavailableInButton: '凭据不可用',
customCredentialUnavailable: '自定义凭据当前不可用',
unavailable: '不可用',
},