diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts index e19ac1b7..0a248f95 100644 --- a/desktop/src/api/providers.ts +++ b/desktop/src/api/providers.ts @@ -61,7 +61,7 @@ export const providersApi = { return api.post<{ ok: true }>('/api/providers/official') }, - test(id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string }) { + test(id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string }) { return api.post(`/api/providers/${id}/test`, overrides) }, diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 61ab89d3..af6eed67 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -218,6 +218,17 @@ export const en = { 'settings.providers.apiFormatOpenaiChat': 'OpenAI Chat Completions (proxy)', 'settings.providers.apiFormatOpenaiResponses': 'OpenAI Responses API (proxy)', 'settings.providers.proxyHint': 'Requests will be translated via the local proxy', + 'settings.providers.authStrategy': 'Auth Variable', + 'settings.providers.authStrategyApiKey': 'API Key (ANTHROPIC_API_KEY)', + 'settings.providers.authStrategyApiKeyDesc': 'Direct Anthropic API access using x-api-key.', + 'settings.providers.authStrategyAuthToken': 'Bearer Token (ANTHROPIC_AUTH_TOKEN)', + 'settings.providers.authStrategyAuthTokenDesc': 'Most third-party Anthropic-compatible services using Authorization Bearer.', + 'settings.providers.authStrategyAuthTokenEmptyApiKey': 'Bearer + Empty API_KEY', + 'settings.providers.authStrategyAuthTokenEmptyApiKeyDesc': 'OpenRouter/Ollama-style setups that must avoid falling back to an Anthropic API key.', + 'settings.providers.authStrategyDualSameToken': 'Write token to both variables', + 'settings.providers.authStrategyDualSameTokenDesc': 'Services such as Hugging Face Router that check both variables.', + 'settings.providers.authStrategyDualDummy': 'Write dummy to both variables', + 'settings.providers.authStrategyDualDummyDesc': 'Local vLLM-compatible services that only need placeholder auth values.', // Settings > Permissions 'settings.permissions.title': 'Permission Mode', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 42bf3744..b8104073 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -220,6 +220,17 @@ export const zh: Record = { 'settings.providers.apiFormatOpenaiChat': 'OpenAI Chat Completions (代理转换)', 'settings.providers.apiFormatOpenaiResponses': 'OpenAI Responses API (代理转换)', 'settings.providers.proxyHint': '请求将通过本地代理转换协议格式', + 'settings.providers.authStrategy': '认证变量', + 'settings.providers.authStrategyApiKey': 'API Key (ANTHROPIC_API_KEY)', + 'settings.providers.authStrategyApiKeyDesc': '直连 Anthropic 官方 API,发送 x-api-key。', + 'settings.providers.authStrategyAuthToken': 'Bearer Token (ANTHROPIC_AUTH_TOKEN)', + 'settings.providers.authStrategyAuthTokenDesc': '大多数第三方 Anthropic 兼容服务,发送 Authorization Bearer。', + 'settings.providers.authStrategyAuthTokenEmptyApiKey': 'Bearer + 清空 API_KEY', + 'settings.providers.authStrategyAuthTokenEmptyApiKeyDesc': 'OpenRouter/Ollama 等场景,避免回退到 Anthropic API key。', + 'settings.providers.authStrategyDualSameToken': '两个变量写同一个 Token', + 'settings.providers.authStrategyDualSameTokenDesc': 'Hugging Face Router 等同时检查两个变量的服务。', + 'settings.providers.authStrategyDualDummy': '两个变量写 dummy', + 'settings.providers.authStrategyDualDummyDesc': 'vLLM 等本地兼容服务,只需要占位认证值。', // Settings > Permissions 'settings.permissions.title': '权限模式', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 69ba2308..641ec6a7 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -9,7 +9,7 @@ import { Button } from '../components/shared/Button' import { Dropdown } from '../components/shared/Dropdown' import type { PermissionMode, EffortLevel, ThemeMode, WebSearchMode } from '../types/settings' import type { Locale } from '../i18n' -import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider' +import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' import { AdapterSettings } from './AdapterSettings' import { useAgentStore } from '../stores/agentStore' @@ -349,6 +349,8 @@ const MODEL_CONTEXT_WINDOWS_ENV_KEY = 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS' const MODEL_CONTEXT_WINDOW_MIN = 16000 const MODEL_CONTEXT_WINDOW_MAX = 10000000 const MODEL_SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const +const DEFAULT_PROVIDER_AUTH_STRATEGY: ProviderAuthStrategy = 'auth_token' +const AUTH_ENV_KEYS = new Set(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN']) type ModelSlot = typeof MODEL_SLOTS[number] type ModelContextInputs = Record @@ -360,6 +362,58 @@ function getPresetAutoCompactWindow(preset: ProviderPreset): string { return preset.defaultEnv?.[AUTO_COMPACT_WINDOW_ENV_KEY] ?? '' } +function getPresetAuthStrategy(preset: ProviderPreset): ProviderAuthStrategy { + return preset.authStrategy ?? DEFAULT_PROVIDER_AUTH_STRATEGY +} + +function omitAuthEnv(env: Record | undefined): Record { + if (!env) return {} + return Object.fromEntries( + Object.entries(env).filter(([key]) => !AUTH_ENV_KEYS.has(key.toUpperCase())), + ) +} + +function getProviderAuthValue(apiKey: string, preset: ProviderPreset): string { + return apiKey || preset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || preset.defaultEnv?.ANTHROPIC_API_KEY || (preset.needsApiKey ? '(your API key)' : '') +} + +function buildSettingsJsonAuthEnv( + apiFormat: ApiFormat, + authStrategy: ProviderAuthStrategy, + apiKey: string, + preset: ProviderPreset, +): Record { + if (apiFormat !== 'anthropic') { + return { ANTHROPIC_API_KEY: 'proxy-managed' } + } + + const value = getProviderAuthValue(apiKey, preset) + switch (authStrategy) { + case 'api_key': + return value ? { ANTHROPIC_API_KEY: value } : {} + case 'auth_token': + return value ? { ANTHROPIC_AUTH_TOKEN: value } : {} + case 'auth_token_empty_api_key': + return { + ANTHROPIC_API_KEY: '', + ...(value ? { ANTHROPIC_AUTH_TOKEN: value } : {}), + } + case 'dual_same_token': + return value ? { ANTHROPIC_API_KEY: value, ANTHROPIC_AUTH_TOKEN: value } : {} + case 'dual_dummy': + return { ANTHROPIC_API_KEY: 'dummy', ANTHROPIC_AUTH_TOKEN: 'dummy' } + } +} + +function inferAuthStrategyFromEnv(env: Record): ProviderAuthStrategy | null { + if (env.ANTHROPIC_API_KEY === 'dummy' && env.ANTHROPIC_AUTH_TOKEN === 'dummy') return 'dual_dummy' + if (env.ANTHROPIC_API_KEY === '' && env.ANTHROPIC_AUTH_TOKEN) return 'auth_token_empty_api_key' + if (env.ANTHROPIC_API_KEY && env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_API_KEY === env.ANTHROPIC_AUTH_TOKEN) return 'dual_same_token' + if (env.ANTHROPIC_AUTH_TOKEN) return 'auth_token' + if (env.ANTHROPIC_API_KEY) return 'api_key' + return null +} + function parseAutoCompactWindowInput(value: string): number | undefined { const trimmed = value.trim() if (!trimmed) return undefined @@ -485,12 +539,38 @@ function updateSettingsJsonModels(raw: string, models: ModelMapping): string { } } +function updateSettingsJsonProviderConnection( + raw: string, + apiFormat: ApiFormat, + authStrategy: ProviderAuthStrategy, + apiKey: string, + preset: ProviderPreset, + baseUrl: string, +): string { + try { + const parsed = JSON.parse(raw || '{}') as { env?: Record } + const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env) + ? parsed.env + : {} + const env = { ...existingEnv } + delete env.ANTHROPIC_API_KEY + delete env.ANTHROPIC_AUTH_TOKEN + env.ANTHROPIC_BASE_URL = apiFormat !== 'anthropic' ? 'http://127.0.0.1:3456/proxy' : baseUrl + Object.assign(env, buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, preset)) + parsed.env = env + return JSON.stringify(parsed, null, 2) + } catch { + return raw + } +} + function buildFallbackPreset(provider?: SavedProvider): ProviderPreset { return { id: provider?.presetId ?? 'custom', name: provider?.name ?? 'Custom', baseUrl: provider?.baseUrl ?? '', apiFormat: provider?.apiFormat ?? 'anthropic', + authStrategy: provider?.authStrategy, defaultModels: provider?.models ?? { main: '', haiku: '', sonnet: '', opus: '' }, modelContextWindows: provider?.modelContextWindows, defaultEnv: provider?.autoCompactWindow !== undefined @@ -537,6 +617,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF const [name, setName] = useState(provider?.name ?? initialPreset.name) const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl) const [apiFormat, setApiFormat] = useState(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic') + const [authStrategy, setAuthStrategy] = useState(provider?.authStrategy ?? getPresetAuthStrategy(initialPreset)) const [apiKey, setApiKey] = useState(provider?.apiKey ?? '') const [showApiKey, setShowApiKey] = useState(false) const [notes, setNotes] = useState(provider?.notes ?? '') @@ -576,15 +657,13 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF skipWebFetchPreflight: settings.skipWebFetchPreflight ?? true, env: { ...cleanedEnv, - ...(selectedPreset.defaultEnv ?? {}), + ...omitAuthEnv(selectedPreset.defaultEnv), ...(autoCompactWindowEnv ? { [AUTO_COMPACT_WINDOW_ENV_KEY]: autoCompactWindowEnv } : {}), ...(Object.keys(modelContextWindows).length > 0 ? { [MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows) } : {}), ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl, - ANTHROPIC_AUTH_TOKEN: needsProxy - ? 'proxy-managed' - : (apiKey || selectedPreset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || (selectedPreset.needsApiKey ? '(your API key)' : '')), + ...buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, selectedPreset), ANTHROPIC_MODEL: models.main, ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, @@ -604,6 +683,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF setName(preset.name) setBaseUrl(preset.baseUrl) setApiFormat(preset.apiFormat ?? 'anthropic') + setAuthStrategy(getPresetAuthStrategy(preset)) setModels({ ...preset.defaultModels }) setModelContextInputs(getModelContextInputs(preset.defaultModels, preset)) setAutoCompactWindow(getPresetAutoCompactWindow(preset)) @@ -639,6 +719,39 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF }, ] const selectedApiFormatLabel = apiFormatItems.find((item) => item.value === apiFormat)?.label ?? t('settings.providers.apiFormatAnthropic') + const authStrategyItems = [ + { + value: 'auth_token' as const, + label: t('settings.providers.authStrategyAuthToken'), + description: t('settings.providers.authStrategyAuthTokenDesc'), + icon: key, + }, + { + value: 'auth_token_empty_api_key' as const, + label: t('settings.providers.authStrategyAuthTokenEmptyApiKey'), + description: t('settings.providers.authStrategyAuthTokenEmptyApiKeyDesc'), + icon: key_off, + }, + { + value: 'api_key' as const, + label: t('settings.providers.authStrategyApiKey'), + description: t('settings.providers.authStrategyApiKeyDesc'), + icon: vpn_key, + }, + { + value: 'dual_same_token' as const, + label: t('settings.providers.authStrategyDualSameToken'), + description: t('settings.providers.authStrategyDualSameTokenDesc'), + icon: sync_alt, + }, + { + value: 'dual_dummy' as const, + label: t('settings.providers.authStrategyDualDummy'), + description: t('settings.providers.authStrategyDualDummyDesc'), + icon: construction, + }, + ] satisfies Array<{ value: ProviderAuthStrategy; label: string; description: string; icon: ReactNode }> + const selectedAuthStrategyLabel = authStrategyItems.find((item) => item.value === authStrategy)?.label ?? t('settings.providers.authStrategyAuthToken') const configuredContextWindows = buildModelContextWindows(models, modelContextInputs) const configuredContextSummary = Object.entries(configuredContextWindows) .filter(([model], index, entries) => entries.findIndex(([candidate]) => candidate === model) === index) @@ -657,6 +770,22 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF setAutoCompactWindow(value) setSettingsJson((current) => updateSettingsJsonAutoCompactWindow(current, value)) } + const handleBaseUrlChange = (value: string) => { + setBaseUrl(value) + setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, apiKey, selectedPreset, value)) + } + const handleApiKeyChange = (value: string) => { + setApiKey(value) + setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, value, selectedPreset, baseUrl)) + } + const handleApiFormatChange = (value: ApiFormat) => { + setApiFormat(value) + setSettingsJson((current) => updateSettingsJsonProviderConnection(current, value, authStrategy, apiKey, selectedPreset, baseUrl)) + } + const handleAuthStrategyChange = (value: ProviderAuthStrategy) => { + setAuthStrategy(value) + setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, value, apiKey, selectedPreset, baseUrl)) + } const handleModelChange = (slot: ModelSlot, value: string) => { const nextModels = { ...models, [slot]: value } const nextInputs = { @@ -715,6 +844,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF presetId: selectedPreset.id, name: name.trim(), apiKey: apiKey.trim(), + authStrategy, baseUrl: baseUrl.trim(), apiFormat, models, @@ -726,6 +856,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF const input: UpdateProviderInput = { name: name.trim(), baseUrl: baseUrl.trim(), + authStrategy, apiFormat, models, autoCompactWindow: parsedAutoCompactWindow ?? null, @@ -757,6 +888,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF baseUrl: baseUrl.trim(), modelId: models.main.trim(), apiFormat, + authStrategy, }) } else { if (requiresApiKey && !apiKey.trim()) return @@ -764,6 +896,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF baseUrl: baseUrl.trim(), apiKey: apiKey.trim() || selectedPreset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || 'local', modelId: models.main.trim(), + authStrategy, apiFormat, }) } @@ -812,7 +945,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF setNotes(e.target.value)} placeholder={t('settings.providers.notesPlaceholder')} /> - setBaseUrl(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} /> + handleBaseUrlChange(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} /> {/* API Format */} {(isCustom || mode === 'edit') ? ( @@ -821,7 +954,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF items={apiFormatItems} value={apiFormat} - onChange={setApiFormat} + onChange={handleApiFormatChange} width="100%" className="block w-full" trigger={ @@ -847,6 +980,28 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF ) : null} + {apiFormat === 'anthropic' && ( +
+ + + items={authStrategyItems} + value={authStrategy} + onChange={handleAuthStrategyChange} + width="100%" + className="block w-full" + trigger={ + + } + /> +
+ )} +