mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
299c50a1e0
commit
a43460f28d
@ -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)
|
||||
},
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '权限模式',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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 配置独立存储')
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'),
|
||||
})
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user