Merge provider auth strategy into main

Bring the provider context-window, auth-secret masking, and explicit auth-strategy worktree commits onto main after local verification. The merge keeps main's notification and docs history intact while preserving the provider fixes as separate commits.

Constraint: main has diverged with local desktop notification and docs commits, so this cannot be fast-forwarded safely.

Confidence: high

Scope-risk: moderate

Directive: Keep provider auth behavior configured through preset authStrategy rather than implicit env-name guesses.

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

# Conflicts:
#	desktop/src/pages/Settings.tsx
This commit is contained in:
程序员阿江(Relakkes) 2026-05-03 17:40:18 +08:00
commit eb00c0d501
28 changed files with 1982 additions and 85 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

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

View File

@ -190,6 +190,27 @@ export const zh: Record<TranslationKey, string> = {
'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<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

@ -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<string, string> }
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' })
})
})

View File

@ -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<string, unknown> | 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<string, unknown>
} 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<string, unknown> }
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<T>(
settings: T,
previousRaw: string,
fallbackApiKey = '',
): T {
if (!settings || typeof settings !== 'object') return settings
const parsed = settings as { env?: Record<string, unknown> }
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<string, string>,
extraManagedKeys: Iterable<string> = [],
): Record<string, string> {
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)
}),
)
}

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'
@ -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<SettingsTab>('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<ModelSlot, string>
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<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
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<string, number> {
const windows: Record<string, number> = {}
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<string, unknown> }
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, number>,
): 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 }
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<string, unknown> }
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<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
? { [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<string, unknown> }
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<T>(settings: T, apiKey: string): T {
if (!apiKey.trim() || !settings || typeof settings !== 'object') return settings
const parsed = settings as { env?: Record<string, unknown> }
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<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 ?? '')
const [models, setModels] = useState<ModelMapping>(provider?.models ?? { ...initialPreset.defaultModels })
const [modelContextInputs, setModelContextInputs] = useState<ModelContextInputs>(
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<ProviderTestResult | null>(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<string, string>) || {}
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: <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)
.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) => (
<button
key={preset.id}
@ -525,13 +829,15 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
const handleSubmit = async () => {
if (!canSubmit) return
const parsedAutoCompactWindow = parseAutoCompactWindowInput(autoCompactWindow)
const parsedModelContextWindows = buildModelContextWindows(models, modelContextInputs)
setIsSubmitting(true)
try {
// Write the edited cc-haha settings.json first so provider-specific model
// settings never conflict with the user's global ~/.claude/settings.json.
if (settingsJson.trim()) {
try {
const parsed = restoreSettingsJsonSecrets(JSON.parse(settingsJson), apiKey)
const parsed = restoreSettingsJsonSecrets(JSON.parse(settingsJson), settingsJson, apiKey)
const { providersApi } = await import('../api/providers')
await providersApi.updateSettings(parsed)
} catch {
@ -544,17 +850,25 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
presetId: selectedPreset.id,
name: name.trim(),
apiKey: apiKey.trim(),
authStrategy,
baseUrl: baseUrl.trim(),
apiFormat,
models,
...(parsedAutoCompactWindow !== undefined && { autoCompactWindow: parsedAutoCompactWindow }),
...(Object.keys(parsedModelContextWindows).length > 0 && { modelContextWindows: parsedModelContextWindows }),
notes: notes.trim() || undefined,
})
} else if (provider) {
const input: UpdateProviderInput = {
name: name.trim(),
baseUrl: baseUrl.trim(),
authStrategy,
apiFormat,
models,
autoCompactWindow: parsedAutoCompactWindow ?? null,
modelContextWindows: Object.keys(parsedModelContextWindows).length > 0
? parsedModelContextWindows
: null,
notes: notes.trim() || undefined,
}
if (apiKey.trim()) input.apiKey = apiKey.trim()
@ -580,6 +894,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
baseUrl: baseUrl.trim(),
modelId: models.main.trim(),
apiFormat,
authStrategy,
})
} else {
if (requiresApiKey && !apiKey.trim()) return
@ -587,6 +902,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,
})
}
@ -635,7 +951,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') ? (
@ -644,7 +960,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={
@ -670,6 +986,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')}
@ -680,7 +1018,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)]"
/>
@ -731,13 +1069,103 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelMapping')}</label>
<div className="grid grid-cols-2 gap-2">
<Input label={t('settings.providers.mainModel')} required value={models.main} onChange={(e) => setModels({ ...models, main: e.target.value })} placeholder="Model ID" />
<Input label={t('settings.providers.haikuModel')} value={models.haiku} onChange={(e) => setModels({ ...models, haiku: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.sonnetModel')} value={models.sonnet} onChange={(e) => setModels({ ...models, sonnet: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.opusModel')} value={models.opus} onChange={(e) => setModels({ ...models, opus: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.mainModel')} required value={models.main} onChange={(e) => handleModelChange('main', e.target.value)} placeholder="Model ID" />
<Input label={t('settings.providers.haikuModel')} value={models.haiku} onChange={(e) => handleModelChange('haiku', e.target.value)} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.sonnetModel')} value={models.sonnet} onChange={(e) => handleModelChange('sonnet', e.target.value)} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.opusModel')} value={models.opus} onChange={(e) => handleModelChange('opus', e.target.value)} placeholder={t('settings.providers.sameAsMain')} />
</div>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<button
type="button"
onClick={() => setShowContextSettings((visible) => !visible)}
className="flex w-full items-start gap-3 px-3 py-3 text-left outline-none transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:shadow-[var(--shadow-focus-ring)]"
aria-expanded={shouldShowContextFields}
>
<span className="material-symbols-outlined mt-0.5 text-[18px] text-[var(--color-brand)]">compress</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.providers.contextSettingsTitle')}
</span>
<span className="mt-1 block truncate text-xs text-[var(--color-text-secondary)]">
{contextSummary}
</span>
<span className="mt-1 block text-[11px] leading-5 text-[var(--color-text-tertiary)]">
{t('settings.providers.contextSettingsDesc')}
</span>
</span>
<span className="mt-0.5 inline-flex items-center gap-1 text-xs font-medium text-[var(--color-brand)]">
{shouldShowContextFields
? t('settings.providers.contextSettingsHide')
: t('settings.providers.contextSettingsEdit')}
<span className="material-symbols-outlined text-[16px]">
{shouldShowContextFields ? 'expand_less' : 'expand_more'}
</span>
</span>
</button>
{shouldShowContextFields && (
<div className="border-t border-[var(--color-border)] px-3 pb-3 pt-3">
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelContextWindows')}</label>
<div className="grid grid-cols-2 gap-2">
{MODEL_SLOTS.map((slot) => {
const errorKey = getModelContextWindowErrorKey(modelContextInputs[slot])
const labelKey = slot === 'main'
? 'settings.providers.mainContextWindow'
: slot === 'haiku'
? 'settings.providers.haikuContextWindow'
: slot === 'sonnet'
? 'settings.providers.sonnetContextWindow'
: 'settings.providers.opusContextWindow'
return (
<div key={slot}>
<Input
label={t(labelKey)}
value={modelContextInputs[slot]}
onChange={(e) => handleModelContextWindowChange(slot, e.target.value)}
placeholder={t('settings.providers.contextWindowPlaceholder')}
/>
{errorKey && (
<p className="text-[11px] text-[var(--color-error)] mt-1">
{errorKey === 'number'
? t('settings.providers.modelContextWindowNumberError')
: t('settings.providers.modelContextWindowRangeError')}
</p>
)}
</div>
)
})}
</div>
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
{t('settings.providers.modelContextWindowsDesc')}
</p>
</div>
<div className="mt-3">
<Input
label={t('settings.providers.autoCompactWindow')}
value={autoCompactWindow}
onChange={(e) => handleAutoCompactWindowChange(e.target.value)}
placeholder={t('settings.providers.autoCompactWindowPlaceholder')}
/>
{autoCompactWindowErrorKey ? (
<p className="text-[11px] text-[var(--color-error)] mt-1">
{autoCompactWindowErrorKey === 'number'
? t('settings.providers.autoCompactWindowNumberError')
: t('settings.providers.autoCompactWindowRangeError')}
</p>
) : (
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
{t('settings.providers.autoCompactWindowDesc')}
</p>
)}
</div>
</div>
)}
</div>
{/* Test connection */}
<div className="flex items-center gap-3">
<Button variant="secondary" size="sm" onClick={handleTest} loading={isTesting} disabled={!baseUrl.trim() || !models.main.trim()}>
@ -769,7 +1197,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
onChange={(e) => {
const raw = e.target.value
try {
const parsed = restoreSettingsJsonSecrets(JSON.parse(raw), apiKey)
const parsed = restoreSettingsJsonSecrets(JSON.parse(raw), settingsJson, apiKey)
setSettingsJson(JSON.stringify(parsed, null, 2))
setSettingsJsonError(null)
// Auto-fill form fields from parsed JSON env
@ -793,13 +1221,46 @@ 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 {
setAutoCompactWindow('')
}
let parsedContextWindows: Record<string, number> = {}
if (typeof env[MODEL_CONTEXT_WINDOWS_ENV_KEY] === 'string') {
try {
const parsedContext = JSON.parse(env[MODEL_CONTEXT_WINDOWS_ENV_KEY]) as Record<string, unknown>
parsedContextWindows = Object.fromEntries(
Object.entries(parsedContext)
.filter(([, value]) => typeof value === 'number' && Number.isInteger(value)),
) as Record<string, number>
} catch {
parsedContextWindows = {}
}
}
const newModels: Partial<ModelMapping> = {}
if (env.ANTHROPIC_MODEL) newModels.main = env.ANTHROPIC_MODEL
if (env.ANTHROPIC_DEFAULT_HAIKU_MODEL) newModels.haiku = env.ANTHROPIC_DEFAULT_HAIKU_MODEL
if (env.ANTHROPIC_DEFAULT_SONNET_MODEL) newModels.sonnet = env.ANTHROPIC_DEFAULT_SONNET_MODEL
if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) newModels.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL
if (Object.keys(newModels).length > 0) {
setModels((prev) => ({ ...prev, ...newModels }))
setModels((prev) => {
const nextModels = { ...prev, ...newModels }
setModelContextInputs(getModelContextInputs(nextModels, {
...selectedPreset,
modelContextWindows: parsedContextWindows,
}))
return nextModels
})
} else if (Object.keys(parsedContextWindows).length > 0) {
setModelContextInputs(getModelContextInputs(models, {
...selectedPreset,
modelContextWindows: parsedContextWindows,
}))
}
}
} catch (err) {

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
@ -9,14 +16,19 @@ export type ModelMapping = {
opus: string
}
export type ModelContextWindows = Record<string, number>
export type SavedProvider = {
id: string
presetId: string
name: string
apiKey: string // masked from server
authStrategy?: ProviderAuthStrategy
baseUrl: string
apiFormat: ApiFormat
models: ModelMapping
autoCompactWindow?: number
modelContextWindows?: ModelContextWindows
notes?: string
}
@ -24,18 +36,24 @@ export type CreateProviderInput = {
presetId: string
name: string
apiKey: string
authStrategy?: ProviderAuthStrategy
baseUrl: string
apiFormat?: ApiFormat
models: ModelMapping
autoCompactWindow?: number
modelContextWindows?: ModelContextWindows
notes?: string
}
export type UpdateProviderInput = {
name?: string
apiKey?: string
authStrategy?: ProviderAuthStrategy
baseUrl?: string
apiFormat?: ApiFormat
models?: ModelMapping
autoCompactWindow?: number | null
modelContextWindows?: ModelContextWindows | null
notes?: string
}
@ -43,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,5 +18,7 @@ export type ProviderPreset = {
apiKeyUrl?: string
promoText?: string
featured?: boolean
authStrategy?: ProviderAuthStrategy
defaultEnv?: Record<string, string>
modelContextWindows?: Record<string, number>
}

View File

@ -0,0 +1,297 @@
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { startServer } from '../src/server/index.js'
type ServerMsg = {
type: string
[key: string]: unknown
}
const modelId = process.env.REPRO_MODEL_ID || 'MiniMax-M2.7-highspeed'
const port = Number(process.env.REPRO_SERVER_PORT || 19747)
const baseUrl = `http://127.0.0.1:${port}`
const wsUrl = `ws://127.0.0.1:${port}`
const turnCount = Number(process.env.REPRO_TURNS || 12)
const chunkChars = Number(process.env.REPRO_CHUNK_CHARS || 120_000)
const turnTimeoutMs = Number(process.env.REPRO_TURN_TIMEOUT_MS || 240_000)
const postErrorGraceMs = Number(process.env.REPRO_POST_ERROR_GRACE_MS || 1500)
const existingSessionId = process.env.REPRO_SESSION_ID || ''
const runPrewarm = process.env.REPRO_PREWARM === '1'
const keepWorkDir = process.env.REPRO_KEEP_WORKDIR === '1'
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function makeLongPrompt(turn: number): string {
const seed =
`Issue 247 long-context reproduction turn ${turn}. ` +
'Only answer with exactly "OK". Do not summarize or analyze the filler. '
const filler = (
'Long context filler for MiniMax desktop-session reproduction. ' +
'This text is intentionally repetitive and contains no instructions. '
).repeat(Math.ceil(chunkChars / 104))
return `${seed}\n\n${filler.slice(0, chunkChars)}`
}
function summarizeMessage(msg: ServerMsg): Record<string, unknown> {
if (msg.type === 'content_delta') {
return { type: msg.type, textLength: String(msg.text ?? '').length }
}
if (msg.type === 'error') {
return {
type: msg.type,
code: msg.code,
retryable: msg.retryable,
message: String(msg.message ?? '').slice(0, 1200),
}
}
if (msg.type === 'message_complete') {
return { type: msg.type, usage: msg.usage }
}
if (msg.type === 'status') {
return { type: msg.type, state: msg.state, verb: msg.verb }
}
if (msg.type === 'system_notification') {
return { type: msg.type, subtype: msg.subtype, message: msg.message }
}
return { type: msg.type }
}
async function createSession(workDir: string): Promise<string> {
const res = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir }),
})
if (!res.ok) {
throw new Error(`create session failed: ${res.status} ${await res.text()}`)
}
const body = await res.json() as { sessionId: string }
return body.sessionId
}
async function fetchDiagnostics(sessionId: string) {
const res = await fetch(`${baseUrl}/api/diagnostics/events?limit=200`)
if (!res.ok) return []
const body = await res.json() as { events?: Array<Record<string, unknown>> }
return (body.events ?? []).filter((event) => event.sessionId === sessionId)
}
async function resolveProviderId(): Promise<string> {
if (process.env.REPRO_PROVIDER_ID) return process.env.REPRO_PROVIDER_ID
const res = await fetch(`${baseUrl}/api/providers`)
if (!res.ok) {
throw new Error(`list providers failed: ${res.status} ${await res.text()}`)
}
const body = await res.json() as {
providers?: Array<{ id: string; name: string; presetId?: string }>
}
const provider = (body.providers ?? []).find((entry) =>
entry.presetId === 'minimax' ||
entry.name.toLowerCase() === 'minimax' ||
entry.name.toLowerCase().includes('minimax')
)
if (!provider) {
throw new Error('MiniMax provider not found. Set REPRO_PROVIDER_ID to a configured provider id.')
}
return provider.id
}
async function connect(sessionId: string): Promise<{
ws: WebSocket
messages: ServerMsg[]
waitForConnected: () => Promise<void>
sendTurn: (content: string) => Promise<ServerMsg[]>
collectSince: (index: number, waitMs: number) => Promise<ServerMsg[]>
close: () => void
}> {
const messages: ServerMsg[] = []
let connectedResolve: (() => void) | null = null
let connectedReject: ((err: Error) => void) | null = null
let turnResolve: ((value: ServerMsg[]) => void) | null = null
let turnReject: ((err: Error) => void) | null = null
let turnStartIndex = 0
let turnTimer: ReturnType<typeof setTimeout> | null = null
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
ws.onmessage = (event) => {
const msg = JSON.parse(String(event.data)) as ServerMsg
messages.push(msg)
if (msg.type === 'connected') {
connectedResolve?.()
connectedResolve = null
}
if (turnResolve && (msg.type === 'message_complete' || msg.type === 'error')) {
if (turnTimer) clearTimeout(turnTimer)
const batch = messages.slice(turnStartIndex)
const resolve = turnResolve
turnResolve = null
turnReject = null
resolve(batch)
}
}
ws.onerror = () => {
const err = new Error(`WebSocket error for ${sessionId}`)
connectedReject?.(err)
turnReject?.(err)
}
ws.onclose = () => {
if (turnResolve) {
if (turnTimer) clearTimeout(turnTimer)
const batch = messages.slice(turnStartIndex)
const resolve = turnResolve
turnResolve = null
turnReject = null
resolve(batch)
}
}
return {
ws,
messages,
waitForConnected() {
if (messages.some((msg) => msg.type === 'connected')) return Promise.resolve()
return new Promise<void>((resolve, reject) => {
connectedResolve = resolve
connectedReject = reject
setTimeout(() => reject(new Error(`Timed out waiting for connected ${sessionId}`)), 10_000)
})
},
sendTurn(content: string) {
if (turnResolve) throw new Error('turn already in flight')
turnStartIndex = messages.length
ws.send(JSON.stringify({ type: 'user_message', content }))
return new Promise<ServerMsg[]>((resolve, reject) => {
turnResolve = resolve
turnReject = reject
turnTimer = setTimeout(() => {
turnResolve = null
turnReject = null
reject(new Error(`Timed out waiting for terminal turn event after ${turnTimeoutMs}ms`))
}, turnTimeoutMs)
})
},
async collectSince(index: number, waitMs: number) {
await sleep(waitMs)
return messages.slice(index)
},
close() {
ws.close()
},
}
}
async function main() {
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-issue-247-real-'))
const server = startServer(port, '127.0.0.1')
await sleep(500)
try {
const providerId = await resolveProviderId()
const sessionId = existingSessionId || await createSession(workDir)
console.log(JSON.stringify({
event: 'start',
sessionId,
workDir,
providerId,
modelId,
turnCount,
chunkChars,
disableCompact: process.env.DISABLE_COMPACT === '1',
existingSession: Boolean(existingSessionId),
runPrewarm,
keepWorkDir,
}))
const client = await connect(sessionId)
await client.waitForConnected()
client.ws.send(JSON.stringify({ type: 'set_runtime_config', providerId, modelId }))
await sleep(250)
if (runPrewarm) {
const startIndex = client.messages.length
client.ws.send(JSON.stringify({ type: 'prewarm_session' }))
const batch = await client.collectSince(startIndex, 5000)
console.log(JSON.stringify({
event: 'prewarm',
messageTypes: batch.map((msg) => msg.type),
messages: batch.map(summarizeMessage),
}))
}
for (let turn = 1; !runPrewarm && turn <= turnCount; turn++) {
const startedAt = Date.now()
const batch = await client.sendTurn(makeLongPrompt(turn))
const errors = batch.filter((msg) => msg.type === 'error')
const completions = batch.filter((msg) => msg.type === 'message_complete')
console.log(JSON.stringify({
event: 'turn',
turn,
durationMs: Date.now() - startedAt,
messageTypes: batch.map((msg) => msg.type),
errors: errors.map(summarizeMessage),
completions: completions.map(summarizeMessage),
}))
if (errors.length > 0) {
await sleep(postErrorGraceMs)
console.log(JSON.stringify({
event: 'post_error_messages',
turn,
recent: client.messages.slice(-12).map(summarizeMessage),
}))
break
}
}
client.close()
await sleep(1000)
const resumeClient = await connect(sessionId)
await resumeClient.waitForConnected()
resumeClient.ws.send(JSON.stringify({ type: 'set_runtime_config', providerId, modelId }))
await sleep(250)
try {
const batch = await resumeClient.sendTurn('Continue with exactly "OK".')
await sleep(postErrorGraceMs)
console.log(JSON.stringify({
event: 'resume_turn',
messageTypes: resumeClient.messages.slice(1).map((msg) => msg.type),
errors: resumeClient.messages.filter((msg) => msg.type === 'error').map(summarizeMessage),
completions: resumeClient.messages.filter((msg) => msg.type === 'message_complete').map(summarizeMessage),
recent: resumeClient.messages.slice(-12).map(summarizeMessage),
}))
} finally {
resumeClient.close()
}
const diagnostics = await fetchDiagnostics(sessionId)
console.log(JSON.stringify({
event: 'diagnostics',
count: diagnostics.length,
events: diagnostics.map((event) => ({
timestamp: event.timestamp,
type: event.type,
severity: event.severity,
summary: String(event.summary ?? '').slice(0, 1200),
})),
}))
} finally {
server.stop(true)
if (!keepWorkDir) {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {})
}
}
}
main().catch((error) => {
console.error(JSON.stringify({
event: 'fatal',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}))
process.exit(1)
})
.then(() => process.exit(0))

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

