feat(desktop): let presets switch API protocol without rewriting the endpoint

A preset used to lock its protocol, so a dual-protocol endpoint could not be pointed at the other format. Keep the preset URL and warn when the chosen protocol no longer matches it.
This commit is contained in:
程序员阿江(Relakkes)
2026-09-22 01:24:18 +08:00
parent b3c09a1e4b
commit 4704fb8310
7 changed files with 57 additions and 8 deletions
+1
View File
@@ -1340,6 +1340,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'settings.providers.multiProtocolBadge': 'Multi-protocol',
'settings.providers.apiFormatPerModelHint': 'This preset picks the protocol per model, so the choice is fixed by it',
'settings.providers.proxyHint': 'Requests will be translated via the local proxy',
'settings.providers.apiFormatOverrideHint': 'Protocol changed from the preset default — point the base URL at an endpoint that serves it',
'settings.providers.authStrategy': 'Auth Variable',
'settings.providers.authStrategyApiKey': 'API Key (ANTHROPIC_API_KEY)',
'settings.providers.authStrategyApiKeyDesc': 'Direct Anthropic API access using x-api-key.',
+1
View File
@@ -1342,6 +1342,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.providers.multiProtocolBadge': 'マルチプロトコル',
'settings.providers.apiFormatPerModelHint': 'このプリセットはモデルごとにプロトコルを選ぶため、この項目は固定です',
'settings.providers.proxyHint': 'リクエストはローカルプロキシ経由で変換されます',
'settings.providers.apiFormatOverrideHint': 'プロトコルを変更しました。プリセットの既定 URL が対応しているとは限らないため、このプロトコルに対応したエンドポイントを指定してください',
'settings.providers.authStrategy': '認証変数',
'settings.providers.authStrategyApiKey': 'API キー (ANTHROPIC_API_KEY)',
'settings.providers.authStrategyApiKeyDesc': 'x-api-key を使用した Anthropic API への直接アクセス。',
+1
View File
@@ -1344,6 +1344,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.providers.multiProtocolBadge': '멀티 프로토콜',
'settings.providers.apiFormatPerModelHint': '이 프리셋은 모델별로 프로토콜을 선택하므로 이 항목은 고정됩니다',
'settings.providers.proxyHint': '요청은 로컬 프록시를 통해 변환됩니다',
'settings.providers.apiFormatOverrideHint': '프로토콜을 변경했습니다. 프리셋 기본 URL이 해당 프로토콜을 지원하지 않을 수 있으니 호환되는 엔드포인트를 지정하세요',
'settings.providers.authStrategy': '인증 변수',
'settings.providers.authStrategyApiKey': 'API 키 (ANTHROPIC_API_KEY)',
'settings.providers.authStrategyApiKeyDesc': 'x-api-key를 사용한 Anthropic API 직접 액세스.',
+1
View File
@@ -1341,6 +1341,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.multiProtocolBadge': '多協定',
'settings.providers.apiFormatPerModelHint': '此預設會依模型自動選擇協定,本項由預設決定',
'settings.providers.proxyHint': '請求將透過本地代理轉換協議格式',
'settings.providers.apiFormatOverrideHint': '已切換協定:請確認介面位址是該協定的相容端點,預設位址可能不適用',
'settings.providers.authStrategy': '認證變數',
'settings.providers.authStrategyApiKey': 'API Key (ANTHROPIC_API_KEY)',
'settings.providers.authStrategyApiKeyDesc': '直連 Anthropic 官方 API,傳送 x-api-key。',
+1
View File
@@ -1340,6 +1340,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.multiProtocolBadge': '多协议',
'settings.providers.apiFormatPerModelHint': '该预设按模型自动选择协议,此项由预设决定',
'settings.providers.proxyHint': '请求将通过本地代理转换协议格式',
'settings.providers.apiFormatOverrideHint': '已切换协议:请确认接口地址是该协议的兼容端点,预设默认地址可能不适用',
'settings.providers.authStrategy': '认证变量',
'settings.providers.authStrategyApiKey': 'API Key (ANTHROPIC_API_KEY)',
'settings.providers.authStrategyApiKeyDesc': '直连 Anthropic 官方 API,发送 x-api-key。',
@@ -80,6 +80,47 @@ describe('ApiSmart sponsor provider', () => {
})))
})
it('lets a preset switch protocol while the preset endpoint stays put', async () => {
const create = vi.spyOn(providersApi, 'create').mockImplementation(async (input) => ({
provider: { ...input, id: 'saved-aruhub', apiFormat: input.apiFormat ?? 'anthropic' },
}))
render(<ProviderSettings />)
fireEvent.click(await screen.findByRole('button', { name: /Add Model/ }))
const dialog = within(screen.getByRole('dialog'))
fireEvent.click(dialog.getByRole('button', { name: 'AruHub' }))
// AruHub is an Anthropic-endpoint preset that also serves OpenAI, so the
// protocol starts on the preset's own value and is the user's to change.
const formatTrigger = dialog.getByRole('button', { name: /Anthropic Messages \(native\)/ })
expect(dialog.queryByText(/point the base URL at an endpoint that serves it/)).not.toBeInTheDocument()
fireEvent.click(formatTrigger)
fireEvent.click(await screen.findByRole('option', { name: /OpenAI Chat Completions/ }))
expect(dialog.getByText(/point the base URL at an endpoint that serves it/)).toBeInTheDocument()
// The address is the user's to replace, so switching must not rewrite it.
expect(dialog.getByDisplayValue('https://direct.aruhub.com:8443')).toBeInTheDocument()
fireEvent.change(dialog.getAllByPlaceholderText('sk-...')[0]!, { target: { value: 'fake-aruhub-key' } })
fireEvent.click(dialog.getByRole('button', { name: 'Add' }))
await waitFor(() => expect(create).toHaveBeenCalledWith(expect.objectContaining({
presetId: 'aruhub',
baseUrl: 'https://direct.aruhub.com:8443',
apiFormat: 'openai_chat',
apiKey: 'fake-aruhub-key',
})))
})
it('does not warn about the endpoint while a preset keeps its own protocol', async () => {
render(<ProviderSettings />)
fireEvent.click(await screen.findByRole('button', { name: /Add Model/ }))
const dialog = within(screen.getByRole('dialog'))
fireEvent.click(dialog.getByRole('button', { name: 'ApiSmart' }))
expect(dialog.getByRole('button', { name: /OpenAI Chat Completions \(proxy\)/ })).toBeInTheDocument()
expect(dialog.queryByText(/point the base URL at an endpoint that serves it/)).not.toBeInTheDocument()
})
it('prefills the sponsor connection, opens its landing page, and saves the selected models', async () => {
const open = vi.spyOn(getDesktopHost().shell, 'open').mockResolvedValue()
const create = vi.spyOn(providersApi, 'create').mockImplementation(async (input) => ({
@@ -1215,7 +1215,6 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
setTestResult(null)
}
const isCustom = selectedPreset.id === 'custom'
const requiresApiKey = selectedPreset.needsApiKey !== false
const autoCompactWindowErrorKey = getAutoCompactWindowErrorKey(autoCompactWindow)
const modelContextWindowErrorSlots = MODEL_SLOTS.filter((slot) => getModelContextWindowErrorKey(modelContextInputs[slot]))
@@ -1878,8 +1877,9 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
<Input label={t('settings.providers.notes')} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t('settings.providers.notesPlaceholder')} />
{/* API Format */}
{(isCustom || mode === 'edit') && !presetDrivesApiFormat ? (
{/* API Format — a preset only owns this field when it routes per model;
every other preset starts on its own format but stays switchable. */}
{!presetDrivesApiFormat ? (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
<Dropdown<ApiFormat>
@@ -1898,18 +1898,21 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
{apiFormat !== 'anthropic' && (
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.proxyHint')}</p>
)}
{/* The preset's own endpoint is still in the field above; a custom
preset brings none, so there is nothing to warn about. */}
{apiFormat !== selectedPreset.apiFormat && Boolean(selectedPreset.baseUrl) && (
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.apiFormatOverrideHint')}</p>
)}
</div>
) : (presetDrivesApiFormat || apiFormat !== 'anthropic') ? (
) : (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
<div className="text-xs text-[var(--color-text-tertiary)] px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)]">
{selectedApiFormatLabel}
</div>
{presetDrivesApiFormat && (
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.apiFormatPerModelHint')}</p>
)}
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.apiFormatPerModelHint')}</p>
</div>
) : null}
)}
<ProviderRequestCompatibilityFields value={compatibility} apiFormat={apiFormat} onChange={handleCompatibilityChange} />