Skip to content
Merged
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
1 change: 1 addition & 0 deletions webui/src/locales/en-US/model.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
1 change: 1 addition & 0 deletions webui/src/locales/zh-CN/model.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"providerType": "Provider 类型",
"baseUrl": "Base URL",
"baseUrlOptional": "(可选,留空用默认)",
"baseUrlRequired": "请填写 Base URL",
"apiKey": "API Key",
"ollamaNoKey": "(Ollama 通常不需要)",
"apiKeyOptional": "(可选,无鉴权网关可留空)",
Expand Down
220 changes: 220 additions & 0 deletions webui/src/pages/Model/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
if (key === 'status.models') return `${params?.count ?? 0} models`;
const translations: Record<string, string> = {
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 }) => <div>{action}</div>,
}));

vi.mock('@/components/common/LoadingSpinner', () => ({
default: () => <div>Loading...</div>,
}));

vi.mock('@/components/common/EmptyState', () => ({
default: ({ title, description }: { title?: React.ReactNode; description?: React.ReactNode }) => (
<div>
<div>{title}</div>
<div>{description}</div>
</div>
),
}));

vi.mock('@/components/common/EntitySheet', () => ({
default: ({
open,
children,
submitDisabled,
submitLabel,
onSubmit,
}: {
open?: boolean;
children?: React.ReactNode;
submitDisabled?: boolean;
submitLabel?: React.ReactNode;
onSubmit?: () => void;
}) => open ? (
<div data-testid="entity-sheet">
<div>{children}</div>
<button type="button" disabled={submitDisabled} onClick={onSubmit}>
{submitLabel}
</button>
</div>
) : 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(<ModelPage />);

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',
});
});
});
});
19 changes: 17 additions & 2 deletions webui/src/pages/Model/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1576,7 +1587,11 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: {
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Base URL
<span className="text-gray-400 font-normal ml-1">{t('form.baseUrlOptional')}</span>
{isCatalogBaseUrlRequired(selectedCatalogId) ? (
<span className="text-slate-500 ml-1">*</span>
) : (
<span className="text-gray-400 font-normal ml-1">{t('form.baseUrlOptional')}</span>
)}
</label>
<input
type="text"
Expand Down