feat: add OpenAI protocol support and enhance AI provider configuration

This commit is contained in:
shiyu
2026-06-20 20:16:32 +08:00
parent 64fe02c23a
commit c8b43dbf4d
8 changed files with 583 additions and 14 deletions

View File

@@ -1,6 +1,7 @@
import request from './client';
export type AIAbility = 'chat' | 'vision' | 'embedding' | 'rerank' | 'voice' | 'tools';
export type OpenAIProtocol = 'chat_completions' | 'responses';
export interface AIProviderPayload {
name: string;

View File

@@ -516,6 +516,7 @@
"Enter identifier": "Enter identifier",
"Only lowercase letters, numbers, dash, dot and underscore are allowed": "Only lowercase letters, numbers, dash, dot and underscore are allowed",
"API Format": "API Format",
"OpenAI Protocol": "OpenAI Protocol",
"Base URL": "Base URL",
"Enter base url": "Enter base URL",
"Optional, can also be provided per request": "Optional, can also be provided per request",

View File

@@ -515,6 +515,7 @@
"Enter identifier": "请输入标识符",
"Only lowercase letters, numbers, dash, dot and underscore are allowed": "仅允许小写字母、数字、连字符、点和下划线",
"API Format": "API 格式",
"OpenAI Protocol": "OpenAI 协议",
"Base URL": "基础 URL",
"Enter base url": "请输入基础 URL",
"Optional, can also be provided per request": "可选,也可在请求时提供",

View File

@@ -48,6 +48,7 @@ import type {
AIModelPayload,
AIProvider,
AIProviderPayload,
OpenAIProtocol,
} from '../../../api/aiProviders';
import {
createModel,
@@ -90,6 +91,11 @@ interface ProviderTemplate {
}
const abilityOrder: AIAbility[] = ['chat', 'vision', 'embedding', 'rerank', 'voice', 'tools'];
const defaultOpenAIProtocol: OpenAIProtocol = 'chat_completions';
function normalizeOpenAIProtocol(value: unknown): OpenAIProtocol {
return value === 'responses' ? 'responses' : defaultOpenAIProtocol;
}
const abilityInfo: Record<AIAbility, { icon: ReactNode; label: string; color: string; description: string }> = {
chat: {
@@ -242,6 +248,7 @@ type AIProviderFormValues = {
name?: string;
identifier?: string;
api_format: AIProviderPayload['api_format'];
openai_protocol?: OpenAIProtocol;
base_url?: string;
api_key?: string;
logo_url?: string;
@@ -275,12 +282,19 @@ export default function AiSettingsTab() {
const [addingRemoteModels, setAddingRemoteModels] = useState<boolean>(false);
const [modelModalTab, setModelModalTab] = useState<'remote' | 'manual'>('remote');
const [remoteSearchKeyword, setRemoteSearchKeyword] = useState<string>('');
const providerApiFormat = Form.useWatch('api_format', providerForm);
const capabilitiesValue = Form.useWatch('capabilities', modelForm);
const showEmbeddingDimensions = useMemo(() => {
const capabilities = Array.isArray(capabilitiesValue) ? capabilitiesValue : [];
return capabilities.includes('embedding') || capabilities.includes('rerank');
}, [capabilitiesValue]);
useEffect(() => {
if (providerApiFormat === 'openai' && !providerForm.getFieldValue('openai_protocol')) {
providerForm.setFieldValue('openai_protocol', defaultOpenAIProtocol);
}
}, [providerApiFormat, providerForm]);
useEffect(() => {
if (!showEmbeddingDimensions) {
modelForm.setFieldsValue({ embedding_dimensions: null });
@@ -338,6 +352,7 @@ export default function AiSettingsTab() {
name: existing.name,
identifier: existing.identifier,
api_format: existing.api_format,
openai_protocol: normalizeOpenAIProtocol(existing.extra_config?.openai_protocol),
base_url: existing.base_url ?? undefined,
api_key: '',
logo_url: existing.logo_url ?? undefined,
@@ -345,7 +360,7 @@ export default function AiSettingsTab() {
});
} else {
providerForm.resetFields();
providerForm.setFieldsValue({ api_format: 'openai' });
providerForm.setFieldsValue({ api_format: 'openai', openai_protocol: defaultOpenAIProtocol });
setSelectedTemplate(null);
setProviderModal({ open: true, step: 1 });
}
@@ -364,6 +379,7 @@ export default function AiSettingsTab() {
name: t(template.nameKey),
identifier: template.identifier,
api_format: template.api_format,
openai_protocol: template.api_format === 'openai' ? defaultOpenAIProtocol : undefined,
base_url: template.base_url ?? '',
api_key: '',
logo_url: template.logo_url ?? '',
@@ -375,7 +391,7 @@ export default function AiSettingsTab() {
setProviderModal((prev) => ({ ...prev, step: 1, editing: undefined }));
setSelectedTemplate(null);
providerForm.resetFields();
providerForm.setFieldsValue({ api_format: 'openai' });
providerForm.setFieldsValue({ api_format: 'openai', openai_protocol: defaultOpenAIProtocol });
};
const handleSubmitProvider = async () => {
@@ -384,6 +400,12 @@ export default function AiSettingsTab() {
const trimmedApiKey = values.api_key?.trim();
const trimmedLogoUrl = values.logo_url?.trim();
const trimmedProviderType = values.provider_type?.trim();
const extraConfig = { ...(providerModal.editing?.extra_config ?? {}) };
if (values.api_format === 'openai') {
extraConfig.openai_protocol = normalizeOpenAIProtocol(values.openai_protocol);
} else {
delete extraConfig.openai_protocol;
}
const payload: AIProviderPayload = {
name: (values.name || '').trim(),
identifier: (values.identifier || '').trim(),
@@ -391,6 +413,7 @@ export default function AiSettingsTab() {
base_url: trimmedBaseUrl ? trimmedBaseUrl : null,
logo_url: trimmedLogoUrl ? trimmedLogoUrl : null,
provider_type: trimmedProviderType ? trimmedProviderType : null,
extra_config: Object.keys(extraConfig).length ? extraConfig : null,
};
if (trimmedApiKey) {
payload.api_key = trimmedApiKey;
@@ -1117,16 +1140,30 @@ export default function AiSettingsTab() {
label={t('API Format')}
rules={[{ required: true }]}
>
<Select
disabled={!allowFormatChange}
options={[
{ value: 'openai', label: 'OpenAI Compatible' },
{ value: 'gemini', label: 'Gemini Compatible' },
{ value: 'anthropic', label: 'Anthropic Native' },
{ value: 'ollama', label: 'Ollama Native' },
]}
/>
</Form.Item>
<Select
disabled={!allowFormatChange}
options={[
{ value: 'openai', label: 'OpenAI Compatible' },
{ value: 'gemini', label: 'Gemini Compatible' },
{ value: 'anthropic', label: 'Anthropic Native' },
{ value: 'ollama', label: 'Ollama Native' },
]}
/>
</Form.Item>
{providerApiFormat === 'openai' ? (
<Form.Item
name="openai_protocol"
label={t('OpenAI Protocol')}
rules={[{ required: true }]}
>
<Select
options={[
{ value: 'chat_completions', label: 'Chat Completions' },
{ value: 'responses', label: 'Responses' },
]}
/>
</Form.Item>
) : null}
<Form.Item name="base_url" label={t('Base URL')} rules={[{ required: true, message: t('Enter base url') }]}>
<Input placeholder="https://" />
</Form.Item>