@ -837,6 +837,38 @@ describe('WebSocket Chat Integration', () => {
expect(messages.at(-1)?.type).toBe('message_complete')
}, 15_000)
it('should not duplicate SDK API errors with the final error result', async () => {
const messages = await runTurnUntilComplete(
`chat-api-error-${crypto.randomUUID()}`,
'trigger api error',
)
const errors = messages.filter((m) => m.type === 'error')
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({
code: 'invalid_request',
message: 'Prompt is too long',
})
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
expect(messages.at(-1)?.type).toBe('message_complete')
}, 15_000)
it('should not add a CLI exit error after a reported SDK API error', async () => {
const messages = await runTurnUntilComplete(
`chat-api-error-exit-${crypto.randomUUID()}`,
'trigger api error then exit',
)
const errors = messages.filter((m) => m.type === 'error')
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({
code: 'invalid_request',
message: 'Prompt is too long',
})
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
expect(messages.at(-1)?.type).toBe('message_complete')
}, 15_000)
it('should handle permission_response without error', async () => {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/chat-test-4`)

View File

@ -120,6 +120,31 @@ ws.addEventListener('message', (event) => {
})
continue
}
if (text.includes('trigger api error')) {
emit(ws, {
type: 'assistant',
error: 'invalid_request',
isApiErrorMessage: true,
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Prompt is too long' }],
},
session_id: sessionId,
})
if (text.includes('then exit')) {
setTimeout(() => process.exit(1), 10)
continue
}
emit(ws, {
type: 'result',
subtype: 'success',
is_error: true,
result: 'Prompt is too long',
usage: { input_tokens: 0, output_tokens: 0 },
session_id: sessionId,
})
continue
}
emit(ws, {
type: 'stream_event',
event: { type: 'message_start' },

View File

@ -72,28 +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')
})
@ -112,16 +121,25 @@ describe('provider presets API', () => {
expect(lmstudio?.needsApiKey).toBe(false)
expect(lmstudio?.promoText).toContain('http://localhost:1234')
expect(lmstudio?.promoText).toContain('200K')
expect(lmstudio?.defaultEnv).toEqual({ ANTHROPIC_AUTH_TOKEN: 'lmstudio' })
expect(lmstudio?.defaultEnv).toEqual({
ANTHROPIC_AUTH_TOKEN: 'lmstudio',
})
expect(ollama?.needsApiKey).toBe(false)
expect(ollama?.promoText).toContain('http://localhost:11434')
expect(ollama?.promoText).toContain('200K')
expect(ollama?.defaultEnv).toEqual({ ANTHROPIC_AUTH_TOKEN: 'ollama' })
expect(ollama?.defaultEnv).toEqual({
ANTHROPIC_AUTH_TOKEN: 'ollama',
})
expect(deepseek?.apiKeyUrl).toBe('https://platform.deepseek.com/api_keys')
expect(deepseek?.modelContextWindows?.['deepseek-v4-pro']).toBe(1000000)
expect(deepseek?.modelContextWindows?.['deepseek-v4-flash']).toBe(1000000)
expect(zhipu?.apiKeyUrl).toBe('https://www.bigmodel.cn/invite?icode=d41B2qi8Z5xNwTGLNPPF3OZLO2QH3C0EBTSr%2BArzMw4%3D')
expect(zhipu?.promoText).toContain('cc-haha')
expect(zhipu?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBe('1')
expect(zhipu?.modelContextWindows?.['glm-5.1']).toBe(200000)
expect(zhipu?.modelContextWindows?.['glm-4.5-air']).toBe(128000)
expect(kimi?.apiKeyUrl).toBe('https://platform.kimi.com/console/api-keys')
expect(kimi?.modelContextWindows?.['kimi-k2.6']).toBe(262144)
expect(minimax?.apiKeyUrl).toBe('https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link')
expect(jiekouai?.apiKeyUrl).toBe('https://jiekou.ai/referral?invited_code=OBNU3K')
expect(jiekouai?.promoText).toContain('官方 8 折')
@ -134,7 +152,10 @@ describe('provider presets API', () => {
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES: 'none',
})
expect(shengsuanyun?.modelContextWindows?.['anthropic/claude-opus-4.7']).toBe(1000000)
expect(custom?.promoText).toBeUndefined()
expect(custom?.authStrategy).toBe('auth_token')
expect(custom?.defaultEnv).toBeUndefined()
})
test('GET and PUT /api/providers/settings read and write cc-haha settings.json', async () => {

View File

@ -66,8 +66,13 @@ 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,
'MiniMax-M2.7-highspeed': 204800,
})
// 验证原版 settings.json 没有被创建
expect(await originalSettingsExists()).toBe(false)
@ -101,13 +106,19 @@ describe('Real Provider Configs', () => {
await service.activateProvider(minimax.id)
let settings = await readCcHahaSettings()
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
expect(JSON.parse((settings.env as Record<string, string>).CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toMatchObject({
'MiniMax-M2.7': 204800,
'MiniMax-M2.7-highspeed': 204800,
})
// 切换到接口AI中转站
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()
// 验证 activeId 正确
const list = await service.listProviders()
@ -151,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')
@ -182,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')
@ -192,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?: { ... } }
@ -237,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'
@ -153,6 +153,28 @@ describe('ProviderService', () => {
expect(provider.notes).toBe('dev environment')
})
test('should preserve optional auto compact window', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({ autoCompactWindow: 64000 }))
expect(provider.autoCompactWindow).toBe(64000)
})
test('should preserve optional model context windows', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
modelContextWindows: {
'model-main': 300000,
'model-haiku': 128000,
},
}))
expect(provider.modelContextWindows).toEqual({
'model-main': 300000,
'model-haiku': 128000,
})
})
})
// ─── getProvider ─────────────────────────────────────────────────────────
@ -223,9 +245,62 @@ 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')
})
test('updating active provider should override and clear auto compact window', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput({ autoCompactWindow: 64000 }))
await svc.activateProvider(added.id)
let settings = await readSettings()
let env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('64000')
await svc.updateProvider(added.id, { autoCompactWindow: 32000 })
settings = await readSettings()
env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('32000')
await svc.updateProvider(added.id, { autoCompactWindow: null })
settings = await readSettings()
env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
})
test('updating active provider should override and clear model context windows', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput({
modelContextWindows: { 'model-main': 300000 },
}))
await svc.activateProvider(added.id)
let settings = await readSettings()
let env = settings.env as Record<string, string>
expect(JSON.parse(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toEqual({
'model-main': 300000,
})
await svc.updateProvider(added.id, {
modelContextWindows: { 'model-main': 500000 },
})
settings = await readSettings()
env = settings.env as Record<string, string>
expect(JSON.parse(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toEqual({
'model-main': 500000,
})
await svc.updateProvider(added.id, { modelContextWindows: null })
settings = await readSettings()
env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS).toBeUndefined()
})
})
// ─── deleteProvider ──────────────────────────────────────────────────────
@ -310,11 +385,66 @@ 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')
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('model-opus')
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 () => {
@ -330,16 +460,47 @@ describe('ProviderService', () => {
const env = settings.env as Record<string, string>
expect(env.API_TIMEOUT_MS).toBe('3000000')
expect(env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe('1')
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
expect(JSON.parse(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toEqual({
'anthropic/claude-sonnet-4.6': 200000,
'anthropic/claude-haiku-4.5:thinking': 200000,
'anthropic/claude-opus-4.7': 1000000,
})
const runtimeEnv = await svc.getProviderRuntimeEnv(provider.id)
expect(runtimeEnv.API_TIMEOUT_MS).toBe('3000000')
expect(runtimeEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe('1')
expect(runtimeEnv.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
expect(JSON.parse(runtimeEnv.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toEqual({
'anthropic/claude-sonnet-4.6': 200000,
'anthropic/claude-haiku-4.5:thinking': 200000,
'anthropic/claude-opus-4.7': 1000000,
})
await svc.activateOfficial()
const clearedSettings = await readSettings()
const clearedEnv = (clearedSettings.env as Record<string, string> | undefined) ?? {}
expect(clearedEnv.API_TIMEOUT_MS).toBeUndefined()
expect(clearedEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBeUndefined()
expect(clearedEnv.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
expect(clearedEnv.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS).toBeUndefined()
})
test('provider auto compact window should override preset default env on activation and runtime env', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
presetId: 'custom',
autoCompactWindow: 32000,
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('32000')
const runtimeEnv = await svc.getProviderRuntimeEnv(provider.id)
expect(runtimeEnv.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('32000')
})
test('should preserve existing settings.json fields on activation', async () => {
@ -407,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
}
})
})
})
// =============================================================================
@ -452,6 +709,7 @@ describe('Providers API', () => {
baseUrl: 'https://api.example.com',
apiKey: 'sk-test',
apiFormat: 'anthropic',
autoCompactWindow: 64000,
models: {
main: 'gpt-4',
haiku: 'gpt-4-haiku',
@ -462,9 +720,10 @@ describe('Providers API', () => {
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(201)
const body = (await res.json()) as { provider: { name: string; models: { main: string } } }
const body = (await res.json()) as { provider: { name: string; models: { main: string }; autoCompactWindow: number } }
expect(body.provider.name).toBe('New Provider')
expect(body.provider.models.main).toBe('gpt-4')
expect(body.provider.autoCompactWindow).toBe(64000)
})
test('POST /api/providers should return 400 for invalid input', async () => {
@ -476,6 +735,26 @@ describe('Providers API', () => {
expect(res.status).toBe(400)
})
test('POST /api/providers should return 400 for invalid auto compact window', async () => {
const { req, url, segments } = makeRequest('POST', '/api/providers', {
presetId: 'custom',
name: 'New Provider',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test',
apiFormat: 'anthropic',
autoCompactWindow: 8000,
models: {
main: 'gpt-4',
haiku: 'gpt-4-haiku',
sonnet: 'gpt-4-sonnet',
opus: 'gpt-4-opus',
},
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(400)
})
// ─── GET /api/providers/:id ──────────────────────────────────────────────
test('GET /api/providers/:id should return a provider', async () => {
@ -566,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,8 +27,15 @@
"needsApiKey": true,
"websiteUrl": "https://platform.deepseek.com",
"apiKeyUrl": "https://platform.deepseek.com/api_keys",
"authStrategy": "auth_token",
"defaultEnv": {
"CC_HAHA_SEND_DISABLED_THINKING": "1"
},
"modelContextWindows": {
"deepseek-v4-pro": 1000000,
"deepseek-v4-flash": 1000000,
"deepseek-chat": 1000000,
"deepseek-reasoner": 1000000
}
},
{
@ -46,8 +53,14 @@
"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"
},
"modelContextWindows": {
"glm-5.1": 200000,
"glm-5-turbo": 200000,
"glm-4.5-air": 128000
}
},
{
@ -64,8 +77,17 @@
"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"
},
"modelContextWindows": {
"kimi-k2.6": 262144,
"kimi-k2.5": 262144,
"kimi-k2-0905-preview": 262144,
"kimi-k2-turbo-preview": 262144,
"kimi-k2-thinking": 262144,
"kimi-k2-thinking-turbo": 262144
}
},
{
@ -81,7 +103,17 @@
},
"needsApiKey": true,
"websiteUrl": "https://platform.minimaxi.com",
"apiKeyUrl": "https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link"
"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,
"MiniMax-M2.5": 204800,
"MiniMax-M2.5-highspeed": 204800,
"MiniMax-M2.1": 204800,
"MiniMax-M2.1-highspeed": 204800,
"MiniMax-M2": 204800
}
},
{
"id": "jiekouai",
@ -99,8 +131,15 @@
"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"
},
"modelContextWindows": {
"claude-sonnet-4-6": 200000,
"claude-haiku-4-5-20251001": 200000,
"claude-haiku-4-5": 200000,
"claude-opus-4-7": 1000000
}
},
{
@ -119,10 +158,16 @@
"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",
"ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "none"
},
"modelContextWindows": {
"anthropic/claude-sonnet-4.6": 200000,
"anthropic/claude-haiku-4.5:thinking": 200000,
"anthropic/claude-opus-4.7": 1000000
}
},
{
@ -139,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"
}
@ -157,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"
}
@ -173,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,7 +24,12 @@ 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),
z.number().int().min(16000).max(10000000),
).optional(),
})
const ProviderPresetsSchema = z.array(ProviderPresetSchema)

View File

@ -499,6 +499,16 @@ export class ConversationService {
if (session.sdkMessages.length > 40) {
session.sdkMessages.splice(0, 20)
}
const sdkError = this.extractSdkErrorEvent(msg)
if (sdkError) {
void diagnosticsService.recordEvent({
type: sdkError.type,
severity: 'error',
sessionId,
summary: sdkError.summary,
details: sdkError.details,
})
}
if (msg?.type === 'system' && msg.subtype === 'init') {
session.initMessage = msg
}
@ -742,6 +752,8 @@ export class ConversationService {
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
'CC_HAHA_SEND_DISABLED_THINKING',
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS',
] as const
const cleanEnv = { ...process.env }
@ -866,6 +878,8 @@ export class ConversationService {
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
'CC_HAHA_SEND_DISABLED_THINKING',
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS',
].some((key) => typeof env[key] === 'string' && env[key]!.trim().length > 0)
} catch {
return false
@ -960,11 +974,15 @@ export class ConversationService {
const resultMessage = [...recentMessages]
.reverse()
.find((msg) => msg?.type === 'result' && msg.is_error)
const assistantApiError = [...recentMessages]
.reverse()
.find((msg) => this.isAssistantApiErrorMessage(msg))
const authStatus = [...recentMessages]
.reverse()
.find((msg) => msg?.type === 'auth_status')
const detail =
this.extractStartupDetail(resultMessage) ||
this.extractAssistantApiErrorDetail(assistantApiError) ||
this.extractStartupDetail(authStatus) ||
capturedOutput
@ -1004,11 +1022,15 @@ export class ConversationService {
const resultMessage = [...recentMessages]
.reverse()
.find((msg) => msg?.type === 'result' && msg.is_error)
const assistantApiError = [...recentMessages]
.reverse()
.find((msg) => this.isAssistantApiErrorMessage(msg))
const authStatus = [...recentMessages]
.reverse()
.find((msg) => msg?.type === 'auth_status')
const detail =
this.extractStartupDetail(resultMessage) ||
this.extractAssistantApiErrorDetail(assistantApiError) ||
this.extractStartupDetail(authStatus) ||
capturedOutput
@ -1055,6 +1077,77 @@ export class ConversationService {
return ''
}
private isAssistantApiErrorMessage(message: any): boolean {
return (
message?.type === 'assistant' &&
(message.isApiErrorMessage === true || typeof message.error === 'string')
)
}
private extractAssistantApiErrorDetail(message: any): string {
if (!this.isAssistantApiErrorMessage(message)) return ''
const text = this.extractAssistantText(message)
const error = typeof message.error === 'string' ? message.error : ''
if (text && error) return `${error}: ${text}`
return text || error
}
private extractAssistantText(message: any): string {
const content = message?.message?.content
if (!Array.isArray(content)) return ''
const textBlock = content.find(
(block: unknown): block is { type: string; text: string } =>
!!block &&
typeof block === 'object' &&
(block as { type?: unknown }).type === 'text' &&
typeof (block as { text?: unknown }).text === 'string',
)
return textBlock?.text || ''
}
private extractSdkErrorEvent(message: any): {
type: string
summary: string
details: Record<string, unknown>
} | null {
if (this.isAssistantApiErrorMessage(message)) {
const summary = this.redactProcessOutput(
this.extractAssistantApiErrorDetail(message) || 'Assistant API error',
)
return {
type: 'sdk_api_error',
summary,
details: {
sdkType: message.type,
error: typeof message.error === 'string' ? message.error : undefined,
isApiErrorMessage: message.isApiErrorMessage === true,
errorDetails:
typeof message.errorDetails === 'string'
? this.redactProcessOutput(message.errorDetails)
: undefined,
},
}
}
if (message?.type === 'result' && message.is_error) {
const summary = this.redactProcessOutput(
this.extractStartupDetail(message) || 'SDK result error',
)
return {
type: 'sdk_result_error',
summary,
details: {
sdkType: message.type,
subtype: message.subtype,
isError: true,
},
}
}
return null
}
private summarizeSdkMessages(messages: any[]): unknown[] {
return messages.slice(-10).map((message) => {
if (!message || typeof message !== 'object') {

View File

@ -16,6 +16,7 @@ import { openaiChatToAnthropic } from '../proxy/transform/openaiChatToAnthropic.
import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesToAnthropic.js'
import type { AnthropicRequest, AnthropicResponse } from '../proxy/transform/types.js'
import { PROVIDER_PRESETS } from '../config/providerPresets.js'
import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js'
import type {
SavedProvider,
ProvidersIndex,
@ -25,6 +26,7 @@ import type {
ProviderTestResult,
ProviderTestStepResult,
ApiFormat,
ProviderAuthStrategy,
} from '../types/provider.js'
const MANAGED_ENV_KEYS = [
@ -38,14 +40,60 @@ const MANAGED_ENV_KEYS = [
'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
MODEL_CONTEXT_WINDOWS_ENV_KEY,
] 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) {
@ -165,9 +213,12 @@ 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,
...(input.autoCompactWindow !== undefined && { autoCompactWindow: input.autoCompactWindow }),
...(input.modelContextWindows !== undefined && { modelContextWindows: input.modelContextWindows }),
...(input.notes !== undefined && { notes: input.notes }),
}
@ -186,11 +237,20 @@ 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 }),
...(typeof input.autoCompactWindow === 'number' && { autoCompactWindow: input.autoCompactWindow }),
...(input.modelContextWindows !== undefined && input.modelContextWindows !== null && { modelContextWindows: input.modelContextWindows }),
...(input.notes !== undefined && { notes: input.notes }),
}
if (input.autoCompactWindow === null) {
delete updated.autoCompactWindow
}
if (input.modelContextWindows === null) {
delete updated.modelContextWindows
}
index.providers[idx] = updated
await this.writeIndex(index)
@ -251,10 +311,23 @@ export class ProviderService {
? `http://127.0.0.1:${ProviderService.serverPort}${proxyPath}`
: provider.baseUrl
const modelContextWindows = {
...getPresetModelContextWindows(provider.presetId),
...(provider.modelContextWindows ?? {}),
}
const presetDefaultEnv = getPresetDefaultEnv(provider.presetId)
return {
...getPresetDefaultEnv(provider.presetId),
...omitAuthEnv(presetDefaultEnv),
...(provider.autoCompactWindow !== undefined && {
CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(provider.autoCompactWindow),
}),
...(Object.keys(modelContextWindows).length > 0 && {
[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,
@ -385,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) {
@ -434,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,
@ -546,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.'
@ -566,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(),
@ -21,14 +30,23 @@ export const ModelMappingSchema = z.object({
opus: z.string(),
})
export const AutoCompactWindowSchema = z.number().int().min(16000).max(10000000)
export const ModelContextWindowsSchema = z.record(
z.string().min(1),
z.number().int().min(16000).max(10000000),
)
export const SavedProviderSchema = z.object({
id: z.string(),
presetId: z.string(),
name: z.string().min(1),
apiKey: z.string(),
authStrategy: ProviderAuthStrategySchema.optional(),
baseUrl: z.string(),
apiFormat: ApiFormatSchema.default('anthropic'),
models: ModelMappingSchema,
autoCompactWindow: AutoCompactWindowSchema.optional(),
modelContextWindows: ModelContextWindowsSchema.optional(),
notes: z.string().optional(),
})
@ -41,18 +59,24 @@ 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,
autoCompactWindow: AutoCompactWindowSchema.optional(),
modelContextWindows: ModelContextWindowsSchema.optional(),
notes: z.string().optional(),
})
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(),
autoCompactWindow: AutoCompactWindowSchema.nullable().optional(),
modelContextWindows: ModelContextWindowsSchema.nullable().optional(),
notes: z.string().optional(),
})
@ -60,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'),
})

