mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: keep provider sessions on compatible model capabilities
Third-party Anthropic-compatible providers can expose Claude model names without supporting every first-party runtime capability. Desktop sessions now preserve provider-specific capability overrides, keep selected provider runtime state across reconnects and restarts, and keep the provider dialog behavior aligned with the updated presets. Constraint: Custom ANTHROPIC_BASE_URL endpoints must not be treated as first-party Anthropic for adaptive thinking support. Rejected: Rename provider default Sonnet models | that would hide the compatibility issue instead of fixing runtime capability detection. Confidence: high Scope-risk: moderate Directive: Do not enable Claude first-party capability defaults for custom Anthropic-compatible base URLs without provider verification. Tested: bun test src/utils/__tests__/thinking.test.ts src/server/__tests__/conversation-service.test.ts src/server/__tests__/provider-presets.test.ts src/server/__tests__/conversations.test.ts Tested: cd desktop && bun run lint Tested: cd desktop && bun run test src/components/shared/Modal.test.tsx --run Tested: git diff --check
This commit is contained in:
parent
5e9303fe44
commit
165cb1777a
36
desktop/src/components/shared/Modal.test.tsx
Normal file
36
desktop/src/components/shared/Modal.test.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Modal } from './Modal'
|
||||
|
||||
describe('Modal', () => {
|
||||
it('portals the dialog to body so the scrim covers the full app shell', () => {
|
||||
const onClose = vi.fn()
|
||||
const { container } = render(
|
||||
<div data-testid="stacking-parent" className="relative z-10">
|
||||
<Modal open onClose={onClose} title="Provider">
|
||||
<span>Provider form</span>
|
||||
</Modal>
|
||||
</div>,
|
||||
)
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Provider' })
|
||||
|
||||
expect(container.contains(dialog)).toBe(false)
|
||||
expect(document.body.contains(dialog)).toBe(true)
|
||||
})
|
||||
|
||||
it('closes when the backdrop is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Modal open onClose={onClose}>
|
||||
<span>Provider form</span>
|
||||
</Modal>,
|
||||
)
|
||||
|
||||
const backdrop = screen.getByRole('dialog').previousElementSibling
|
||||
expect(backdrop).not.toBeNull()
|
||||
fireEvent.click(backdrop!)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean
|
||||
@ -21,7 +22,7 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
@ -35,6 +36,7 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M
|
||||
style={{ width, maxWidth: 'calc(100vw - 48px)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
{title && (
|
||||
<div className="flex items-start justify-between gap-4 px-6 pt-6 pb-0">
|
||||
@ -60,6 +62,7 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@ -58,7 +58,6 @@ export function EmptySession() {
|
||||
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const createSession = useSessionStore((state) => state.createSession)
|
||||
const sendMessage = useChatStore((state) => state.sendMessage)
|
||||
const setSessionRuntime = useChatStore((state) => state.setSessionRuntime)
|
||||
const connectToSession = useChatStore((state) => state.connectToSession)
|
||||
const setActiveView = useUIStore((state) => state.setActiveView)
|
||||
const addToast = useUIStore((state) => state.addToast)
|
||||
@ -233,12 +232,11 @@ export function EmptySession() {
|
||||
modelId: settings.currentModel?.id ?? OFFICIAL_DEFAULT_MODEL_ID,
|
||||
}
|
||||
const sessionId = await createSession(workDir || undefined)
|
||||
useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection)
|
||||
useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY)
|
||||
setActiveView('code')
|
||||
useTabStore.getState().openTab(sessionId, 'New Session')
|
||||
connectToSession(sessionId)
|
||||
useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection)
|
||||
useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY)
|
||||
setSessionRuntime(sessionId, draftSelection)
|
||||
const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
|
||||
type: attachment.type,
|
||||
name: attachment.name,
|
||||
|
||||
@ -358,6 +358,39 @@ 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)
|
||||
@ -446,6 +479,9 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
|
||||
const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || !requiresApiKey || apiKey.trim()) && models.main.trim() && !settingsJsonError
|
||||
const apiKeyUrl = selectedPreset.apiKeyUrl?.trim()
|
||||
const promoText = selectedPreset.promoText?.trim()
|
||||
const displayedSettingsJson = showApiKey
|
||||
? settingsJson
|
||||
: maskSettingsJsonSecrets(settingsJson, apiKey)
|
||||
const apiFormatItems = [
|
||||
{
|
||||
value: 'anthropic' as const,
|
||||
@ -486,7 +522,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
|
||||
// settings never conflict with the user's global ~/.claude/settings.json.
|
||||
if (settingsJson.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(settingsJson)
|
||||
const parsed = restoreSettingsJsonSecrets(JSON.parse(settingsJson), apiKey)
|
||||
const { providersApi } = await import('../api/providers')
|
||||
await providersApi.updateSettings(parsed)
|
||||
} catch {
|
||||
@ -720,12 +756,12 @@ 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.settingsJson')}</label>
|
||||
<textarea
|
||||
value={settingsJson}
|
||||
value={displayedSettingsJson}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
setSettingsJson(raw)
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
const parsed = restoreSettingsJsonSecrets(JSON.parse(raw), apiKey)
|
||||
setSettingsJson(JSON.stringify(parsed, null, 2))
|
||||
setSettingsJsonError(null)
|
||||
// Auto-fill form fields from parsed JSON env
|
||||
const env = parsed.env as Record<string, string> | undefined
|
||||
@ -744,7 +780,10 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
|
||||
}
|
||||
}
|
||||
}
|
||||
if (env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_AUTH_TOKEN !== '(your API key)') setApiKey(env.ANTHROPIC_AUTH_TOKEN)
|
||||
const nextApiKey = env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY
|
||||
if (nextApiKey && nextApiKey !== '(your API key)' && nextApiKey !== API_KEY_JSON_PLACEHOLDER) {
|
||||
setApiKey(nextApiKey)
|
||||
}
|
||||
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
|
||||
@ -755,6 +794,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setSettingsJson(raw)
|
||||
setSettingsJsonError(err instanceof Error ? err.message : 'Invalid JSON')
|
||||
}
|
||||
}}
|
||||
|
||||
@ -191,6 +191,33 @@ describe('ConversationService', () => {
|
||||
expect(env.ANTHROPIC_MODEL).toBe('new-provider-sonnet')
|
||||
})
|
||||
|
||||
test('buildChildEnv preserves provider capability overrides from presets', async () => {
|
||||
const providerService = new ProviderService()
|
||||
const provider = await providerService.addProvider({
|
||||
presetId: 'jiekouai',
|
||||
name: 'Jiekou',
|
||||
apiKey: 'provider-key',
|
||||
baseUrl: 'https://api.jiekou.ai/anthropic',
|
||||
apiFormat: 'anthropic',
|
||||
models: {
|
||||
main: 'claude-sonnet-4-6',
|
||||
haiku: 'claude-haiku-4-5-20251001',
|
||||
sonnet: 'claude-sonnet-4-6',
|
||||
opus: 'claude-opus-4-7',
|
||||
},
|
||||
})
|
||||
|
||||
const service = new ConversationService() as any
|
||||
const env = (await service.buildChildEnv('/tmp', undefined, {
|
||||
providerId: provider.id,
|
||||
model: 'claude-sonnet-4-6',
|
||||
})) as Record<string, string>
|
||||
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe('')
|
||||
})
|
||||
|
||||
test('buildChildEnv can force official auth even when a custom default provider exists', async () => {
|
||||
const ccHahaDir = path.join(tmpDir, 'cc-haha')
|
||||
await fs.mkdir(ccHahaDir, { recursive: true })
|
||||
|
||||
@ -914,6 +914,115 @@ describe('WebSocket Chat Integration', () => {
|
||||
}
|
||||
}, 20_000)
|
||||
|
||||
it('should restart a prewarm that began before runtime config arrived', async () => {
|
||||
const providerService = new ProviderService()
|
||||
const provider = await providerService.addProvider({
|
||||
presetId: 'custom',
|
||||
name: 'Provider Late Runtime',
|
||||
apiKey: 'key-late-runtime',
|
||||
baseUrl: 'http://127.0.0.1:1/anthropic',
|
||||
apiFormat: 'anthropic',
|
||||
models: {
|
||||
main: 'late-main',
|
||||
haiku: 'late-haiku',
|
||||
sonnet: 'late-sonnet',
|
||||
opus: 'late-opus',
|
||||
},
|
||||
})
|
||||
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workDir: process.cwd() }),
|
||||
})
|
||||
expect(createRes.status).toBe(201)
|
||||
const { sessionId } = await createRes.json() as { sessionId: string }
|
||||
|
||||
const originalStartSession = conversationService.startSession.bind(conversationService)
|
||||
const startCalls: Array<{
|
||||
sessionId: string
|
||||
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
||||
}> = []
|
||||
let releaseFirstStart!: () => void
|
||||
const firstStartGate = new Promise<void>((resolve) => {
|
||||
releaseFirstStart = resolve
|
||||
})
|
||||
let markFirstStart!: () => void
|
||||
const firstStartEntered = new Promise<void>((resolve) => {
|
||||
markFirstStart = resolve
|
||||
})
|
||||
|
||||
conversationService.startSession = (async function patchedStartSession(
|
||||
sid: string,
|
||||
workDir: string,
|
||||
sdkUrl: string,
|
||||
options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null },
|
||||
) {
|
||||
startCalls.push({ sessionId: sid, options })
|
||||
if (startCalls.length === 1) {
|
||||
markFirstStart()
|
||||
await firstStartGate
|
||||
}
|
||||
return originalStartSession(sid, workDir, sdkUrl, options)
|
||||
}) as typeof conversationService.startSession
|
||||
|
||||
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
||||
const messages: any[] = []
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`Timed out waiting for late runtime prewarm connection for session ${sessionId}`))
|
||||
}, 5000)
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string)
|
||||
messages.push(msg)
|
||||
if (msg.type === 'connected') {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
if (msg.type === 'error') {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error(msg.message))
|
||||
}
|
||||
}
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error(`WebSocket error for late runtime prewarm session ${sessionId}`))
|
||||
}
|
||||
})
|
||||
|
||||
ws.send(JSON.stringify({ type: 'prewarm_session' }))
|
||||
await firstStartEntered
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'set_runtime_config',
|
||||
providerId: provider.id,
|
||||
modelId: 'late-sonnet',
|
||||
}))
|
||||
releaseFirstStart()
|
||||
|
||||
await waitUntil(async () => startCalls.length >= 2, `runtime restart for ${sessionId}`)
|
||||
await waitUntil(
|
||||
async () => messages.some((msg) => msg.type === 'status' && msg.state === 'idle'),
|
||||
`runtime restart idle status for ${sessionId}`,
|
||||
)
|
||||
|
||||
expect(startCalls[0]).toMatchObject({ sessionId })
|
||||
expect(startCalls[0]?.options?.providerId).toBeUndefined()
|
||||
expect(startCalls[1]).toMatchObject({
|
||||
sessionId,
|
||||
options: {
|
||||
providerId: provider.id,
|
||||
model: 'late-sonnet',
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
ws.close()
|
||||
conversationService.startSession = originalStartSession
|
||||
conversationService.stopSession(sessionId)
|
||||
}
|
||||
}, 20_000)
|
||||
|
||||
it('should resume streaming to a reconnected client during an active turn', async () => {
|
||||
await withMockStreamDelay(150, async () => {
|
||||
const sessionId = `chat-reconnect-${crypto.randomUUID()}`
|
||||
|
||||
@ -90,6 +90,7 @@ describe('provider presets API', () => {
|
||||
expect(jiekouai?.baseUrl).toBe('https://api.jiekou.ai/anthropic')
|
||||
expect(jiekouai?.defaultModels.main).toBe('claude-sonnet-4-6')
|
||||
expect(jiekouai?.defaultModels.opus).toBe('claude-opus-4-7')
|
||||
expect(jiekouai?.defaultEnv?.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe('')
|
||||
expect(shengsuanyun?.baseUrl).toBe('https://router.shengsuanyun.com/api')
|
||||
expect(shengsuanyun?.defaultModels.main).toBe('anthropic/claude-sonnet-4.6')
|
||||
expect(shengsuanyun?.defaultModels.haiku).toBe('anthropic/claude-haiku-4.5:thinking')
|
||||
@ -128,6 +129,7 @@ describe('provider presets API', () => {
|
||||
expect(shengsuanyun?.defaultEnv).toEqual({
|
||||
API_TIMEOUT_MS: '3000000',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES: '',
|
||||
})
|
||||
expect(custom?.promoText).toBeUndefined()
|
||||
})
|
||||
|
||||
@ -42,7 +42,7 @@
|
||||
"needsApiKey": true,
|
||||
"websiteUrl": "https://open.bigmodel.cn",
|
||||
"apiKeyUrl": "https://www.bigmodel.cn/invite?icode=d41B2qi8Z5xNwTGLNPPF3OZLO2QH3C0EBTSr%2BArzMw4%3D",
|
||||
"promoText": ""
|
||||
"promoText": "智谱 GLM 为 cc-haha 用户准备了专属邀请福利,使用此链接注册后可领取新用户权益。"
|
||||
},
|
||||
{
|
||||
"id": "kimi",
|
||||
@ -89,7 +89,10 @@
|
||||
"websiteUrl": "https://jiekou.ai",
|
||||
"apiKeyUrl": "https://jiekou.ai/referral?invited_code=OBNU3K",
|
||||
"promoText": "接口AI为 cc-haha 的用户提供官方资源与稳定高性能体验,订阅包价格为官方 8 折;绑定 GitHub 后还可领取 3 美元优惠券。",
|
||||
"featured": true
|
||||
"featured": true,
|
||||
"defaultEnv": {
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shengsuanyun",
|
||||
@ -109,7 +112,8 @@
|
||||
"featured": true,
|
||||
"defaultEnv": {
|
||||
"API_TIMEOUT_MS": "3000000",
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@ -610,8 +610,11 @@ export class ConversationService {
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
|
||||
] as const
|
||||
|
||||
const cleanEnv = { ...process.env }
|
||||
@ -730,8 +733,11 @@ export class ConversationService {
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
|
||||
].some((key) => typeof env[key] === 'string' && env[key]!.trim().length > 0)
|
||||
} catch {
|
||||
return false
|
||||
|
||||
@ -33,8 +33,11 @@ const MANAGED_ENV_KEYS = [
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
|
||||
] as const
|
||||
|
||||
const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] }
|
||||
|
||||
@ -460,6 +460,21 @@ async function handleSetRuntimeConfig(
|
||||
}
|
||||
|
||||
if (!conversationService.hasSession(sessionId)) {
|
||||
const pendingStartup = sessionStartupPromises.get(sessionId)
|
||||
if (pendingStartup) {
|
||||
await enqueueRuntimeTransition(sessionId, async () => {
|
||||
await pendingStartup.catch(() => undefined)
|
||||
const currentOverride = runtimeOverrides.get(sessionId)
|
||||
if (
|
||||
currentOverride?.providerId !== nextOverride.providerId ||
|
||||
currentOverride.modelId !== nextOverride.modelId ||
|
||||
!conversationService.hasSession(sessionId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
await restartSessionWithRuntimeConfig(ws, sessionId)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
77
src/utils/__tests__/thinking.test.ts
Normal file
77
src/utils/__tests__/thinking.test.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { get3PModelCapabilityOverride } from '../model/modelSupportOverrides.js'
|
||||
import { modelSupportsAdaptiveThinking, modelSupportsThinking } from '../thinking.js'
|
||||
|
||||
describe('provider-aware thinking support', () => {
|
||||
let originalBaseUrl: string | undefined
|
||||
let originalSonnetModel: string | undefined
|
||||
let originalSonnetCapabilities: string | undefined
|
||||
let originalBedrock: string | undefined
|
||||
let originalVertex: string | undefined
|
||||
let originalFoundry: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalBaseUrl = process.env.ANTHROPIC_BASE_URL
|
||||
originalSonnetModel = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
originalSonnetCapabilities = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES
|
||||
originalBedrock = process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
originalVertex = process.env.CLAUDE_CODE_USE_VERTEX
|
||||
originalFoundry = process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv('ANTHROPIC_BASE_URL', originalBaseUrl)
|
||||
restoreEnv('ANTHROPIC_DEFAULT_SONNET_MODEL', originalSonnetModel)
|
||||
restoreEnv('ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', originalSonnetCapabilities)
|
||||
restoreEnv('CLAUDE_CODE_USE_BEDROCK', originalBedrock)
|
||||
restoreEnv('CLAUDE_CODE_USE_VERTEX', originalVertex)
|
||||
restoreEnv('CLAUDE_CODE_USE_FOUNDRY', originalFoundry)
|
||||
clearCapabilityCache()
|
||||
})
|
||||
|
||||
test('does not assume adaptive thinking for Anthropic-compatible third-party base URLs', () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://api.jiekou.ai/anthropic'
|
||||
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES
|
||||
clearCapabilityCache()
|
||||
|
||||
expect(modelSupportsAdaptiveThinking('claude-sonnet-4-6')).toBe(false)
|
||||
})
|
||||
|
||||
test('honors explicit empty provider capability overrides', () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://api.jiekou.ai/anthropic'
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'claude-sonnet-4-6'
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES = ''
|
||||
clearCapabilityCache()
|
||||
|
||||
expect(get3PModelCapabilityOverride('claude-sonnet-4-6', 'thinking')).toBe(false)
|
||||
expect(modelSupportsThinking('claude-sonnet-4-6')).toBe(false)
|
||||
expect(modelSupportsAdaptiveThinking('claude-sonnet-4-6')).toBe(false)
|
||||
})
|
||||
|
||||
test('keeps first-party Anthropic Sonnet adaptive thinking enabled', () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com'
|
||||
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES
|
||||
clearCapabilityCache()
|
||||
|
||||
expect(modelSupportsThinking('claude-sonnet-4-6')).toBe(true)
|
||||
expect(modelSupportsAdaptiveThinking('claude-sonnet-4-6')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
function clearCapabilityCache() {
|
||||
;(get3PModelCapabilityOverride as typeof get3PModelCapabilityOverride & {
|
||||
cache?: { clear?: () => void }
|
||||
}).cache?.clear?.()
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { getAPIProvider } from './providers.js'
|
||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './providers.js'
|
||||
|
||||
export type ModelCapabilityOverride =
|
||||
| 'effort'
|
||||
@ -29,7 +29,7 @@ const TIERS = [
|
||||
*/
|
||||
export const get3PModelCapabilityOverride = memoize(
|
||||
(model: string, capability: ModelCapabilityOverride): boolean | undefined => {
|
||||
if (getAPIProvider() === 'firstParty') {
|
||||
if (getAPIProvider() === 'firstParty' && isFirstPartyAnthropicBaseUrl()) {
|
||||
return undefined
|
||||
}
|
||||
const m = model.toLowerCase()
|
||||
|
||||
@ -4,7 +4,7 @@ import { feature } from 'bun:bundle'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||||
import { getCanonicalName } from './model/model.js'
|
||||
import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js'
|
||||
import { getAPIProvider } from './model/providers.js'
|
||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './model/providers.js'
|
||||
import { getSettingsWithErrors } from './settings/settings.js'
|
||||
|
||||
export type ThinkingConfig =
|
||||
@ -102,7 +102,10 @@ export function modelSupportsThinking(model: string): boolean {
|
||||
const canonical = getCanonicalName(model)
|
||||
const provider = getAPIProvider()
|
||||
// 1P and Foundry: all Claude 4+ models (including Haiku 4.5)
|
||||
if (provider === 'foundry' || provider === 'firstParty') {
|
||||
if (
|
||||
provider === 'foundry' ||
|
||||
(provider === 'firstParty' && isFirstPartyAnthropicBaseUrl())
|
||||
) {
|
||||
return !canonical.includes('claude-3-')
|
||||
}
|
||||
// 3P (Bedrock/Vertex): only Opus 4+ and Sonnet 4+
|
||||
@ -116,6 +119,14 @@ export function modelSupportsAdaptiveThinking(model: string): boolean {
|
||||
return supported3P
|
||||
}
|
||||
const canonical = getCanonicalName(model)
|
||||
const provider = getAPIProvider()
|
||||
const isFirstPartyBaseUrl =
|
||||
provider === 'firstParty' && isFirstPartyAnthropicBaseUrl()
|
||||
|
||||
if (!isFirstPartyBaseUrl && provider !== 'foundry') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Supported by a subset of Claude 4 models
|
||||
if (canonical.includes('opus-4-6') || canonical.includes('sonnet-4-6')) {
|
||||
return true
|
||||
@ -139,7 +150,6 @@ export function modelSupportsAdaptiveThinking(model: string): boolean {
|
||||
// Default to true for unknown model strings on 1P and Foundry (because Foundry
|
||||
// is a proxy). Do not default to true for other 3P as they have different formats
|
||||
// for their model strings.
|
||||
const provider = getAPIProvider()
|
||||
return provider === 'firstParty' || provider === 'foundry'
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user