mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
commit
eb00c0d501
@ -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)
|
||||
},
|
||||
|
||||
|
||||
@ -188,6 +188,27 @@ export const en = {
|
||||
'settings.providers.sonnetModel': 'Sonnet Model',
|
||||
'settings.providers.opusModel': 'Opus Model',
|
||||
'settings.providers.sameAsMain': 'Same as main',
|
||||
'settings.providers.contextSettingsTitle': 'Context and auto-compact',
|
||||
'settings.providers.contextSettingsDesc': 'Preset limits are applied automatically. Edit only when the provider changes limits or you use a custom model ID.',
|
||||
'settings.providers.contextSettingsEdit': 'Advanced',
|
||||
'settings.providers.contextSettingsHide': 'Hide',
|
||||
'settings.providers.contextSummaryAuto': 'Auto-detect configured models; unknown models use the built-in fallback',
|
||||
'settings.providers.contextFallbackAuto': 'Unknown models: auto',
|
||||
'settings.providers.contextFallbackSummary': 'Unknown models: {tokens}',
|
||||
'settings.providers.modelContextWindows': 'Model context windows',
|
||||
'settings.providers.mainContextWindow': 'Main model context',
|
||||
'settings.providers.haikuContextWindow': 'Haiku model context',
|
||||
'settings.providers.sonnetContextWindow': 'Sonnet model context',
|
||||
'settings.providers.opusContextWindow': 'Opus model context',
|
||||
'settings.providers.contextWindowPlaceholder': 'e.g. 200000',
|
||||
'settings.providers.modelContextWindowsDesc': 'Use each model’s real context window. Presets are filled automatically; blank values fall back to built-in detection or 200K.',
|
||||
'settings.providers.modelContextWindowNumberError': 'Enter an integer.',
|
||||
'settings.providers.modelContextWindowRangeError': 'Must be between 16000 and 10000000.',
|
||||
'settings.providers.autoCompactWindow': 'Unknown Model Fallback Window',
|
||||
'settings.providers.autoCompactWindowPlaceholder': 'Optional, e.g. 200000',
|
||||
'settings.providers.autoCompactWindowDesc': 'Only used for models that cannot be identified; configured model windows take precedence.',
|
||||
'settings.providers.autoCompactWindowNumberError': 'Enter an integer.',
|
||||
'settings.providers.autoCompactWindowRangeError': 'Must be between 16000 and 10000000.',
|
||||
'settings.providers.testConnection': 'Test Connection',
|
||||
'settings.providers.settingsJson': 'Settings JSON',
|
||||
'settings.providers.settingsJsonDesc': '~/.claude/cc-haha/settings.json — edit directly, will be written on save.',
|
||||
@ -197,6 +218,17 @@ export const en = {
|
||||
'settings.providers.apiFormatOpenaiChat': 'OpenAI Chat Completions (proxy)',
|
||||
'settings.providers.apiFormatOpenaiResponses': 'OpenAI Responses API (proxy)',
|
||||
'settings.providers.proxyHint': 'Requests will be translated via the local proxy',
|
||||
'settings.providers.authStrategy': 'Auth Variable',
|
||||
'settings.providers.authStrategyApiKey': 'API Key (ANTHROPIC_API_KEY)',
|
||||
'settings.providers.authStrategyApiKeyDesc': 'Direct Anthropic API access using x-api-key.',
|
||||
'settings.providers.authStrategyAuthToken': 'Bearer Token (ANTHROPIC_AUTH_TOKEN)',
|
||||
'settings.providers.authStrategyAuthTokenDesc': 'Most third-party Anthropic-compatible services using Authorization Bearer.',
|
||||
'settings.providers.authStrategyAuthTokenEmptyApiKey': 'Bearer + Empty API_KEY',
|
||||
'settings.providers.authStrategyAuthTokenEmptyApiKeyDesc': 'OpenRouter/Ollama-style setups that must avoid falling back to an Anthropic API key.',
|
||||
'settings.providers.authStrategyDualSameToken': 'Write token to both variables',
|
||||
'settings.providers.authStrategyDualSameTokenDesc': 'Services such as Hugging Face Router that check both variables.',
|
||||
'settings.providers.authStrategyDualDummy': 'Write dummy to both variables',
|
||||
'settings.providers.authStrategyDualDummyDesc': 'Local vLLM-compatible services that only need placeholder auth values.',
|
||||
|
||||
// Settings > Permissions
|
||||
'settings.permissions.title': 'Permission Mode',
|
||||
|
||||
@ -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': '权限模式',
|
||||
|
||||
63
desktop/src/lib/__tests__/providerSettingsJson.test.ts
Normal file
63
desktop/src/lib/__tests__/providerSettingsJson.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
103
desktop/src/lib/providerSettingsJson.ts
Normal file
103
desktop/src/lib/providerSettingsJson.ts
Normal 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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -29,7 +29,7 @@ type ProviderStore = {
|
||||
deleteProvider: (id: string) => Promise<void>
|
||||
activateProvider: (id: string) => Promise<void>
|
||||
activateOfficial: () => Promise<void>
|
||||
testProvider: (id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string }) => Promise<ProviderTestResult>
|
||||
testProvider: (id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string }) => Promise<ProviderTestResult>
|
||||
testConfig: (input: TestProviderConfigInput) => Promise<ProviderTestResult>
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,13 @@
|
||||
|
||||
export type ApiFormat = 'anthropic' | 'openai_chat' | 'openai_responses'
|
||||
|
||||
export type ProviderAuthStrategy =
|
||||
| 'api_key'
|
||||
| 'auth_token'
|
||||
| 'auth_token_empty_api_key'
|
||||
| 'dual_same_token'
|
||||
| 'dual_dummy'
|
||||
|
||||
export type ModelMapping = {
|
||||
main: string
|
||||
haiku: string
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
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))
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 配置独立存储')
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -94,7 +94,7 @@ export async function handleProvidersApi(
|
||||
// /api/providers/:id/test
|
||||
if (action === 'test') {
|
||||
if (req.method !== 'POST') throw methodNotAllowed(req.method)
|
||||
let overrides: { baseUrl?: string; modelId?: string; apiFormat?: string } | undefined
|
||||
let overrides: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string } | undefined
|
||||
try {
|
||||
const body = await req.json()
|
||||
if (body && typeof body === 'object') overrides = body as typeof overrides
|
||||
|
||||
@ -27,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"
|
||||
}
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'),
|
||||
})
|
||||
|
||||
|
||||
@ -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