diff --git a/webui/src/locales/en-US/model.json b/webui/src/locales/en-US/model.json index fbd1d228f..addde6f46 100644 --- a/webui/src/locales/en-US/model.json +++ b/webui/src/locales/en-US/model.json @@ -75,6 +75,7 @@ "providerType": "Provider Type", "baseUrl": "Base URL", "baseUrlOptional": "(optional, leave empty for default)", + "baseUrlRequired": "Please enter Base URL", "apiKey": "API Key", "ollamaNoKey": "(Ollama usually doesn't need this)", "apiKeyOptional": "(optional, leave empty for no-auth gateways)", diff --git a/webui/src/locales/zh-CN/model.json b/webui/src/locales/zh-CN/model.json index a88592e0b..5e30a242b 100644 --- a/webui/src/locales/zh-CN/model.json +++ b/webui/src/locales/zh-CN/model.json @@ -75,6 +75,7 @@ "providerType": "Provider 类型", "baseUrl": "Base URL", "baseUrlOptional": "(可选,留空用默认)", + "baseUrlRequired": "请填写 Base URL", "apiKey": "API Key", "ollamaNoKey": "(Ollama 通常不需要)", "apiKeyOptional": "(可选,无鉴权网关可留空)", diff --git a/webui/src/pages/Model/index.test.tsx b/webui/src/pages/Model/index.test.tsx new file mode 100644 index 000000000..06e092dc7 --- /dev/null +++ b/webui/src/pages/Model/index.test.tsx @@ -0,0 +1,220 @@ +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi } from 'vitest'; + +import ModelPage from './index'; +import { renderWithRouter } from '@/test/helpers'; + +const mocks = vi.hoisted(() => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, + useProviders: vi.fn(), + refetch: vi.fn(), + getSummary: vi.fn(), + getResolved: vi.fn(), + listDefinitions: vi.fn(), + catalogList: vi.fn(), + createProvider: vi.fn(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + if (key === 'status.models') return `${params?.count ?? 0} models`; + const translations: Record = { + pageTitle: 'Models', + pageDescription: 'Manage providers', + addProvider: 'Add Provider', + providerAdded: 'Provider added', + 'providerList.empty': 'No providers', + 'providerList.emptyHint': 'Add one to get started', + 'providerList.addProvider': 'Add First Provider', + 'form.model': 'Model', + 'form.done': 'Done', + 'form.providerType': 'Provider Type', + 'form.selectProvider': 'Select Provider...', + 'form.baseUrlOptional': '(optional, leave empty for default)', + 'form.baseUrlRequired': 'Please enter Base URL', + 'form.apiKeyOptional': '(optional, leave empty for no-auth gateways)', + 'form.apiKeyOptionalHint': 'Leave empty for no-auth gateway', + 'form.searchProvider': 'Search providers...', + 'form.noResults': 'No results', + 'form.alreadyAdded': 'Already added', + }; + return translations[key] ?? key; + }, + i18n: { + language: 'en-US', + }, + }), +})); + +vi.mock('@/hooks/useProviders', () => ({ + useProviders: mocks.useProviders, +})); + +vi.mock('@/hooks/useSSE', () => ({ + useSSE: () => undefined, +})); + +vi.mock('@/components/common/Toast', () => ({ + useToast: () => mocks.toast, +})); + +vi.mock('@/components/common/PageHeader', () => ({ + default: ({ action }: { action?: React.ReactNode }) =>
{action}
, +})); + +vi.mock('@/components/common/LoadingSpinner', () => ({ + default: () =>
Loading...
, +})); + +vi.mock('@/components/common/EmptyState', () => ({ + default: ({ title, description }: { title?: React.ReactNode; description?: React.ReactNode }) => ( +
+
{title}
+
{description}
+
+ ), +})); + +vi.mock('@/components/common/EntitySheet', () => ({ + default: ({ + open, + children, + submitDisabled, + submitLabel, + onSubmit, + }: { + open?: boolean; + children?: React.ReactNode; + submitDisabled?: boolean; + submitLabel?: React.ReactNode; + onSubmit?: () => void; + }) => open ? ( +
+
{children}
+ +
+ ) : null, +})); + +vi.mock('@/api/provider', () => ({ + providerAPI: { + getCredentials: vi.fn(), + setCredentials: vi.fn(), + testCredentials: vi.fn(), + }, + modelV2API: { + listDefinitions: mocks.listDefinitions, + createDefinition: vi.fn(), + deleteDefinition: vi.fn(), + }, + usageAPI: { + getSummary: mocks.getSummary, + }, + customAPI: { + createProvider: mocks.createProvider, + }, + modelSettingsAPI: { + get: vi.fn(), + update: vi.fn(), + }, + catalogAPI: { + list: mocks.catalogList, + }, + defaultModelAPI: { + getResolved: mocks.getResolved, + delete: vi.fn(), + set: vi.fn(), + }, +})); + +describe('ModelPage add provider dialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mocks.useProviders.mockReturnValue({ + providers: [], + connectedIds: [], + loading: false, + error: null, + refetch: mocks.refetch, + }); + mocks.getSummary.mockResolvedValue({ data: null }); + mocks.getResolved.mockResolvedValue({ data: null }); + mocks.listDefinitions.mockResolvedValue({ data: { models: [] } }); + mocks.catalogList.mockResolvedValue({ + data: { + providers: [ + { + id: 'openai-compatible', + name: 'OpenAI Compatible', + description: 'Compatible endpoint', + credential_schemas: [], + env_vars: [], + default_base_url: 'https://api.example.com/v1', + model_count: 0, + models: [], + allow_multiple: true, + }, + ], + }, + }); + mocks.createProvider.mockResolvedValue({ + data: { + id: 'custom-my-api', + }, + }); + }); + + it('blocks openai-compatible creation until Base URL is filled and submits once provided', async () => { + const user = userEvent.setup(); + + renderWithRouter(); + + await user.click(screen.getByRole('button', { name: 'Add Provider' })); + expect(await screen.findByTestId('entity-sheet')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Select Provider...' })); + await user.click(await screen.findByRole('button', { name: /OpenAI Compatible/i })); + + expect(screen.queryByText('(optional, leave empty for default)')).not.toBeInTheDocument(); + + const saveButton = screen.getByRole('button', { name: 'Save' }); + const baseUrlInput = screen.getByPlaceholderText('https://api.example.com/v1'); + + await user.type( + screen.getByPlaceholderText('e.g. SiliconFlow, LM Studio, My API'), + 'My API', + ); + + expect(saveButton).toBeEnabled(); + + await user.clear(baseUrlInput); + expect(saveButton).toBeDisabled(); + expect(mocks.createProvider).not.toHaveBeenCalled(); + + await user.type(baseUrlInput, 'https://gateway.example.com/v1'); + + expect(saveButton).toBeEnabled(); + + await user.click(saveButton); + + await waitFor(() => { + expect(mocks.createProvider).toHaveBeenCalledWith({ + name: 'My API', + base_url: 'https://gateway.example.com/v1', + api_key: 'not-needed', + description: 'Compatible endpoint', + }); + }); + }); +}); diff --git a/webui/src/pages/Model/index.tsx b/webui/src/pages/Model/index.tsx index 167581501..67d6067bc 100644 --- a/webui/src/pages/Model/index.tsx +++ b/webui/src/pages/Model/index.tsx @@ -56,6 +56,10 @@ function providerAllowsEmptyApiKey(providerId: string): boolean { ); } +function isCatalogBaseUrlRequired(providerId: string): boolean { + return providerId === 'openai-compatible'; +} + const AZURE_PROVIDER_IDS = new Set(['azure-openai', 'azure']); function isAzureProviderId(providerId: string): boolean { @@ -1243,6 +1247,10 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { toast.warning('Please enter Provider Name'); return; } + if (isCatalogBaseUrlRequired(selectedCatalogId) && !baseUrl.trim()) { + toast.warning(t('form.baseUrlRequired')); + return; + } if (!apiKey.trim() && !providerAllowsEmptyApiKey(selectedCatalogId)) { toast.warning('Please enter API Key'); return; @@ -1356,7 +1364,10 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { setModelTesting(false); }; - const canSave = !!selectedCatalogId && (selectedCatalogId !== 'openai-compatible' || !!providerName.trim()); + const canSave = !!selectedCatalogId && ( + selectedCatalogId !== 'openai-compatible' || + (!!providerName.trim() && !!baseUrl.trim()) + ); const canTest = !!selectedCatalogId && selectedCatalogId !== 'openai-compatible'; // Dynamic EntitySheet props based on wizard step @@ -1576,7 +1587,11 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: {