fix: honor provider auth env strategy

Provider presets now carry an explicit auth strategy so Anthropic-compatible services can choose the right Claude Code environment shape instead of relying on one global variable. The desktop editor exposes the same strategy, keeps settings.json previews in sync, and applies the strategy to connection tests as well as runtime env sync.

Constraint: Third-party Anthropic-compatible providers do not agree on ANTHROPIC_API_KEY versus ANTHROPIC_AUTH_TOKEN.

Rejected: Force all third-party providers onto one env var | breaks providers that require bearer auth, dual variables, or dummy local auth.

Confidence: high

Scope-risk: moderate

Directive: Add new provider presets with explicit authStrategy; do not infer auth behavior from model names alone.

Tested: bun test src/server/__tests__/providers.test.ts src/server/__tests__/providers-real.test.ts src/server/__tests__/provider-presets.test.ts src/server/__tests__/conversation-service.test.ts

Tested: cd desktop && bun run lint

Tested: ALLOW_CLI_CORE_CHANGE=1 bun run quality:pr

Tested: bun run quality:gate --mode baseline --allow-live --provider-model minimax:main:minimax-main

Tested: agent-browser provider edit flow on http://127.0.0.1:19811

Not-tested: Live successful calls for every third-party provider with real credentials.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-03 17:30:59 +08:00
parent 299c50a1e0
commit a43460f28d
16 changed files with 488 additions and 30 deletions

View File

@ -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<TestResultResponse>(`/api/providers/${id}/test`, overrides)
},

View File

@ -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',

View File

@ -220,6 +220,17 @@ export const zh: Record<TranslationKey, string> = {
'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': '权限模式',

View File

@ -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<ModelSlot, string>
@ -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<string, string> | undefined): Record<string, string> {
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<string, string> {
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<string, string>): 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<string, unknown> }
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<ApiFormat>(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic')
const [authStrategy, setAuthStrategy] = useState<ProviderAuthStrategy>(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: <span className="material-symbols-outlined text-[17px]">key</span>,
},
{
value: 'auth_token_empty_api_key' as const,
label: t('settings.providers.authStrategyAuthTokenEmptyApiKey'),
description: t('settings.providers.authStrategyAuthTokenEmptyApiKeyDesc'),
icon: <span className="material-symbols-outlined text-[17px]">key_off</span>,
},
{
value: 'api_key' as const,
label: t('settings.providers.authStrategyApiKey'),
description: t('settings.providers.authStrategyApiKeyDesc'),
icon: <span className="material-symbols-outlined text-[17px]">vpn_key</span>,
},
{
value: 'dual_same_token' as const,
label: t('settings.providers.authStrategyDualSameToken'),
description: t('settings.providers.authStrategyDualSameTokenDesc'),
icon: <span className="material-symbols-outlined text-[17px]">sync_alt</span>,
},
{
value: 'dual_dummy' as const,
label: t('settings.providers.authStrategyDualDummy'),
description: t('settings.providers.authStrategyDualDummyDesc'),
icon: <span className="material-symbols-outlined text-[17px]">construction</span>,
},
] 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
<Input label={t('settings.providers.notes')} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t('settings.providers.notesPlaceholder')} />
<Input label={t('settings.providers.baseUrl')} required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} />
<Input label={t('settings.providers.baseUrl')} required value={baseUrl} onChange={(e) => 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
<Dropdown<ApiFormat>
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
</div>
) : null}
{apiFormat === 'anthropic' && (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.authStrategy')}</label>
<Dropdown<ProviderAuthStrategy>
items={authStrategyItems}
value={authStrategy}
onChange={handleAuthStrategyChange}
width="100%"
className="block w-full"
trigger={
<button
type="button"
className="flex min-h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="min-w-0 flex-1 truncate">{selectedAuthStrategyLabel}</span>
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
</button>
}
/>
</div>
)}
<div className="flex flex-col gap-1">
<label htmlFor="provider-api-key" className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.providers.apiKey')}
@ -857,7 +1012,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
id="provider-api-key"
type={showApiKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder="sk-..."
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 pr-10 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]"
/>
@ -1060,6 +1215,10 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
if (nextApiKey && nextApiKey !== '(your API key)' && nextApiKey !== API_KEY_JSON_PLACEHOLDER) {
setApiKey(nextApiKey)
}
const nextAuthStrategy = inferAuthStrategyFromEnv(env)
if (nextAuthStrategy) {
setAuthStrategy(nextAuthStrategy)
}
if (env[AUTO_COMPACT_WINDOW_ENV_KEY] !== undefined) {
setAutoCompactWindow(String(env[AUTO_COMPACT_WINDOW_ENV_KEY]))
} else {

View File

@ -29,7 +29,7 @@ type ProviderStore = {
deleteProvider: (id: string) => Promise<void>
activateProvider: (id: string) => Promise<void>
activateOfficial: () => Promise<void>
testProvider: (id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string }) => Promise<ProviderTestResult>
testProvider: (id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string }) => Promise<ProviderTestResult>
testConfig: (input: TestProviderConfigInput) => Promise<ProviderTestResult>
}

