diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts index e19ac1b7..0a248f95 100644 --- a/desktop/src/api/providers.ts +++ b/desktop/src/api/providers.ts @@ -61,7 +61,7 @@ export const providersApi = { return api.post<{ ok: true }>('/api/providers/official') }, - test(id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string }) { + test(id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string }) { return api.post(`/api/providers/${id}/test`, overrides) }, diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index d44a52b0..2a06bcc0 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -188,6 +188,27 @@ export const en = { 'settings.providers.sonnetModel': 'Sonnet Model', 'settings.providers.opusModel': 'Opus Model', 'settings.providers.sameAsMain': 'Same as main', + 'settings.providers.contextSettingsTitle': 'Context and auto-compact', + 'settings.providers.contextSettingsDesc': 'Preset limits are applied automatically. Edit only when the provider changes limits or you use a custom model ID.', + 'settings.providers.contextSettingsEdit': 'Advanced', + 'settings.providers.contextSettingsHide': 'Hide', + 'settings.providers.contextSummaryAuto': 'Auto-detect configured models; unknown models use the built-in fallback', + 'settings.providers.contextFallbackAuto': 'Unknown models: auto', + 'settings.providers.contextFallbackSummary': 'Unknown models: {tokens}', + 'settings.providers.modelContextWindows': 'Model context windows', + 'settings.providers.mainContextWindow': 'Main model context', + 'settings.providers.haikuContextWindow': 'Haiku model context', + 'settings.providers.sonnetContextWindow': 'Sonnet model context', + 'settings.providers.opusContextWindow': 'Opus model context', + 'settings.providers.contextWindowPlaceholder': 'e.g. 200000', + 'settings.providers.modelContextWindowsDesc': 'Use each model’s real context window. Presets are filled automatically; blank values fall back to built-in detection or 200K.', + 'settings.providers.modelContextWindowNumberError': 'Enter an integer.', + 'settings.providers.modelContextWindowRangeError': 'Must be between 16000 and 10000000.', + 'settings.providers.autoCompactWindow': 'Unknown Model Fallback Window', + 'settings.providers.autoCompactWindowPlaceholder': 'Optional, e.g. 200000', + 'settings.providers.autoCompactWindowDesc': 'Only used for models that cannot be identified; configured model windows take precedence.', + 'settings.providers.autoCompactWindowNumberError': 'Enter an integer.', + 'settings.providers.autoCompactWindowRangeError': 'Must be between 16000 and 10000000.', 'settings.providers.testConnection': 'Test Connection', 'settings.providers.settingsJson': 'Settings JSON', 'settings.providers.settingsJsonDesc': '~/.claude/cc-haha/settings.json — edit directly, will be written on save.', @@ -197,6 +218,17 @@ export const en = { 'settings.providers.apiFormatOpenaiChat': 'OpenAI Chat Completions (proxy)', 'settings.providers.apiFormatOpenaiResponses': 'OpenAI Responses API (proxy)', 'settings.providers.proxyHint': 'Requests will be translated via the local proxy', + 'settings.providers.authStrategy': 'Auth Variable', + 'settings.providers.authStrategyApiKey': 'API Key (ANTHROPIC_API_KEY)', + 'settings.providers.authStrategyApiKeyDesc': 'Direct Anthropic API access using x-api-key.', + 'settings.providers.authStrategyAuthToken': 'Bearer Token (ANTHROPIC_AUTH_TOKEN)', + 'settings.providers.authStrategyAuthTokenDesc': 'Most third-party Anthropic-compatible services using Authorization Bearer.', + 'settings.providers.authStrategyAuthTokenEmptyApiKey': 'Bearer + Empty API_KEY', + 'settings.providers.authStrategyAuthTokenEmptyApiKeyDesc': 'OpenRouter/Ollama-style setups that must avoid falling back to an Anthropic API key.', + 'settings.providers.authStrategyDualSameToken': 'Write token to both variables', + 'settings.providers.authStrategyDualSameTokenDesc': 'Services such as Hugging Face Router that check both variables.', + 'settings.providers.authStrategyDualDummy': 'Write dummy to both variables', + 'settings.providers.authStrategyDualDummyDesc': 'Local vLLM-compatible services that only need placeholder auth values.', // Settings > Permissions 'settings.permissions.title': 'Permission Mode', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 1a98596b..2c39bb51 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -190,6 +190,27 @@ export const zh: Record = { 'settings.providers.sonnetModel': 'Sonnet 模型', 'settings.providers.opusModel': 'Opus 模型', 'settings.providers.sameAsMain': '与主模型相同', + 'settings.providers.contextSettingsTitle': '上下文与自动压缩', + 'settings.providers.contextSettingsDesc': '预设会自动应用模型窗口。只有服务商调整限制,或使用自定义模型 ID 时才需要修改。', + 'settings.providers.contextSettingsEdit': '高级', + 'settings.providers.contextSettingsHide': '收起', + 'settings.providers.contextSummaryAuto': '自动识别已配置模型;未知模型使用内置兜底', + 'settings.providers.contextFallbackAuto': '未知模型:自动', + 'settings.providers.contextFallbackSummary': '未知模型:{tokens}', + 'settings.providers.modelContextWindows': '模型上下文窗口', + 'settings.providers.mainContextWindow': '主模型上下文', + 'settings.providers.haikuContextWindow': 'Haiku 模型上下文', + 'settings.providers.sonnetContextWindow': 'Sonnet 模型上下文', + 'settings.providers.opusContextWindow': 'Opus 模型上下文', + 'settings.providers.contextWindowPlaceholder': '例如 200000', + 'settings.providers.modelContextWindowsDesc': '按每个模型的真实上下文填写,内置预设会自动带出,留空则走内置识别或默认 200K。', + 'settings.providers.modelContextWindowNumberError': '请输入整数。', + 'settings.providers.modelContextWindowRangeError': '范围必须在 16000 到 10000000 之间。', + 'settings.providers.autoCompactWindow': '未知模型兜底窗口', + 'settings.providers.autoCompactWindowPlaceholder': '可选,例如 200000', + 'settings.providers.autoCompactWindowDesc': '仅用于无法识别的模型;已配置的模型窗口会优先使用。', + 'settings.providers.autoCompactWindowNumberError': '请输入整数。', + 'settings.providers.autoCompactWindowRangeError': '范围必须在 16000 到 10000000 之间。', 'settings.providers.testConnection': '测试连接', 'settings.providers.settingsJson': '设置 JSON', 'settings.providers.settingsJsonDesc': '~/.claude/cc-haha/settings.json — 直接编辑,保存时写入。', @@ -199,6 +220,17 @@ export const zh: Record = { 'settings.providers.apiFormatOpenaiChat': 'OpenAI Chat Completions (代理转换)', 'settings.providers.apiFormatOpenaiResponses': 'OpenAI Responses API (代理转换)', 'settings.providers.proxyHint': '请求将通过本地代理转换协议格式', + 'settings.providers.authStrategy': '认证变量', + 'settings.providers.authStrategyApiKey': 'API Key (ANTHROPIC_API_KEY)', + 'settings.providers.authStrategyApiKeyDesc': '直连 Anthropic 官方 API,发送 x-api-key。', + 'settings.providers.authStrategyAuthToken': 'Bearer Token (ANTHROPIC_AUTH_TOKEN)', + 'settings.providers.authStrategyAuthTokenDesc': '大多数第三方 Anthropic 兼容服务,发送 Authorization Bearer。', + 'settings.providers.authStrategyAuthTokenEmptyApiKey': 'Bearer + 清空 API_KEY', + 'settings.providers.authStrategyAuthTokenEmptyApiKeyDesc': 'OpenRouter/Ollama 等场景,避免回退到 Anthropic API key。', + 'settings.providers.authStrategyDualSameToken': '两个变量写同一个 Token', + 'settings.providers.authStrategyDualSameTokenDesc': 'Hugging Face Router 等同时检查两个变量的服务。', + 'settings.providers.authStrategyDualDummy': '两个变量写 dummy', + 'settings.providers.authStrategyDualDummyDesc': 'vLLM 等本地兼容服务,只需要占位认证值。', // Settings > Permissions 'settings.permissions.title': '权限模式', diff --git a/desktop/src/lib/__tests__/providerSettingsJson.test.ts b/desktop/src/lib/__tests__/providerSettingsJson.test.ts new file mode 100644 index 00000000..94c47807 --- /dev/null +++ b/desktop/src/lib/__tests__/providerSettingsJson.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' + +import { + API_KEY_JSON_PLACEHOLDER, + maskSettingsJsonSecrets, + restoreSettingsJsonSecrets, + stripProviderSettingsJsonEnv, +} from '../providerSettingsJson' + +describe('provider settings JSON helpers', () => { + it('masks both Anthropic API key env vars even when the values differ', () => { + const raw = JSON.stringify({ + env: { + ANTHROPIC_API_KEY: 'stale-api-key', + ANTHROPIC_AUTH_TOKEN: 'current-auth-token', + OTHER_VALUE: 'visible', + }, + }) + + const masked = JSON.parse(maskSettingsJsonSecrets(raw)) as { env: Record } + + expect(masked.env.ANTHROPIC_API_KEY).toBe(API_KEY_JSON_PLACEHOLDER) + expect(masked.env.ANTHROPIC_AUTH_TOKEN).toBe(API_KEY_JSON_PLACEHOLDER) + expect(masked.env.OTHER_VALUE).toBe('visible') + }) + + it('restores masked Anthropic API key env vars from their previous field values', () => { + const previousRaw = JSON.stringify({ + env: { + ANTHROPIC_API_KEY: 'previous-api-key', + ANTHROPIC_AUTH_TOKEN: 'previous-auth-token', + }, + }) + const edited = { + env: { + ANTHROPIC_API_KEY: API_KEY_JSON_PLACEHOLDER, + ANTHROPIC_AUTH_TOKEN: API_KEY_JSON_PLACEHOLDER, + }, + } + + const restored = restoreSettingsJsonSecrets(edited, previousRaw, 'fallback-key') + + expect(restored.env.ANTHROPIC_API_KEY).toBe('previous-api-key') + expect(restored.env.ANTHROPIC_AUTH_TOKEN).toBe('previous-auth-token') + }) + + it('strips provider-managed env vars from existing settings before preview merge', () => { + const cleaned = stripProviderSettingsJsonEnv( + { + ANTHROPIC_API_KEY: 'old-api-key', + ANTHROPIC_AUTH_TOKEN: 'old-auth-token', + ANTHROPIC_BASE_URL: 'https://old.example.com', + ANTHROPIC_MODEL: 'old-model', + CLAUDE_CODE_MODEL_CONTEXT_WINDOWS: '{"old":100000}', + CC_HAHA_SEND_DISABLED_THINKING: '1', + USER_DEFINED: 'keep-me', + }, + ['CC_HAHA_SEND_DISABLED_THINKING'], + ) + + expect(cleaned).toEqual({ USER_DEFINED: 'keep-me' }) + }) +}) diff --git a/desktop/src/lib/providerSettingsJson.ts b/desktop/src/lib/providerSettingsJson.ts new file mode 100644 index 00000000..9ad5fc09 --- /dev/null +++ b/desktop/src/lib/providerSettingsJson.ts @@ -0,0 +1,103 @@ +export const API_KEY_JSON_PLACEHOLDER = '••••••••' + +const API_KEY_JSON_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'] as const + +const PROVIDER_SETTINGS_JSON_ENV_KEYS = new Set([ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION', + 'ANTHROPIC_DEFAULT_SONNET_MODEL_NAME', + 'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION', + 'ANTHROPIC_DEFAULT_OPUS_MODEL_NAME', + 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', + 'ANTHROPIC_SMALL_FAST_MODEL', + 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', + 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', + 'CLAUDE_CODE_SUBAGENT_MODEL', +]) + +function getEnvRecord(raw: string): Record | null { + try { + const parsed = JSON.parse(raw) as { env?: unknown } + if (!parsed.env || typeof parsed.env !== 'object' || Array.isArray(parsed.env)) { + return null + } + return parsed.env as Record + } catch { + return null + } +} + +function isSecretDisplayValue(value: unknown): value is string { + return ( + typeof value === 'string' && + value.trim() !== '' && + value !== API_KEY_JSON_PLACEHOLDER && + value !== '(your API key)' && + value !== 'proxy-managed' + ) +} + +export function maskSettingsJsonSecrets(raw: string): string { + try { + const parsed = JSON.parse(raw) as { env?: Record } + if (!parsed.env || typeof parsed.env !== 'object' || Array.isArray(parsed.env)) return raw + let changed = false + for (const key of API_KEY_JSON_KEYS) { + if (isSecretDisplayValue(parsed.env[key])) { + parsed.env[key] = API_KEY_JSON_PLACEHOLDER + changed = true + } + } + return changed ? JSON.stringify(parsed, null, 2) : raw + } catch { + return raw + } +} + +export function restoreSettingsJsonSecrets( + settings: T, + previousRaw: string, + fallbackApiKey = '', +): T { + if (!settings || typeof settings !== 'object') return settings + const parsed = settings as { env?: Record } + if (!parsed.env || typeof parsed.env !== 'object' || Array.isArray(parsed.env)) return settings + + const previousEnv = getEnvRecord(previousRaw) + const fallback = fallbackApiKey.trim() + + for (const key of API_KEY_JSON_KEYS) { + if (parsed.env[key] !== API_KEY_JSON_PLACEHOLDER) continue + const previousValue = previousEnv?.[key] + if (isSecretDisplayValue(previousValue)) { + parsed.env[key] = previousValue + } else if (fallback) { + parsed.env[key] = fallback + } + } + + return settings +} + +export function stripProviderSettingsJsonEnv( + env: Record, + extraManagedKeys: Iterable = [], +): Record { + const extraKeys = new Set(Array.from(extraManagedKeys, key => key.toUpperCase())) + return Object.fromEntries( + Object.entries(env).filter(([key]) => { + const upperKey = key.toUpperCase() + return !PROVIDER_SETTINGS_JSON_ENV_KEYS.has(upperKey) && !extraKeys.has(upperKey) + }), + ) +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index b4067485..aa570580 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -9,7 +9,7 @@ import { Button } from '../components/shared/Button' import { Dropdown } from '../components/shared/Dropdown' import type { PermissionMode, EffortLevel, ThemeMode, WebSearchMode } from '../types/settings' import type { Locale } from '../i18n' -import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider' +import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' import { AdapterSettings } from './AdapterSettings' import { useAgentStore } from '../stores/agentStore' @@ -37,6 +37,12 @@ import { requestDesktopNotificationPermission, type DesktopNotificationPermission, } from '../lib/desktopNotifications' +import { + API_KEY_JSON_PLACEHOLDER, + maskSettingsJsonSecrets, + restoreSettingsJsonSecrets, + stripProviderSettingsJsonEnv, +} from '../lib/providerSettingsJson' export function Settings() { const [activeTab, setActiveTab] = useState('providers') @@ -344,13 +350,238 @@ function requirePreset(preset: ProviderPreset | undefined): ProviderPreset { return preset } +const AUTO_COMPACT_WINDOW_ENV_KEY = 'CLAUDE_CODE_AUTO_COMPACT_WINDOW' +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 + +function formatContextWindow(value: number): string { + return value.toLocaleString('en-US') +} + +function getPresetAutoCompactWindow(preset: ProviderPreset): string { + return preset.defaultEnv?.[AUTO_COMPACT_WINDOW_ENV_KEY] ?? '' +} + +function getPresetAuthStrategy(preset: ProviderPreset): ProviderAuthStrategy { + return preset.authStrategy ?? DEFAULT_PROVIDER_AUTH_STRATEGY +} + +function omitAuthEnv(env: Record | undefined): Record { + if (!env) return {} + return Object.fromEntries( + Object.entries(env).filter(([key]) => !AUTH_ENV_KEYS.has(key.toUpperCase())), + ) +} + +function getProviderAuthValue(apiKey: string, preset: ProviderPreset): string { + return apiKey || preset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || preset.defaultEnv?.ANTHROPIC_API_KEY || (preset.needsApiKey ? '(your API key)' : '') +} + +function buildSettingsJsonAuthEnv( + apiFormat: ApiFormat, + authStrategy: ProviderAuthStrategy, + apiKey: string, + preset: ProviderPreset, +): Record { + if (apiFormat !== 'anthropic') { + return { ANTHROPIC_API_KEY: 'proxy-managed' } + } + + const value = getProviderAuthValue(apiKey, preset) + switch (authStrategy) { + case 'api_key': + return value ? { ANTHROPIC_API_KEY: value } : {} + case 'auth_token': + return value ? { ANTHROPIC_AUTH_TOKEN: value } : {} + case 'auth_token_empty_api_key': + return { + ANTHROPIC_API_KEY: '', + ...(value ? { ANTHROPIC_AUTH_TOKEN: value } : {}), + } + case 'dual_same_token': + return value ? { ANTHROPIC_API_KEY: value, ANTHROPIC_AUTH_TOKEN: value } : {} + case 'dual_dummy': + return { ANTHROPIC_API_KEY: 'dummy', ANTHROPIC_AUTH_TOKEN: 'dummy' } + } +} + +function inferAuthStrategyFromEnv(env: Record): ProviderAuthStrategy | null { + if (env.ANTHROPIC_API_KEY === 'dummy' && env.ANTHROPIC_AUTH_TOKEN === 'dummy') return 'dual_dummy' + if (env.ANTHROPIC_API_KEY === '' && env.ANTHROPIC_AUTH_TOKEN) return 'auth_token_empty_api_key' + if (env.ANTHROPIC_API_KEY && env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_API_KEY === env.ANTHROPIC_AUTH_TOKEN) return 'dual_same_token' + if (env.ANTHROPIC_AUTH_TOKEN) return 'auth_token' + if (env.ANTHROPIC_API_KEY) return 'api_key' + return null +} + +function parseAutoCompactWindowInput(value: string): number | undefined { + const trimmed = value.trim() + if (!trimmed) return undefined + const parsed = Number(trimmed) + if (!Number.isInteger(parsed)) return undefined + if (parsed < MODEL_CONTEXT_WINDOW_MIN || parsed > MODEL_CONTEXT_WINDOW_MAX) return undefined + return parsed +} + +function getAutoCompactWindowErrorKey(value: string): 'number' | 'range' | null { + const trimmed = value.trim() + if (!trimmed) return null + const parsed = Number(trimmed) + if (!Number.isInteger(parsed)) return 'number' + if (parsed < MODEL_CONTEXT_WINDOW_MIN || parsed > MODEL_CONTEXT_WINDOW_MAX) return 'range' + return null +} + +function parseModelContextWindowsInput(value: string): number | undefined { + return parseAutoCompactWindowInput(value) +} + +function getModelContextWindowErrorKey(value: string): 'number' | 'range' | null { + return getAutoCompactWindowErrorKey(value) +} + +function getModelContextInputValue( + model: string | undefined, + preset: ProviderPreset, + provider?: SavedProvider, +): string { + const trimmedModel = model?.trim() + if (!trimmedModel) return '' + const value = provider?.modelContextWindows?.[trimmedModel] ?? preset.modelContextWindows?.[trimmedModel] + return value !== undefined ? String(value) : '' +} + +function getModelContextInputs( + models: ModelMapping, + preset: ProviderPreset, + provider?: SavedProvider, +): ModelContextInputs { + const inputs = {} as ModelContextInputs + for (const slot of MODEL_SLOTS) { + inputs[slot] = getModelContextInputValue(models[slot], preset, provider) + } + return inputs +} + +function buildModelContextWindows( + models: ModelMapping, + inputs: ModelContextInputs, +): Record { + const windows: Record = {} + for (const slot of MODEL_SLOTS) { + const model = models[slot]?.trim() + const parsed = parseModelContextWindowsInput(inputs[slot]) + if (model && parsed !== undefined) { + windows[model] = parsed + } + } + return windows +} + +function updateSettingsJsonAutoCompactWindow(raw: string, value: string): string { + try { + const parsed = JSON.parse(raw || '{}') as { env?: Record } + const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env) + ? parsed.env + : {} + const env = { ...existingEnv } + const trimmed = value.trim() + if (trimmed) { + env[AUTO_COMPACT_WINDOW_ENV_KEY] = trimmed + } else { + delete env[AUTO_COMPACT_WINDOW_ENV_KEY] + } + parsed.env = env + return JSON.stringify(parsed, null, 2) + } catch { + return raw + } +} + +function updateSettingsJsonModelContextWindows( + raw: string, + modelContextWindows: Record, +): string { + try { + const parsed = JSON.parse(raw || '{}') as { env?: Record } + const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env) + ? parsed.env + : {} + const env = { ...existingEnv } + if (Object.keys(modelContextWindows).length > 0) { + env[MODEL_CONTEXT_WINDOWS_ENV_KEY] = JSON.stringify(modelContextWindows) + } else { + delete env[MODEL_CONTEXT_WINDOWS_ENV_KEY] + } + parsed.env = env + return JSON.stringify(parsed, null, 2) + } catch { + return raw + } +} + +function updateSettingsJsonModels(raw: string, models: ModelMapping): string { + try { + const parsed = JSON.parse(raw || '{}') as { env?: Record } + const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env) + ? parsed.env + : {} + parsed.env = { + ...existingEnv, + ANTHROPIC_MODEL: models.main, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, + } + return JSON.stringify(parsed, null, 2) + } catch { + return raw + } +} + +function updateSettingsJsonProviderConnection( + raw: string, + apiFormat: ApiFormat, + authStrategy: ProviderAuthStrategy, + apiKey: string, + preset: ProviderPreset, + baseUrl: string, +): string { + try { + const parsed = JSON.parse(raw || '{}') as { env?: Record } + const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env) + ? parsed.env + : {} + const env = { ...existingEnv } + delete env.ANTHROPIC_API_KEY + delete env.ANTHROPIC_AUTH_TOKEN + env.ANTHROPIC_BASE_URL = apiFormat !== 'anthropic' ? 'http://127.0.0.1:3456/proxy' : baseUrl + Object.assign(env, buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, preset)) + parsed.env = env + return JSON.stringify(parsed, null, 2) + } catch { + return raw + } +} + function buildFallbackPreset(provider?: SavedProvider): ProviderPreset { return { id: provider?.presetId ?? 'custom', name: provider?.name ?? 'Custom', baseUrl: provider?.baseUrl ?? '', apiFormat: provider?.apiFormat ?? 'anthropic', + authStrategy: provider?.authStrategy, defaultModels: provider?.models ?? { main: '', haiku: '', sonnet: '', opus: '' }, + modelContextWindows: provider?.modelContextWindows, + defaultEnv: provider?.autoCompactWindow !== undefined + ? { [AUTO_COMPACT_WINDOW_ENV_KEY]: String(provider.autoCompactWindow) } + : undefined, needsApiKey: true, websiteUrl: '', } @@ -367,39 +598,6 @@ function openExternalUrl(url: string) { .catch(() => window.open(url, '_blank', 'noopener,noreferrer')) } -const API_KEY_JSON_PLACEHOLDER = '••••••••' -const API_KEY_JSON_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'] as const - -function maskSettingsJsonSecrets(raw: string, apiKey: string): string { - if (!apiKey.trim()) return raw - try { - const parsed = JSON.parse(raw) as { env?: Record } - if (!parsed.env || typeof parsed.env !== 'object') return raw - let changed = false - for (const key of API_KEY_JSON_KEYS) { - if (parsed.env[key] === apiKey) { - parsed.env[key] = API_KEY_JSON_PLACEHOLDER - changed = true - } - } - return changed ? JSON.stringify(parsed, null, 2) : raw - } catch { - return raw - } -} - -function restoreSettingsJsonSecrets(settings: T, apiKey: string): T { - if (!apiKey.trim() || !settings || typeof settings !== 'object') return settings - const parsed = settings as { env?: Record } - if (!parsed.env || typeof parsed.env !== 'object') return settings - for (const key of API_KEY_JSON_KEYS) { - if (parsed.env[key] === API_KEY_JSON_PLACEHOLDER) { - parsed.env[key] = apiKey - } - } - return settings -} - function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderFormProps) { const { createProvider, updateProvider, testConfig } = useProviderStore() const fetchSettings = useSettingsStore((s) => s.fetchAll) @@ -409,7 +607,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF const regularPresets = availablePresets.filter((p) => !p.featured) const featuredPresets = availablePresets.filter((p) => p.featured) const presetDefaultEnvKeys = useMemo( - () => new Set(presets.flatMap((preset) => Object.keys(preset.defaultEnv ?? {}))), + () => presets.flatMap((preset) => Object.keys(preset.defaultEnv ?? {})), [presets], ) const fallbackPreset = provider @@ -425,10 +623,20 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF const [name, setName] = useState(provider?.name ?? initialPreset.name) const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl) const [apiFormat, setApiFormat] = useState(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic') + const [authStrategy, setAuthStrategy] = useState(provider?.authStrategy ?? getPresetAuthStrategy(initialPreset)) const [apiKey, setApiKey] = useState(provider?.apiKey ?? '') const [showApiKey, setShowApiKey] = useState(false) const [notes, setNotes] = useState(provider?.notes ?? '') const [models, setModels] = useState(provider?.models ?? { ...initialPreset.defaultModels }) + const [modelContextInputs, setModelContextInputs] = useState( + getModelContextInputs(provider?.models ?? initialPreset.defaultModels, initialPreset, provider), + ) + const [autoCompactWindow, setAutoCompactWindow] = useState( + provider?.autoCompactWindow !== undefined + ? String(provider.autoCompactWindow) + : getPresetAutoCompactWindow(initialPreset), + ) + const [showContextSettings, setShowContextSettings] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [testResult, setTestResult] = useState(null) const [isTesting, setIsTesting] = useState(false) @@ -446,20 +654,22 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF import('../api/providers').then(({ providersApi }) => { providersApi.getSettings().then((settings) => { const needsProxy = apiFormat !== 'anthropic' + const autoCompactWindowEnv = autoCompactWindow.trim() + const modelContextWindows = buildModelContextWindows(models, modelContextInputs) const existingEnv = (settings.env as Record) || {} - const cleanedEnv = Object.fromEntries( - Object.entries(existingEnv).filter(([key]) => !presetDefaultEnvKeys.has(key)), - ) + const cleanedEnv = stripProviderSettingsJsonEnv(existingEnv, presetDefaultEnvKeys) const merged = { ...settings, 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, @@ -479,18 +689,24 @@ 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)) + setShowContextSettings(false) setTestResult(null) } const isCustom = selectedPreset.id === 'custom' const requiresApiKey = selectedPreset.needsApiKey !== false - const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || !requiresApiKey || apiKey.trim()) && models.main.trim() && !settingsJsonError + const autoCompactWindowErrorKey = getAutoCompactWindowErrorKey(autoCompactWindow) + const modelContextWindowErrorSlots = MODEL_SLOTS.filter((slot) => getModelContextWindowErrorKey(modelContextInputs[slot])) + const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || !requiresApiKey || apiKey.trim()) && models.main.trim() && !settingsJsonError && !autoCompactWindowErrorKey && modelContextWindowErrorSlots.length === 0 const apiKeyUrl = selectedPreset.apiKeyUrl?.trim() const promoText = selectedPreset.promoText?.trim() const displayedSettingsJson = showApiKey ? settingsJson - : maskSettingsJsonSecrets(settingsJson, apiKey) + : maskSettingsJsonSecrets(settingsJson) const apiFormatItems = [ { value: 'anthropic' as const, @@ -509,6 +725,94 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF }, ] const selectedApiFormatLabel = apiFormatItems.find((item) => item.value === apiFormat)?.label ?? t('settings.providers.apiFormatAnthropic') + const authStrategyItems = [ + { + value: 'auth_token' as const, + label: t('settings.providers.authStrategyAuthToken'), + description: t('settings.providers.authStrategyAuthTokenDesc'), + icon: key, + }, + { + value: 'auth_token_empty_api_key' as const, + label: t('settings.providers.authStrategyAuthTokenEmptyApiKey'), + description: t('settings.providers.authStrategyAuthTokenEmptyApiKeyDesc'), + icon: key_off, + }, + { + value: 'api_key' as const, + label: t('settings.providers.authStrategyApiKey'), + description: t('settings.providers.authStrategyApiKeyDesc'), + icon: vpn_key, + }, + { + value: 'dual_same_token' as const, + label: t('settings.providers.authStrategyDualSameToken'), + description: t('settings.providers.authStrategyDualSameTokenDesc'), + icon: sync_alt, + }, + { + value: 'dual_dummy' as const, + label: t('settings.providers.authStrategyDualDummy'), + description: t('settings.providers.authStrategyDualDummyDesc'), + icon: construction, + }, + ] satisfies Array<{ value: ProviderAuthStrategy; label: string; description: string; icon: ReactNode }> + const selectedAuthStrategyLabel = authStrategyItems.find((item) => item.value === authStrategy)?.label ?? t('settings.providers.authStrategyAuthToken') + const configuredContextWindows = buildModelContextWindows(models, modelContextInputs) + const configuredContextSummary = Object.entries(configuredContextWindows) + .filter(([model], index, entries) => entries.findIndex(([candidate]) => candidate === model) === index) + .map(([model, value]) => `${model}: ${formatContextWindow(value)}`) + const parsedFallbackContextWindow = parseAutoCompactWindowInput(autoCompactWindow) + const fallbackContextSummary = parsedFallbackContextWindow !== undefined + ? t('settings.providers.contextFallbackSummary', { + tokens: formatContextWindow(parsedFallbackContextWindow), + }) + : t('settings.providers.contextFallbackAuto') + const contextSummary = configuredContextSummary.length > 0 + ? [...configuredContextSummary, fallbackContextSummary].join(' · ') + : t('settings.providers.contextSummaryAuto') + const shouldShowContextFields = showContextSettings || modelContextWindowErrorSlots.length > 0 || !!autoCompactWindowErrorKey + const handleAutoCompactWindowChange = (value: string) => { + 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 = { + ...modelContextInputs, + [slot]: getModelContextInputValue(value, selectedPreset, provider), + } + setModels(nextModels) + setModelContextInputs(nextInputs) + setSettingsJson((current) => updateSettingsJsonModelContextWindows( + updateSettingsJsonModels(current, nextModels), + buildModelContextWindows(nextModels, nextInputs), + )) + } + const handleModelContextWindowChange = (slot: ModelSlot, value: string) => { + const nextInputs = { ...modelContextInputs, [slot]: value } + setModelContextInputs(nextInputs) + setSettingsJson((current) => updateSettingsJsonModelContextWindows( + current, + buildModelContextWindows(models, nextInputs), + )) + } const renderPresetButton = (preset: ProviderPreset) => ( + } + /> + + )} +