fix(openai): clarify endpoint capability UI

This commit is contained in:
shaw 2026-05-29 09:23:06 +08:00
parent ed1b57c597
commit 37044b83eb
5 changed files with 114 additions and 9 deletions

View File

@ -2692,10 +2692,18 @@
<Select
v-model="openAIResponsesMode"
:options="openAIResponsesModeOptions"
:disabled="!openAITextGenerationCapabilityEnabled"
data-testid="openai-responses-mode-select"
/>
</div>
</div>
<p
v-if="!openAITextGenerationCapabilityEnabled"
class="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
data-testid="openai-responses-mode-not-applicable"
>
{{ t('admin.accounts.openai.responsesModeTextDisabledHint') }}
</p>
<div>
<label class="input-label mb-2 block">{{ t('admin.accounts.openai.endpointCapabilities') }}</label>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
@ -3434,10 +3442,22 @@ const openAIResponsesModeOptions = computed(() => [
{ value: 'force_responses', label: t('admin.accounts.openai.responsesModeForceResponses') },
{ value: 'force_chat_completions', label: t('admin.accounts.openai.responsesModeForceChatCompletions') }
])
const openAITextEndpointCapabilityLabel = computed(() => {
if (openAIResponsesMode.value === 'force_responses') {
return t('admin.accounts.openai.capabilityResponses')
}
if (openAIResponsesMode.value === 'force_chat_completions') {
return t('admin.accounts.openai.capabilityChatCompletions')
}
return t('admin.accounts.openai.capabilityTextAuto')
})
const openAIEndpointCapabilityOptions = computed<{ value: OpenAIEndpointCapability; label: string }[]>(() => [
{ value: 'chat_completions', label: t('admin.accounts.openai.capabilityChatCompletions') },
{ value: 'chat_completions', label: openAITextEndpointCapabilityLabel.value },
{ value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }
])
const openAITextGenerationCapabilityEnabled = computed(() =>
openAIEndpointCapabilities.value.includes('chat_completions')
)
const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => {
const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings']
@ -3455,6 +3475,9 @@ const toggleOpenAIEndpointCapability = (capability: OpenAIEndpointCapability, ev
openAIEndpointCapabilities.value = openAIEndpointCapabilities.value.filter(
(value) => value !== capability
)
if (!openAITextGenerationCapabilityEnabled.value) {
openAIResponsesMode.value = 'auto'
}
return
}
openAIEndpointCapabilities.value = normalizeOpenAIEndpointCapabilities([
@ -4268,7 +4291,11 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
delete extra.openai_compact_mode
}
if (accountCategory.value === 'apikey' && openAIResponsesMode.value !== 'auto') {
if (
accountCategory.value === 'apikey' &&
openAITextGenerationCapabilityEnabled.value &&
openAIResponsesMode.value !== 'auto'
) {
extra.openai_responses_mode = openAIResponsesMode.value
} else {
delete extra.openai_responses_mode

View File

@ -1452,13 +1452,24 @@
<Select
v-model="openAIResponsesMode"
:options="openAIResponsesModeOptions"
:disabled="!openAITextGenerationCapabilityEnabled"
data-testid="openai-responses-mode-select"
/>
</div>
</div>
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-700 dark:text-gray-300">
<div
v-if="openAITextGenerationCapabilityEnabled"
class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-700 dark:text-gray-300"
>
<span class="font-medium">{{ t(openAIResponsesStatusKey) }}</span>
</div>
<div
v-else
class="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
data-testid="openai-responses-mode-not-applicable"
>
{{ t('admin.accounts.openai.responsesModeTextDisabledHint') }}
</div>
<div>
<label class="input-label mb-2 block">{{ t('admin.accounts.openai.endpointCapabilities') }}</label>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
@ -2568,10 +2579,29 @@ const openAIResponsesModeOptions = computed(() => [
{ value: 'force_responses', label: t('admin.accounts.openai.responsesModeForceResponses') },
{ value: 'force_chat_completions', label: t('admin.accounts.openai.responsesModeForceChatCompletions') }
])
const openAITextEndpointCapabilityLabel = computed(() => {
if (openAIResponsesMode.value === 'force_responses') {
return t('admin.accounts.openai.capabilityResponses')
}
if (openAIResponsesMode.value === 'force_chat_completions') {
return t('admin.accounts.openai.capabilityChatCompletions')
}
const extra = props.account?.extra as Record<string, unknown> | undefined
if (extra?.openai_responses_supported === true) {
return t('admin.accounts.openai.capabilityResponsesAuto')
}
if (extra?.openai_responses_supported === false) {
return t('admin.accounts.openai.capabilityChatCompletionsAuto')
}
return t('admin.accounts.openai.capabilityTextAuto')
})
const openAIEndpointCapabilityOptions = computed<{ value: OpenAIEndpointCapability; label: string }[]>(() => [
{ value: 'chat_completions', label: t('admin.accounts.openai.capabilityChatCompletions') },
{ value: 'chat_completions', label: openAITextEndpointCapabilityLabel.value },
{ value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }
])
const openAITextGenerationCapabilityEnabled = computed(() =>
openAIEndpointCapabilities.value.includes('chat_completions')
)
const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => {
const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings']
@ -2609,6 +2639,9 @@ const toggleOpenAIEndpointCapability = (capability: OpenAIEndpointCapability, ev
openAIEndpointCapabilities.value = openAIEndpointCapabilities.value.filter(
(value) => value !== capability
)
if (!openAITextGenerationCapabilityEnabled.value) {
openAIResponsesMode.value = 'auto'
}
return
}
openAIEndpointCapabilities.value = normalizeOpenAIEndpointCapabilities([
@ -2826,6 +2859,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
openAIEndpointCapabilities.value = readOpenAIEndpointCapabilities(
newAccount.credentials as Record<string, unknown> | undefined
)
if (!openAITextGenerationCapabilityEnabled.value) {
openAIResponsesMode.value = 'auto'
}
}
const codexImageGenerationBridgeValue = typeof extra?.codex_image_generation_bridge === 'boolean'
? extra.codex_image_generation_bridge
@ -3945,7 +3981,7 @@ const handleSubmit = async () => {
newExtra.openai_compact_mode = openAICompactMode.value
}
if (props.account.type === 'apikey') {
if (openAIResponsesMode.value === 'auto') {
if (!openAITextGenerationCapabilityEnabled.value || openAIResponsesMode.value === 'auto') {
delete newExtra.openai_responses_mode
} else {
newExtra.openai_responses_mode = openAIResponsesMode.value

View File

@ -367,6 +367,37 @@ describe('EditAccountModal', () => {
])
})
it('disables text generation protocol when only embeddings requests are accepted', async () => {
const account = buildAccount()
account.credentials.openai_capabilities = ['embeddings']
account.extra = {
openai_responses_mode: 'force_responses',
openai_responses_supported: true
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const responsesModeSelect = wrapper.get<HTMLSelectElement>(
'[data-testid="openai-responses-mode-select"]'
)
expect(responsesModeSelect.element.disabled).toBe(true)
expect(wrapper.find('[data-testid="openai-responses-mode-not-applicable"]').exists()).toBe(true)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.openai_capabilities).toEqual([
'embeddings'
])
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('openai_responses_mode')
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_supported).toBe(true)
})
it('submits account-level Codex image generation bridge override', async () => {
const account = buildAccount()
account.extra = {

View File

@ -3349,14 +3349,20 @@ export default {
'Automatic passthrough is currently enabled: it only affects HTTP passthrough and does not disable WS mode.',
responsesMode: 'Responses API support',
responsesModeDesc:
'Only applies to OpenAI API Key accounts. Auto follows probe results; force modes override probing.',
'Only applies to the OpenAI API Key text forwarding path. Auto follows probe results; force modes override probing.',
responsesModeAuto: 'Auto',
responsesModeForceResponses: 'Force Responses',
responsesModeForceChatCompletions: 'Force Chat Completions',
responsesModeTextDisabledHint:
'Not applicable when the Responses / Chat Completions endpoint is not enabled.',
endpointCapabilities: 'Endpoint capabilities',
endpointCapabilitiesDesc:
'Used by account routing. Both endpoints are allowed by default; if the upstream only supports one, select only the supported endpoint.',
'Used by account routing. The text endpoint follows the Responses API support setting above and is shown as Responses, Chat Completions, or auto mode; Embeddings independently controls /v1/embeddings.',
capabilityResponses: 'Responses',
capabilityTextAuto: 'Responses / Chat Completions (Auto)',
capabilityResponsesAuto: 'Responses (auto probe)',
capabilityChatCompletions: 'Chat Completions',
capabilityChatCompletionsAuto: 'Chat Completions (auto probe)',
capabilityEmbeddings: 'Embeddings',
responsesStatusAutoSupported: 'Auto probe: Responses',
responsesStatusAutoUnsupported: 'Auto probe: Chat Completions',

View File

@ -3495,14 +3495,19 @@ export default {
responsesWebsocketsV2PassthroughHint: '当前已开启自动透传:仅影响 HTTP 透传链路,不影响 WS mode。',
responsesMode: 'Responses API 支持',
responsesModeDesc:
'仅对 OpenAI API Key 生效。自动跟随探测结果,强制模式会覆盖自动探测。',
'仅对 OpenAI API Key 的文本转发链路生效。自动跟随探测结果,强制模式会覆盖自动探测。',
responsesModeAuto: '自动',
responsesModeForceResponses: '强制 Responses',
responsesModeForceChatCompletions: '强制 Chat Completions',
responsesModeTextDisabledHint: '未启用 Responses / Chat Completions 端点时,此设置不适用。',
endpointCapabilities: '端点能力',
endpointCapabilitiesDesc:
'用于调度筛选。默认两个端点都可用;如果上游只支持其中一个,请只勾选实际支持的端点。',
'用于调度筛选。文本端点会跟随上方 Responses API 支持显示为 Responses、Chat Completions 或自动模式Embeddings 独立控制 /v1/embeddings。',
capabilityResponses: 'Responses',
capabilityTextAuto: 'Responses / Chat Completions自动',
capabilityResponsesAuto: 'Responses自动探测',
capabilityChatCompletions: 'Chat Completions',
capabilityChatCompletionsAuto: 'Chat Completions自动探测',
capabilityEmbeddings: 'Embeddings',
responsesStatusAutoSupported: '自动探测Responses',
responsesStatusAutoUnsupported: '自动探测Chat Completions',