mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
fix: configure provider context windows
Teach provider presets and saved providers to carry per-model context windows so auto-compact uses provider-specific limits instead of a blanket fallback. The desktop provider form now keeps these limits in an advanced context section and syncs the generated settings preview with model changes. Constraint: Third-party provider APIs do not consistently expose machine-readable model context windows. Rejected: Keep a single 100K/200K fallback | modern provider windows vary from 128K to 1M+ and the fallback caused late or early compaction. Confidence: high Scope-risk: moderate Directive: Do not change provider compact thresholds without re-running real provider long-context checks. Tested: bun test src/services/compact/autoCompact.test.ts; bun test src/server/__tests__/provider-presets.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/providers-real.test.ts; bun run check:server; bun run check:desktop; bun run check:policy; bun run check:native; MiniMax/Kimi/DeepSeek live long-context probes; agent-browser provider form flow. Not-tested: quality:pr policy gate still requires explicit allow-cli-core-change for CLI core edits.
This commit is contained in:
parent
e72047adfb
commit
8711ba1560
@ -188,6 +188,27 @@ export const en = {
|
||||
'settings.providers.sonnetModel': 'Sonnet Model',
|
||||
'settings.providers.opusModel': 'Opus Model',
|
||||
'settings.providers.sameAsMain': 'Same as main',
|
||||
'settings.providers.contextSettingsTitle': 'Context and auto-compact',
|
||||
'settings.providers.contextSettingsDesc': 'Preset limits are applied automatically. Edit only when the provider changes limits or you use a custom model ID.',
|
||||
'settings.providers.contextSettingsEdit': 'Advanced',
|
||||
'settings.providers.contextSettingsHide': 'Hide',
|
||||
'settings.providers.contextSummaryAuto': 'Auto-detect configured models; unknown models use the built-in fallback',
|
||||
'settings.providers.contextFallbackAuto': 'Unknown models: auto',
|
||||
'settings.providers.contextFallbackSummary': 'Unknown models: {tokens}',
|
||||
'settings.providers.modelContextWindows': 'Model context windows',
|
||||
'settings.providers.mainContextWindow': 'Main model context',
|
||||
'settings.providers.haikuContextWindow': 'Haiku model context',
|
||||
'settings.providers.sonnetContextWindow': 'Sonnet model context',
|
||||
'settings.providers.opusContextWindow': 'Opus model context',
|
||||
'settings.providers.contextWindowPlaceholder': 'e.g. 200000',
|
||||
'settings.providers.modelContextWindowsDesc': 'Use each model’s real context window. Presets are filled automatically; blank values fall back to built-in detection or 200K.',
|
||||
'settings.providers.modelContextWindowNumberError': 'Enter an integer.',
|
||||
'settings.providers.modelContextWindowRangeError': 'Must be between 16000 and 10000000.',
|
||||
'settings.providers.autoCompactWindow': 'Unknown Model Fallback Window',
|
||||
'settings.providers.autoCompactWindowPlaceholder': 'Optional, e.g. 200000',
|
||||
'settings.providers.autoCompactWindowDesc': 'Only used for models that cannot be identified; configured model windows take precedence.',
|
||||
'settings.providers.autoCompactWindowNumberError': 'Enter an integer.',
|
||||
'settings.providers.autoCompactWindowRangeError': 'Must be between 16000 and 10000000.',
|
||||
'settings.providers.testConnection': 'Test Connection',
|
||||
'settings.providers.settingsJson': 'Settings JSON',
|
||||
'settings.providers.settingsJsonDesc': '~/.claude/cc-haha/settings.json — edit directly, will be written on save.',
|
||||
|
||||
@ -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 — 直接编辑,保存时写入。',
|
||||
|
||||
@ -338,6 +338,147 @@ 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
|
||||
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 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 buildFallbackPreset(provider?: SavedProvider): ProviderPreset {
|
||||
return {
|
||||
id: provider?.presetId ?? 'custom',
|
||||
@ -345,6 +486,10 @@ function buildFallbackPreset(provider?: SavedProvider): ProviderPreset {
|
||||
baseUrl: provider?.baseUrl ?? '',
|
||||
apiFormat: provider?.apiFormat ?? 'anthropic',
|
||||
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: '',
|
||||
}
|
||||
@ -403,7 +548,11 @@ 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 ?? {}))),
|
||||
() => new Set([
|
||||
AUTO_COMPACT_WINDOW_ENV_KEY,
|
||||
MODEL_CONTEXT_WINDOWS_ENV_KEY,
|
||||
...presets.flatMap((preset) => Object.keys(preset.defaultEnv ?? {})),
|
||||
]),
|
||||
[presets],
|
||||
)
|
||||
const fallbackPreset = provider
|
||||
@ -423,6 +572,15 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
|
||||
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)
|
||||
@ -440,6 +598,8 @@ 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)),
|
||||
@ -450,6 +610,10 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
|
||||
env: {
|
||||
...cleanedEnv,
|
||||
...(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'
|
||||
@ -474,12 +638,17 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
|
||||
setBaseUrl(preset.baseUrl)
|
||||
setApiFormat(preset.apiFormat ?? 'anthropic')
|
||||
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
|
||||
@ -503,6 +672,45 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
|
||||
},
|
||||
]
|
||||
const selectedApiFormatLabel = apiFormatItems.find((item) => item.value === apiFormat)?.label ?? t('settings.providers.apiFormatAnthropic')
|
||||
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 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}
|
||||
@ -519,6 +727,8 @@ 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
|
||||
@ -541,6 +751,8 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
|
||||
baseUrl: baseUrl.trim(),
|
||||
apiFormat,
|
||||
models,
|
||||
...(parsedAutoCompactWindow !== undefined && { autoCompactWindow: parsedAutoCompactWindow }),
|
||||
...(Object.keys(parsedModelContextWindows).length > 0 && { modelContextWindows: parsedModelContextWindows }),
|
||||
notes: notes.trim() || undefined,
|
||||
})
|
||||
} else if (provider) {
|
||||
@ -549,6 +761,10 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
|
||||
baseUrl: baseUrl.trim(),
|
||||
apiFormat,
|
||||
models,
|
||||
autoCompactWindow: parsedAutoCompactWindow ?? null,
|
||||
modelContextWindows: Object.keys(parsedModelContextWindows).length > 0
|
||||
? parsedModelContextWindows
|
||||
: null,
|
||||
notes: notes.trim() || undefined,
|
||||
}
|
||||
if (apiKey.trim()) input.apiKey = apiKey.trim()
|
||||
@ -725,13 +941,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()}>
|
||||
@ -787,13 +1093,42 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
|
||||
if (nextApiKey && nextApiKey !== '(your API key)' && nextApiKey !== API_KEY_JSON_PLACEHOLDER) {
|
||||
setApiKey(nextApiKey)
|
||||
}
|
||||
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) {
|
||||
|
||||
@ -9,6 +9,8 @@ export type ModelMapping = {
|
||||
opus: string
|
||||
}
|
||||
|
||||
export type ModelContextWindows = Record<string, number>
|
||||
|
||||
export type SavedProvider = {
|
||||
id: string
|
||||
presetId: string
|
||||
@ -17,6 +19,8 @@ export type SavedProvider = {
|
||||
baseUrl: string
|
||||
apiFormat: ApiFormat
|
||||
models: ModelMapping
|
||||
autoCompactWindow?: number
|
||||
modelContextWindows?: ModelContextWindows
|
||||
notes?: string
|
||||
}
|
||||
|
||||
@ -27,6 +31,8 @@ export type CreateProviderInput = {
|
||||
baseUrl: string
|
||||
apiFormat?: ApiFormat
|
||||
models: ModelMapping
|
||||
autoCompactWindow?: number
|
||||
modelContextWindows?: ModelContextWindows
|
||||
notes?: string
|
||||
}
|
||||
|
||||
@ -36,6 +42,8 @@ export type UpdateProviderInput = {
|
||||
baseUrl?: string
|
||||
apiFormat?: ApiFormat
|
||||
models?: ModelMapping
|
||||
autoCompactWindow?: number | null
|
||||
modelContextWindows?: ModelContextWindows | null
|
||||
notes?: string
|
||||
}
|
||||
|
||||
|
||||
@ -19,4 +19,5 @@ export type ProviderPreset = {
|
||||
promoText?: string
|
||||
featured?: boolean
|
||||
defaultEnv?: Record<string, string>
|
||||
modelContextWindows?: Record<string, number>
|
||||
}
|
||||
|
||||
297
scripts/repro-issue-247-real.ts
Normal file
297
scripts/repro-issue-247-real.ts
Normal 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))
|
||||
@ -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`)
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -89,6 +89,7 @@ describe('provider presets API', () => {
|
||||
expect(kimi?.defaultModels.main).toBe('kimi-k2.6')
|
||||
expect(kimi?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBe('1')
|
||||
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?.defaultModels.main).toBe('claude-sonnet-4-6')
|
||||
expect(jiekouai?.defaultModels.opus).toBe('claude-opus-4-7')
|
||||
@ -112,16 +113,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 +144,9 @@ 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?.defaultEnv).toBeUndefined()
|
||||
})
|
||||
|
||||
test('GET and PUT /api/providers/settings read and write cc-haha settings.json', async () => {
|
||||
|
||||
@ -68,6 +68,10 @@ describe('Real Provider Configs', () => {
|
||||
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_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,6 +105,10 @@ 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)
|
||||
@ -108,6 +116,7 @@ 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-fake-test-key-for-testing-only')
|
||||
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()
|
||||
|
||||
@ -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 ─────────────────────────────────────────────────────────
|
||||
@ -226,6 +248,58 @@ describe('ProviderService', () => {
|
||||
expect(env.ANTHROPIC_API_KEY).toBe('sk-new-key')
|
||||
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 ──────────────────────────────────────────────────────
|
||||
@ -315,6 +389,7 @@ describe('ProviderService', () => {
|
||||
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 include preset default env on activation and runtime env', async () => {
|
||||
@ -330,16 +405,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 () => {
|
||||
@ -452,6 +558,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 +569,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 +584,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 () => {
|
||||
|
||||
@ -29,6 +29,12 @@
|
||||
"apiKeyUrl": "https://platform.deepseek.com/api_keys",
|
||||
"defaultEnv": {
|
||||
"CC_HAHA_SEND_DISABLED_THINKING": "1"
|
||||
},
|
||||
"modelContextWindows": {
|
||||
"deepseek-v4-pro": 1000000,
|
||||
"deepseek-v4-flash": 1000000,
|
||||
"deepseek-chat": 1000000,
|
||||
"deepseek-reasoner": 1000000
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -48,6 +54,11 @@
|
||||
"promoText": "智谱 GLM 为 cc-haha 用户准备了专属邀请福利,使用此链接注册后可领取新用户权益。",
|
||||
"defaultEnv": {
|
||||
"CC_HAHA_SEND_DISABLED_THINKING": "1"
|
||||
},
|
||||
"modelContextWindows": {
|
||||
"glm-5.1": 200000,
|
||||
"glm-5-turbo": 200000,
|
||||
"glm-4.5-air": 128000
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -66,6 +77,14 @@
|
||||
"apiKeyUrl": "https://platform.kimi.com/console/api-keys",
|
||||
"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 +100,16 @@
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
@ -101,6 +129,12 @@
|
||||
"featured": true,
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -123,6 +157,11 @@
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@ -25,6 +25,10 @@ const ProviderPresetSchema = z.object({
|
||||
promoText: z.string().optional(),
|
||||
featured: z.boolean().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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -724,6 +734,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 }
|
||||
@ -848,6 +860,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
|
||||
@ -942,11 +956,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
|
||||
|
||||
@ -986,11 +1004,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
|
||||
|
||||
@ -1037,6 +1059,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') {
|
||||
|
||||
@ -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,
|
||||
@ -38,6 +39,8 @@ 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: [] }
|
||||
@ -46,6 +49,10 @@ function getPresetDefaultEnv(presetId: string): Record<string, string> {
|
||||
return PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.defaultEnv ?? {}
|
||||
}
|
||||
|
||||
function getPresetModelContextWindows(presetId: string): Record<string, number> {
|
||||
return PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.modelContextWindows ?? {}
|
||||
}
|
||||
|
||||
function getManagedEnvKeys(): string[] {
|
||||
const keys = new Set<string>(MANAGED_ENV_KEYS)
|
||||
for (const preset of PROVIDER_PRESETS) {
|
||||
@ -168,6 +175,8 @@ export class ProviderService {
|
||||
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 }),
|
||||
}
|
||||
|
||||
@ -189,8 +198,16 @@ export class ProviderService {
|
||||
...(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,8 +268,19 @@ export class ProviderService {
|
||||
? `http://127.0.0.1:${ProviderService.serverPort}${proxyPath}`
|
||||
: provider.baseUrl
|
||||
|
||||
const modelContextWindows = {
|
||||
...getPresetModelContextWindows(provider.presetId),
|
||||
...(provider.modelContextWindows ?? {}),
|
||||
}
|
||||
|
||||
return {
|
||||
...getPresetDefaultEnv(provider.presetId),
|
||||
...(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,
|
||||
ANTHROPIC_MODEL: provider.models.main,
|
||||
|
||||
@ -21,6 +21,12 @@ 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(),
|
||||
@ -29,6 +35,8 @@ export const SavedProviderSchema = z.object({
|
||||
baseUrl: z.string(),
|
||||
apiFormat: ApiFormatSchema.default('anthropic'),
|
||||
models: ModelMappingSchema,
|
||||
autoCompactWindow: AutoCompactWindowSchema.optional(),
|
||||
modelContextWindows: ModelContextWindowsSchema.optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
@ -44,6 +52,8 @@ export const CreateProviderSchema = z.object({
|
||||
baseUrl: z.string(),
|
||||
apiFormat: ApiFormatSchema.default('anthropic'),
|
||||
models: ModelMappingSchema,
|
||||
autoCompactWindow: AutoCompactWindowSchema.optional(),
|
||||
modelContextWindows: ModelContextWindowsSchema.optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
@ -53,6 +63,8 @@ export const UpdateProviderSchema = z.object({
|
||||
baseUrl: z.string().optional(),
|
||||
apiFormat: ApiFormatSchema.optional(),
|
||||
models: ModelMappingSchema.optional(),
|
||||
autoCompactWindow: AutoCompactWindowSchema.nullable().optional(),
|
||||
modelContextWindows: ModelContextWindowsSchema.nullable().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
|
||||
@ -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 }]
|
||||
}
|
||||
|
||||
|
||||
54
src/services/compact/autoCompact.test.ts
Normal file
54
src/services/compact/autoCompact.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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',
|
||||
|
||||
134
src/utils/model/modelContextWindows.ts
Normal file
134
src/utils/model/modelContextWindows.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user