View File

@ -680,6 +680,10 @@ type SessionStreamState = {
/** Tool blocks whose input JSON failed to parse in content_block_stop.
* The assistant message carries the complete input defer to that. */
pendingToolBlocks: Map<string, { toolName: string; toolUseId: string; parentToolUseId?: string }>
lastApiError?: {
message: string
code: string
}
}
const sessionStreamStates = new Map<string, SessionStreamState>()
@ -692,6 +696,7 @@ function getStreamState(sessionId: string): SessionStreamState {
activeBlockTypes: new Map(),
activeToolBlocks: new Map(),
pendingToolBlocks: new Map(),
lastApiError: undefined,
}
sessionStreamStates.set(sessionId, state)
}
@ -761,6 +766,31 @@ function cacheSessionInitMetadata(sessionId: string, cliMsg: any) {
}
}
function extractAssistantText(cliMsg: any): string {
const content = cliMsg?.message?.content
if (!Array.isArray(content)) return ''
const textBlock = content.find(
(block: unknown): block is { type: string; text: string } =>
!!block &&
typeof block === 'object' &&
(block as { type?: unknown }).type === 'text' &&
typeof (block as { text?: unknown }).text === 'string',
)
return textBlock?.text || ''
}
function isDuplicateOfLastApiError(
lastApiError: SessionStreamState['lastApiError'],
resultMessage: string,
): boolean {
if (!lastApiError?.message) return false
if (resultMessage === lastApiError.message) return true
return (
resultMessage.includes(lastApiError.message) &&
/CLI (?:process exited unexpectedly|exited during startup)/i.test(resultMessage)
)
}
function bindPrewarmMetadataCapture(sessionId: string) {
for (const msg of conversationService.getRecentSdkMessages(sessionId)) {
cacheSessionInitMetadata(sessionId, msg)
@ -831,11 +861,14 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
const streamState = getStreamState(sessionId)
switch (cliMsg.type) {
case 'assistant': {
if (cliMsg.error) {
if (cliMsg.error || cliMsg.isApiErrorMessage) {
const message = extractAssistantText(cliMsg) || cliMsg.error || 'Unknown API error'
const code = typeof cliMsg.error === 'string' ? cliMsg.error : 'API_ERROR'
streamState.lastApiError = { message, code }
return [{
type: 'error',
message: cliMsg.message?.content?.[0]?.text || cliMsg.error,
code: cliMsg.error,
message,
code,
}]
}
@ -1078,6 +1111,10 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
(Array.isArray(cliMsg.errors) && cliMsg.errors.length > 0
? cliMsg.errors.join('\n')
: 'Unknown error')
if (isDuplicateOfLastApiError(streamState.lastApiError, resultMessage)) {
streamState.lastApiError = undefined
return [{ type: 'message_complete', usage }]
}
// 错误和完成消息都发送
return [
{
@ -1091,6 +1128,7 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
// Clear stop flag on successful completion too
sessionStopRequested.delete(sessionId)
streamState.lastApiError = undefined
return [{ type: 'message_complete', usage }]
}

View File

@ -0,0 +1,54 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
import { getEffectiveContextWindowSize } from './autoCompact.js'
import { getContextWindowForModel } from '../../utils/context.js'
import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js'
let originalAutoCompactWindow: string | undefined
let originalContextWindows: string | undefined
beforeEach(() => {
originalAutoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
originalContextWindows = process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY]
delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
delete process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY]
})
afterEach(() => {
if (originalAutoCompactWindow === undefined) {
delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
} else {
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = originalAutoCompactWindow
}
if (originalContextWindows === undefined) {
delete process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY]
} else {
process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY] = originalContextWindows
}
})
describe('model context window resolution', () => {
test('uses built-in windows for current third-party coding models', () => {
expect(getContextWindowForModel('deepseek-v4-pro')).toBe(1_000_000)
expect(getContextWindowForModel('MiniMax-M2.7')).toBe(204_800)
expect(getContextWindowForModel('kimi-k2.6')).toBe(262_144)
expect(getContextWindowForModel('glm-5.1')).toBe(200_000)
expect(getContextWindowForModel('glm-4.5-air')).toBe(128_000)
})
test('uses per-model provider overrides before built-in defaults', () => {
process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY] = JSON.stringify({
'deepseek-v4-pro': 500_000,
'custom-model': 300_000,
})
expect(getContextWindowForModel('deepseek-v4-pro')).toBe(500_000)
expect(getContextWindowForModel('provider/custom-model')).toBe(300_000)
})
test('global auto compact window can raise unknown models above the default', () => {
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '1000000'
expect(getEffectiveContextWindowSize('unknown-future-model')).toBe(980_000)
})
})

View File

@ -41,7 +41,7 @@ export function getEffectiveContextWindowSize(model: string): number {
if (autoCompactWindow) {
const parsed = parseInt(autoCompactWindow, 10)
if (!isNaN(parsed) && parsed > 0) {
contextWindow = Math.min(contextWindow, parsed)
contextWindow = parsed
}
}

View File

@ -4,6 +4,7 @@ import { getGlobalConfig } from './config.js'
import { isEnvTruthy } from './envUtils.js'
import { getCanonicalName } from './model/model.js'
import { getModelCapability } from './model/modelCapabilities.js'
import { getConfiguredOrBuiltInModelContextWindow } from './model/modelContextWindows.js'
// Model context window size (200k tokens for all models right now)
export const MODEL_CONTEXT_WINDOW_DEFAULT = 200_000
@ -36,7 +37,7 @@ export function has1mContext(model: string): boolean {
if (is1mContextDisabled()) {
return false
}
return /\[1m\]/i.test(model)
return /\[1m\]/i.test(model) || /:1m$/i.test(model)
}
// @[MODEL LAUNCH]: Update this pattern if the new model supports 1M context
@ -71,6 +72,17 @@ export function getContextWindowForModel(
return 1_000_000
}
const configuredWindow = getConfiguredOrBuiltInModelContextWindow(model)
if (configuredWindow !== undefined) {
if (
configuredWindow > MODEL_CONTEXT_WINDOW_DEFAULT &&
is1mContextDisabled()
) {
return MODEL_CONTEXT_WINDOW_DEFAULT
}
return configuredWindow
}
const cap = getModelCapability(model)
if (cap?.max_input_tokens && cap.max_input_tokens >= 100_000) {
if (

View File

@ -58,6 +58,8 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
'ANTHROPIC_SMALL_FAST_MODEL',
'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION',
'CLAUDE_CODE_SUBAGENT_MODEL',
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS',
])
const PROVIDER_MANAGED_ENV_PREFIXES = [
@ -146,6 +148,7 @@ export const SAFE_ENV_VARS = new Set([
'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL',
'CLAUDE_CODE_MAX_OUTPUT_TOKENS',
'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS',
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
'CLAUDE_CODE_SKIP_VERTEX_AUTH',

View File

@ -0,0 +1,134 @@
export const MODEL_CONTEXT_WINDOWS_ENV_KEY = 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS'
export const MODEL_CONTEXT_WINDOW_MIN = 16_000
export const MODEL_CONTEXT_WINDOW_MAX = 10_000_000
const DIRECT_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
'claude-opus-4-7': 1_000_000,
'claude-sonnet-4-6': 200_000,
'claude-haiku-4-5': 200_000,
'deepseek-v4-pro': 1_000_000,
'deepseek-v4-flash': 1_000_000,
'deepseek-chat': 1_000_000,
'deepseek-reasoner': 1_000_000,
'kimi-k2.6': 262_144,
'kimi-k2.5': 262_144,
'kimi-k2-0905-preview': 262_144,
'kimi-k2-turbo-preview': 262_144,
'kimi-k2-thinking': 262_144,
'kimi-k2-thinking-turbo': 262_144,
'minimax-m2.7': 204_800,
'minimax-m2.7-highspeed': 204_800,
'minimax-m2.5': 204_800,
'minimax-m2.5-highspeed': 204_800,
'minimax-m2.1': 204_800,
'minimax-m2.1-highspeed': 204_800,
'minimax-m2': 204_800,
'glm-5.1': 200_000,
'glm-5': 200_000,
'glm-5-turbo': 200_000,
'glm-4.7': 200_000,
'glm-4.6': 200_000,
'glm-4.5': 128_000,
'glm-4.5-air': 128_000,
}
const PATTERN_MODEL_CONTEXT_WINDOWS: Array<[RegExp, number]> = [
[/^anthropic\/claude-opus-4\.7\b/i, 1_000_000],
[/^anthropic\/claude-sonnet-4\.6\b/i, 200_000],
[/^anthropic\/claude-haiku-4\.5\b/i, 200_000],
[/^openai\/gpt-4\.1\b/i, 1_047_576],
[/^openai\/gpt-5(?:[.-]\d+)?\b/i, 400_000],
[/^google\/gemini-(?:2\.0|2\.5|3)/i, 1_048_576],
[/^gemini-(?:2\.0|2\.5|3)/i, 1_048_576],
[/^qwen\/qwen3\.5-(?:plus|flash)\b/i, 1_000_000],
[/^qwen3\.5-(?:plus|flash)\b/i, 1_000_000],
[/^qwen\/qwen3-max\b/i, 262_144],
[/^qwen3-max\b/i, 262_144],
[/qwen-long/i, 10_000_000],
]
export function normalizeModelContextKey(model: string): string {
return model
.trim()
.replace(/\[1m\]$/i, '')
.replace(/:1m$/i, '')
.toLowerCase()
}
function normalizeWindow(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isInteger(value)) {
return undefined
}
if (value < MODEL_CONTEXT_WINDOW_MIN || value > MODEL_CONTEXT_WINDOW_MAX) {
return undefined
}
return value
}
function parseConfiguredContextWindows(): Record<string, number> {
const raw = process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY]
if (!raw?.trim()) {
return {}
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
const windows: Record<string, number> = {}
for (const [model, value] of Object.entries(parsed)) {
const normalized = normalizeWindow(value)
if (normalized !== undefined) {
windows[normalizeModelContextKey(model)] = normalized
}
}
return windows
} catch {
return {}
}
}
function getConfiguredModelContextWindow(model: string): number | undefined {
const configured = parseConfiguredContextWindows()
const normalizedModel = normalizeModelContextKey(model)
const exact = configured[normalizedModel]
if (exact !== undefined) {
return exact
}
for (const [configuredModel, window] of Object.entries(configured)) {
if (
normalizedModel.endsWith(`/${configuredModel}`) ||
normalizedModel.endsWith(`:${configuredModel}`)
) {
return window
}
}
return undefined
}
function getBuiltInModelContextWindow(model: string): number | undefined {
const normalizedModel = normalizeModelContextKey(model)
const exact = DIRECT_MODEL_CONTEXT_WINDOWS[normalizedModel]
if (exact !== undefined) {
return exact
}
for (const [pattern, window] of PATTERN_MODEL_CONTEXT_WINDOWS) {
if (pattern.test(normalizedModel)) {
return window
}
}
return undefined
}
export function getConfiguredOrBuiltInModelContextWindow(
model: string,
): number | undefined {
return (
getConfiguredModelContextWindow(model) ??
getBuiltInModelContextWindow(model)
)
}