View File

@ -2,6 +2,13 @@
export type ApiFormat = 'anthropic' | 'openai_chat' | 'openai_responses'
export type ProviderAuthStrategy =
| 'api_key'
| 'auth_token'
| 'auth_token_empty_api_key'
| 'dual_same_token'
| 'dual_dummy'
export type ModelMapping = {
main: string
haiku: string
@ -16,6 +23,7 @@ export type SavedProvider = {
presetId: string
name: string
apiKey: string // masked from server
authStrategy?: ProviderAuthStrategy
baseUrl: string
apiFormat: ApiFormat
models: ModelMapping
@ -28,6 +36,7 @@ export type CreateProviderInput = {
presetId: string
name: string
apiKey: string
authStrategy?: ProviderAuthStrategy
baseUrl: string
apiFormat?: ApiFormat
models: ModelMapping
@ -39,6 +48,7 @@ export type CreateProviderInput = {
export type UpdateProviderInput = {
name?: string
apiKey?: string
authStrategy?: ProviderAuthStrategy
baseUrl?: string
apiFormat?: ApiFormat
models?: ModelMapping
@ -51,6 +61,7 @@ export type TestProviderConfigInput = {
baseUrl: string
apiKey: string
modelId: string
authStrategy?: ProviderAuthStrategy
apiFormat?: ApiFormat
}

View File

@ -1,4 +1,4 @@
import type { ApiFormat } from './provider'
import type { ApiFormat, ProviderAuthStrategy } from './provider'
export type ModelMapping = {
main: string
@ -18,6 +18,7 @@ export type ProviderPreset = {
apiKeyUrl?: string
promoText?: string
featured?: boolean
authStrategy?: ProviderAuthStrategy
defaultEnv?: Record<string, string>
modelContextWindows?: Record<string, number>
}

View File

@ -214,6 +214,8 @@ describe('ConversationService', () => {
})) as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('provider-key')
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe('none')
})

View File

@ -72,29 +72,37 @@ describe('provider presets API', () => {
expect(lmstudio?.baseUrl).toBe('http://localhost:1234')
expect(lmstudio?.apiFormat).toBe('anthropic')
expect(lmstudio?.authStrategy).toBe('auth_token_empty_api_key')
expect(lmstudio?.defaultModels.main).toBe('qwen/qwen3.6-27b')
expect(ollama?.baseUrl).toBe('http://localhost:11434')
expect(ollama?.apiFormat).toBe('anthropic')
expect(ollama?.authStrategy).toBe('auth_token_empty_api_key')
expect(ollama?.defaultModels.main).toBe('qwen3.6:27b')
expect(deepseek?.authStrategy).toBe('auth_token')
expect(deepseek?.defaultModels.main).toBe('deepseek-v4-pro')
expect(deepseek?.defaultModels.haiku).toBe('deepseek-v4-flash')
expect(deepseek?.defaultModels.sonnet).toBe('deepseek-v4-pro')
expect(deepseek?.defaultModels.opus).toBe('deepseek-v4-pro')
expect(deepseek?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBe('1')
expect(zhipu?.authStrategy).toBe('auth_token')
expect(zhipu?.defaultModels.main).toBe('glm-5.1')
expect(zhipu?.defaultModels.haiku).toBe('glm-4.5-air')
expect(zhipu?.defaultModels.sonnet).toBe('glm-5-turbo')
expect(zhipu?.defaultModels.opus).toBe('glm-5.1')
expect(kimi?.baseUrl).toBe('https://api.kimi.com/coding')
expect(kimi?.authStrategy).toBe('auth_token')
expect(kimi?.defaultModels.main).toBe('kimi-k2.6')
expect(kimi?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBe('1')
expect(minimax?.authStrategy).toBe('auth_token')
expect(minimax?.defaultModels.main).toBe('MiniMax-M2.7')
expect(minimax?.modelContextWindows?.['MiniMax-M2.7']).toBe(204800)
expect(jiekouai?.baseUrl).toBe('https://api.jiekou.ai/anthropic')
expect(jiekouai?.authStrategy).toBe('auth_token')
expect(jiekouai?.defaultModels.main).toBe('claude-sonnet-4-6')
expect(jiekouai?.defaultModels.opus).toBe('claude-opus-4-7')
expect(jiekouai?.defaultEnv?.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe('none')
expect(shengsuanyun?.baseUrl).toBe('https://router.shengsuanyun.com/api')
expect(shengsuanyun?.authStrategy).toBe('auth_token')
expect(shengsuanyun?.defaultModels.main).toBe('anthropic/claude-sonnet-4.6')
expect(shengsuanyun?.defaultModels.haiku).toBe('anthropic/claude-haiku-4.5:thinking')
})
@ -146,6 +154,7 @@ describe('provider presets API', () => {
})
expect(shengsuanyun?.modelContextWindows?.['anthropic/claude-opus-4.7']).toBe(1000000)
expect(custom?.promoText).toBeUndefined()
expect(custom?.authStrategy).toBe('auth_token')
expect(custom?.defaultEnv).toBeUndefined()
})

View File

@ -66,7 +66,8 @@ describe('Real Provider Configs', () => {
// 验证写入 cc-haha/settings.json
const settings = await readCcHahaSettings()
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('sk-fake-test-key-for-testing-only')
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only')
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBeUndefined()
expect((settings.env as Record<string, string>).ANTHROPIC_MODEL).toBe('MiniMax-M2.7-highspeed')
expect(JSON.parse((settings.env as Record<string, string>).CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toMatchObject({
'MiniMax-M2.7': 204800,
@ -114,7 +115,8 @@ describe('Real Provider Configs', () => {
await service.activateProvider(jiekou.id)
settings = await readCcHahaSettings()
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('sk-fake-test-key-for-testing-only')
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only')
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBeUndefined()
expect((settings.env as Record<string, string>).ANTHROPIC_MODEL).toBe('claude-opus-4-7')
expect((settings.env as Record<string, string>).CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
@ -160,7 +162,8 @@ describe('Real Provider Configs', () => {
// 验证新字段写入
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('sk_test')
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk_test')
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBeUndefined()
// 验证已有字段保留
expect(settings.customField).toBe('should_be_preserved')
@ -191,6 +194,7 @@ describe('Real Provider Configs', () => {
const env = settings.env as Record<string, string> | undefined
expect(env?.ANTHROPIC_BASE_URL).toBeUndefined()
expect(env?.ANTHROPIC_API_KEY).toBeUndefined()
expect(env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
expect(env?.ANTHROPIC_MODEL).toBeUndefined()
console.log('✅ activateOfficial 正确清除了 provider env')
@ -201,6 +205,7 @@ describe('Real Provider Configs', () => {
baseUrl: 'https://api.minimaxi.com/anthropic',
apiKey: 'sk-fake-test-key',
modelId: 'MiniMax-M2.7-highspeed',
authStrategy: 'auth_token',
})
// testProviderConfig 返回 { connectivity: { ... }, proxy?: { ... } }
@ -246,7 +251,8 @@ describe('Real Provider Configs', () => {
// 验证 cc-haha/settings.json 是 Haha 自己的
const haha = await readCcHahaSettings()
expect((haha.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
expect((haha.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('sk-haha-key')
expect((haha.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk-haha-key')
expect((haha.env as Record<string, string>).ANTHROPIC_API_KEY).toBeUndefined()
console.log('✅ 原版 settings.json 完好无损Haha 配置独立存储')
})

View File

@ -2,7 +2,7 @@
* Unit tests for ProviderService and Providers REST API
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
@ -245,7 +245,8 @@ describe('ProviderService', () => {
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://new-api.example.com')
expect(env.ANTHROPIC_API_KEY).toBe('sk-new-key')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-new-key')
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_MODEL).toBe('model-main')
})
@ -384,7 +385,8 @@ describe('ProviderService', () => {
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://second-api.example.com')
expect(env.ANTHROPIC_API_KEY).toBe('sk-second-key')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-second-key')
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_MODEL).toBe('model-main')
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('model-haiku')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('model-sonnet')
@ -392,6 +394,59 @@ describe('ProviderService', () => {
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
})
test('should honor provider auth env strategies on activation and runtime env', async () => {
const svc = new ProviderService()
const apiKeyProvider = await svc.addProvider(sampleInput({
apiKey: 'sk-api-key',
authStrategy: 'api_key',
}))
await svc.activateProvider(apiKeyProvider.id)
let env = (await readSettings()).env as Record<string, string>
expect(env.ANTHROPIC_API_KEY).toBe('sk-api-key')
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
const bearerProvider = await svc.addProvider(sampleInput({
apiKey: 'sk-bearer',
authStrategy: 'auth_token_empty_api_key',
}))
await svc.activateProvider(bearerProvider.id)
env = (await readSettings()).env as Record<string, string>
expect(env.ANTHROPIC_API_KEY).toBe('')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-bearer')
const dualProvider = await svc.addProvider(sampleInput({
apiKey: 'sk-dual',
authStrategy: 'dual_same_token',
}))
const runtimeEnv = await svc.getProviderRuntimeEnv(dualProvider.id)
expect(runtimeEnv.ANTHROPIC_API_KEY).toBe('sk-dual')
expect(runtimeEnv.ANTHROPIC_AUTH_TOKEN).toBe('sk-dual')
const dummyProvider = await svc.addProvider(sampleInput({
apiKey: '',
authStrategy: 'dual_dummy',
}))
const dummyRuntimeEnv = await svc.getProviderRuntimeEnv(dummyProvider.id)
expect(dummyRuntimeEnv.ANTHROPIC_API_KEY).toBe('dummy')
expect(dummyRuntimeEnv.ANTHROPIC_AUTH_TOKEN).toBe('dummy')
})
test('proxy providers keep proxy-managed auth regardless of auth strategy', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
apiFormat: 'openai_chat',
authStrategy: 'auth_token',
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_API_KEY).toBe('proxy-managed')
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
})
test('should include preset default env on activation and runtime env', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
@ -513,6 +568,102 @@ describe('ProviderService', () => {
expect(active!.apiFormat).toBe('anthropic')
})
})
describe('testProvider', () => {
test('should use preset default auth for saved no-key Anthropic-compatible providers', async () => {
const originalFetch = globalThis.fetch
const calls: Array<{ headers: Record<string, string> }> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
calls.push({ headers: init?.headers as Record<string, string> })
return new Response(JSON.stringify({
type: 'message',
model: 'lmstudio-model',
content: [],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
presetId: 'lmstudio',
apiKey: '',
authStrategy: 'auth_token_empty_api_key',
models: {
main: 'lmstudio-model',
haiku: 'lmstudio-model',
sonnet: 'lmstudio-model',
opus: 'lmstudio-model',
},
}))
const result = await svc.testProvider(provider.id)
expect(result.connectivity.success).toBe(true)
expect(calls[0].headers.Authorization).toBe('Bearer lmstudio')
expect(calls[0].headers['x-api-key']).toBeUndefined()
} finally {
globalThis.fetch = originalFetch
}
})
})
describe('testProviderConfig', () => {
test('should use auth strategy headers for Anthropic-compatible tests', async () => {
const originalFetch = globalThis.fetch
const calls: Array<{ url: string; headers: Record<string, string> }> = []
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
calls.push({
url: String(url),
headers: init?.headers as Record<string, string>,
})
return new Response(JSON.stringify({
type: 'message',
model: 'model-main',
content: [],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
await svc.testProviderConfig({
baseUrl: 'https://api.example.com/anthropic',
apiKey: 'sk-bearer',
modelId: 'model-main',
authStrategy: 'auth_token',
apiFormat: 'anthropic',
})
await svc.testProviderConfig({
baseUrl: 'https://api.example.com/anthropic',
apiKey: 'sk-api',
modelId: 'model-main',
authStrategy: 'api_key',
apiFormat: 'anthropic',
})
await svc.testProviderConfig({
baseUrl: 'https://api.example.com/anthropic',
apiKey: 'sk-dual',
modelId: 'model-main',
authStrategy: 'dual_same_token',
apiFormat: 'anthropic',
})
expect(calls[0].headers.Authorization).toBe('Bearer sk-bearer')
expect(calls[0].headers['x-api-key']).toBeUndefined()
expect(calls[1].headers['x-api-key']).toBe('sk-api')
expect(calls[1].headers.Authorization).toBeUndefined()
expect(calls[2].headers['x-api-key']).toBe('sk-dual')
expect(calls[2].headers.Authorization).toBe('Bearer sk-dual')
} finally {
globalThis.fetch = originalFetch
}
})
})
})
// =============================================================================
@ -694,7 +845,8 @@ describe('Providers API', () => {
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://second.example.com')
expect(env.ANTHROPIC_API_KEY).toBe('sk-second')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-second')
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_MODEL).toBe('model-main')
})

View File

@ -94,7 +94,7 @@ export async function handleProvidersApi(
// /api/providers/:id/test
if (action === 'test') {
if (req.method !== 'POST') throw methodNotAllowed(req.method)
let overrides: { baseUrl?: string; modelId?: string; apiFormat?: string } | undefined
let overrides: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string } | undefined
try {
const body = await req.json()
if (body && typeof body === 'object') overrides = body as typeof overrides

View File

@ -27,6 +27,7 @@
"needsApiKey": true,
"websiteUrl": "https://platform.deepseek.com",
"apiKeyUrl": "https://platform.deepseek.com/api_keys",
"authStrategy": "auth_token",
"defaultEnv": {
"CC_HAHA_SEND_DISABLED_THINKING": "1"
},
@ -52,6 +53,7 @@
"websiteUrl": "https://open.bigmodel.cn",
"apiKeyUrl": "https://www.bigmodel.cn/invite?icode=d41B2qi8Z5xNwTGLNPPF3OZLO2QH3C0EBTSr%2BArzMw4%3D",
"promoText": "智谱 GLM 为 cc-haha 用户准备了专属邀请福利,使用此链接注册后可领取新用户权益。",
"authStrategy": "auth_token",
"defaultEnv": {
"CC_HAHA_SEND_DISABLED_THINKING": "1"
},
@ -75,6 +77,7 @@
"needsApiKey": true,
"websiteUrl": "https://platform.moonshot.cn",
"apiKeyUrl": "https://platform.kimi.com/console/api-keys",
"authStrategy": "auth_token",
"defaultEnv": {
"CC_HAHA_SEND_DISABLED_THINKING": "1"
},
@ -101,6 +104,7 @@
"needsApiKey": true,
"websiteUrl": "https://platform.minimaxi.com",
"apiKeyUrl": "https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link",
"authStrategy": "auth_token",
"modelContextWindows": {
"MiniMax-M2.7": 204800,
"MiniMax-M2.7-highspeed": 204800,
@ -127,6 +131,7 @@
"apiKeyUrl": "https://jiekou.ai/referral?invited_code=OBNU3K",
"promoText": "接口AI为 cc-haha 的用户提供官方资源与稳定高性能体验,订阅包价格为官方 8 折;绑定 GitHub 后还可领取 3 美元优惠券。",
"featured": true,
"authStrategy": "auth_token",
"defaultEnv": {
"ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "none"
},
@ -153,6 +158,7 @@
"apiKeyUrl": "https://www.shengsuanyun.com/?from=CH_LEJ88KWR",
"promoText": "胜算云为 cc-haha 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!",
"featured": true,
"authStrategy": "auth_token",
"defaultEnv": {
"API_TIMEOUT_MS": "3000000",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
@ -178,6 +184,7 @@
"needsApiKey": false,
"websiteUrl": "https://lmstudio.ai/docs/integrations/claude-code",
"promoText": "LM Studio 使用 Anthropic 兼容协议Base URL 填 http://localhost:1234不要追加 /v1。Claude Code 的提示词、工具和 Skill 会占用较多上下文,请在本地模型设置里把 Context Window 调大,建议至少 200K。",
"authStrategy": "auth_token_empty_api_key",
"defaultEnv": {
"ANTHROPIC_AUTH_TOKEN": "lmstudio"
}
@ -196,6 +203,7 @@
"needsApiKey": false,
"websiteUrl": "https://docs.ollama.com/integrations/claude-code",
"promoText": "Ollama 使用 Anthropic 兼容协议Base URL 填 http://localhost:11434不要追加 /v1。Claude Code 的提示词、工具和 Skill 会占用较多上下文,请在本地模型设置里把 Context Window 调大,建议至少 200K。",
"authStrategy": "auth_token_empty_api_key",
"defaultEnv": {
"ANTHROPIC_AUTH_TOKEN": "ollama"
}
@ -212,6 +220,7 @@
"opus": ""
},
"needsApiKey": true,
"websiteUrl": ""
"websiteUrl": "",
"authStrategy": "auth_token"
}
]

View File

@ -4,7 +4,7 @@
import { z } from 'zod'
import providerPresetsJson from './providerPresets.json'
import { ApiFormatSchema } from '../types/provider.js'
import { ApiFormatSchema, ProviderAuthStrategySchema } from '../types/provider.js'
const ModelMappingSchema = z.object({
main: z.string(),
@ -24,6 +24,7 @@ const ProviderPresetSchema = z.object({
apiKeyUrl: z.string().optional(),
promoText: z.string().optional(),
featured: z.boolean().optional(),
authStrategy: ProviderAuthStrategySchema.optional(),
defaultEnv: z.record(z.string(), z.string()).optional(),
modelContextWindows: z.record(
z.string().min(1),

View File

@ -26,6 +26,7 @@ import type {
ProviderTestResult,
ProviderTestStepResult,
ApiFormat,
ProviderAuthStrategy,
} from '../types/provider.js'
const MANAGED_ENV_KEYS = [
@ -44,15 +45,55 @@ const MANAGED_ENV_KEYS = [
] as const
const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] }
const AUTH_ENV_KEYS = new Set(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'])
function getPresetDefaultEnv(presetId: string): Record<string, string> {
return PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.defaultEnv ?? {}
}
function omitAuthEnv(env: Record<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter(([key]) => !AUTH_ENV_KEYS.has(key.toUpperCase())),
)
}
function getPresetAuthStrategy(presetId: string): ProviderAuthStrategy {
return PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.authStrategy ?? 'auth_token'
}
function getPresetModelContextWindows(presetId: string): Record<string, number> {
return PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.modelContextWindows ?? {}
}
function buildProviderAuthEnv(
provider: SavedProvider,
presetDefaultEnv: Record<string, string>,
needsProxy: boolean,
): Record<string, string> {
if (needsProxy) {
return { ANTHROPIC_API_KEY: 'proxy-managed' }
}
const strategy = provider.authStrategy ?? getPresetAuthStrategy(provider.presetId)
const key = provider.apiKey || presetDefaultEnv.ANTHROPIC_AUTH_TOKEN || presetDefaultEnv.ANTHROPIC_API_KEY || ''
switch (strategy) {
case 'api_key':
return key ? { ANTHROPIC_API_KEY: key } : {}
case 'auth_token':
return key ? { ANTHROPIC_AUTH_TOKEN: key } : {}
case 'auth_token_empty_api_key':
return {
ANTHROPIC_API_KEY: '',
...(key ? { ANTHROPIC_AUTH_TOKEN: key } : {}),
}
case 'dual_same_token':
return key ? { ANTHROPIC_API_KEY: key, ANTHROPIC_AUTH_TOKEN: key } : {}
case 'dual_dummy':
return { ANTHROPIC_API_KEY: 'dummy', ANTHROPIC_AUTH_TOKEN: 'dummy' }
}
}
function getManagedEnvKeys(): string[] {
const keys = new Set<string>(MANAGED_ENV_KEYS)
for (const preset of PROVIDER_PRESETS) {
@ -172,6 +213,7 @@ export class ProviderService {
presetId: input.presetId,
name: input.name,
apiKey: input.apiKey,
...(input.authStrategy !== undefined && { authStrategy: input.authStrategy }),
baseUrl: input.baseUrl,
apiFormat: input.apiFormat ?? 'anthropic',
models: input.models,
@ -195,6 +237,7 @@ export class ProviderService {
...existing,
...(input.name !== undefined && { name: input.name }),
...(input.apiKey !== undefined && { apiKey: input.apiKey }),
...(input.authStrategy !== undefined && { authStrategy: input.authStrategy }),
...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }),
...(input.apiFormat !== undefined && { apiFormat: input.apiFormat }),
...(input.models !== undefined && { models: input.models }),
@ -273,8 +316,10 @@ export class ProviderService {
...(provider.modelContextWindows ?? {}),
}
const presetDefaultEnv = getPresetDefaultEnv(provider.presetId)
return {
...getPresetDefaultEnv(provider.presetId),
...omitAuthEnv(presetDefaultEnv),
...(provider.autoCompactWindow !== undefined && {
CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(provider.autoCompactWindow),
}),
@ -282,7 +327,7 @@ export class ProviderService {
[MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows),
}),
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_API_KEY: needsProxy ? 'proxy-managed' : provider.apiKey,
...buildProviderAuthEnv(provider, presetDefaultEnv, needsProxy),
ANTHROPIC_MODEL: provider.models.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet,
@ -413,31 +458,39 @@ export class ProviderService {
async testProvider(
id: string,
overrides?: { baseUrl?: string; modelId?: string; apiFormat?: ApiFormat },
overrides?: { baseUrl?: string; modelId?: string; apiFormat?: ApiFormat; authStrategy?: ProviderAuthStrategy },
): Promise<ProviderTestResult> {
const provider = await this.getProvider(id)
const baseUrl = overrides?.baseUrl || provider.baseUrl
const modelId = overrides?.modelId || provider.models.main
const apiFormat = overrides?.apiFormat ?? provider.apiFormat ?? 'anthropic'
const authStrategy = overrides?.authStrategy ?? provider.authStrategy ?? getPresetAuthStrategy(provider.presetId)
const presetDefaultEnv = getPresetDefaultEnv(provider.presetId)
const apiKey = provider.apiKey
|| presetDefaultEnv.ANTHROPIC_AUTH_TOKEN
|| presetDefaultEnv.ANTHROPIC_API_KEY
|| (authStrategy === 'dual_dummy' ? 'dummy' : '')
if (!baseUrl || !provider.apiKey) {
if (!baseUrl || !apiKey) {
return { connectivity: { success: false, latencyMs: 0, error: 'Missing baseUrl or apiKey' } }
}
return this.testProviderConfig({
baseUrl,
apiKey: provider.apiKey,
apiKey,
modelId,
authStrategy,
apiFormat,
})
}
async testProviderConfig(input: TestProviderInput): Promise<ProviderTestResult> {
const format: ApiFormat = input.apiFormat ?? 'anthropic'
const authStrategy = input.authStrategy ?? 'api_key'
const base = input.baseUrl.replace(/\/+$/, '')
// ── Step 1: Basic connectivity ───────────────────────────
// Directly call the upstream API to verify URL, key, and model.
const step1 = await this.testConnectivity(base, input.apiKey, input.modelId, format)
const step1 = await this.testConnectivity(base, input.apiKey, input.modelId, format, authStrategy)
// If connectivity failed, no point running step 2
if (!step1.success) {
@ -462,10 +515,11 @@ export class ProviderService {
apiKey: string,
modelId: string,
format: ApiFormat,
authStrategy: ProviderAuthStrategy,
): Promise<ProviderTestStepResult> {
const start = Date.now()
try {
const { url, headers, body } = buildDirectTestRequest(base, apiKey, modelId, format)
const { url, headers, body } = buildDirectTestRequest(base, apiKey, modelId, format, authStrategy)
const response = await fetch(url, {
method: 'POST',
headers,
@ -574,6 +628,7 @@ function buildDirectTestRequest(
apiKey: string,
modelId: string,
format: ApiFormat,
authStrategy: ProviderAuthStrategy,
): { url: string; headers: Record<string, string>; body: Record<string, unknown> } {
const prompt = 'Say "ok" and nothing else.'
@ -594,11 +649,29 @@ function buildDirectTestRequest(
// anthropic
return {
url: `${base}/v1/messages`,
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
...buildAnthropicAuthHeaders(apiKey, authStrategy),
},
body: { model: modelId, max_tokens: 16, messages: [{ role: 'user', content: prompt }] },
}
}
function buildAnthropicAuthHeaders(apiKey: string, authStrategy: ProviderAuthStrategy): Record<string, string> {
switch (authStrategy) {
case 'api_key':
return { 'x-api-key': apiKey }
case 'auth_token':
case 'auth_token_empty_api_key':
return { Authorization: `Bearer ${apiKey}` }
case 'dual_same_token':
return { 'x-api-key': apiKey, Authorization: `Bearer ${apiKey}` }
case 'dual_dummy':
return { 'x-api-key': 'dummy', Authorization: 'Bearer dummy' }
}
}
function validateResponseBody(
body: Record<string, unknown> | null,
format: ApiFormat,

View File

@ -14,6 +14,15 @@ export const ApiFormatSchema = z.enum([
])
export type ApiFormat = z.infer<typeof ApiFormatSchema>
export const ProviderAuthStrategySchema = z.enum([
'api_key',
'auth_token',
'auth_token_empty_api_key',
'dual_same_token',
'dual_dummy',
])
export type ProviderAuthStrategy = z.infer<typeof ProviderAuthStrategySchema>
export const ModelMappingSchema = z.object({
main: z.string(),
haiku: z.string(),
@ -32,6 +41,7 @@ export const SavedProviderSchema = z.object({
presetId: z.string(),
name: z.string().min(1),
apiKey: z.string(),
authStrategy: ProviderAuthStrategySchema.optional(),
baseUrl: z.string(),
apiFormat: ApiFormatSchema.default('anthropic'),
models: ModelMappingSchema,
@ -49,6 +59,7 @@ export const CreateProviderSchema = z.object({
presetId: z.string().min(1),
name: z.string().min(1),
apiKey: z.string(),
authStrategy: ProviderAuthStrategySchema.optional(),
baseUrl: z.string(),
apiFormat: ApiFormatSchema.default('anthropic'),
models: ModelMappingSchema,
@ -60,6 +71,7 @@ export const CreateProviderSchema = z.object({
export const UpdateProviderSchema = z.object({
name: z.string().min(1).optional(),
apiKey: z.string().optional(),
authStrategy: ProviderAuthStrategySchema.optional(),
baseUrl: z.string().optional(),
apiFormat: ApiFormatSchema.optional(),
models: ModelMappingSchema.optional(),
@ -72,6 +84,7 @@ export const TestProviderSchema = z.object({
baseUrl: z.string().url(),
apiKey: z.string().min(1),
modelId: z.string().min(1),
authStrategy: ProviderAuthStrategySchema.optional(),
apiFormat: ApiFormatSchema.default('anthropic'),
})