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:
程序员阿江(Relakkes) 2026-04-28 17:35:56 +08:00
parent 5e9303fe44
commit 165cb1777a
14 changed files with 349 additions and 19 deletions

View 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)
})
})

View File

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

View File

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

View File

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

View File

@ -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 })

View File

@ -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()}`

View File

@ -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()
})

View File

@ -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": ""
}
},
{

View File

@ -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

View File

@ -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: [] }

View File

@ -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
}

View 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?.()
}

View File

@ -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()

View File

@ -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'
}