diff --git a/src/app/api/providers/models/route.ts b/src/app/api/providers/models/route.ts index 740d97e3..f972a990 100644 --- a/src/app/api/providers/models/route.ts +++ b/src/app/api/providers/models/route.ts @@ -57,6 +57,7 @@ export async function GET() { provider_id: 'env', provider_name: 'Claude Code', provider_type: 'anthropic', + modelsSource: 'fallback', ...(!envHasDirectCredentials ? { sdkProxyOnly: true } : {}), models: DEFAULT_MODELS.map(m => { const cw = getContextWindow(m.value); @@ -69,6 +70,7 @@ export async function GET() { const { getCachedModels } = await import('@/lib/agent-sdk-capabilities'); const sdkModels = getCachedModels('env'); if (sdkModels.length > 0) { + groups[0].modelsSource = 'sdk'; groups[0].models = sdkModels.map(m => { const cw = getContextWindow(m.value); return { @@ -185,6 +187,7 @@ export async function GET() { provider_id: provider.id, provider_name: provider.name, provider_type: provider.provider_type, + modelsSource: 'configured', ...(sdkProxyOnly ? { sdkProxyOnly: true } : {}), models, }); diff --git a/src/app/api/sdk/warm/route.ts b/src/app/api/sdk/warm/route.ts new file mode 100644 index 00000000..313b50c7 --- /dev/null +++ b/src/app/api/sdk/warm/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCachedModels, getCapabilityCacheAge } from '@/lib/agent-sdk-capabilities'; +import { warmCapabilitiesViaSdk } from '@/lib/claude-client'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const CACHE_TTL_MS = 10 * 60 * 1000; + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const providerId = typeof body.providerId === 'string' && body.providerId ? body.providerId : 'env'; + const workingDirectory = typeof body.workingDirectory === 'string' && body.workingDirectory + ? body.workingDirectory + : undefined; + const force = body.force === true; + + const existingModels = getCachedModels(providerId); + const cacheAge = getCapabilityCacheAge(providerId); + const isFresh = existingModels.length > 0 && cacheAge < CACHE_TTL_MS; + + if (!force && isFresh) { + return NextResponse.json({ + ok: true, + providerId, + warmed: false, + reason: 'cache_fresh', + models: existingModels.length, + cacheAge, + }); + } + + const result = await warmCapabilitiesViaSdk({ + providerId, + workingDirectory, + }); + + return NextResponse.json({ + ok: true, + providerId: result.providerId, + warmed: result.warmed, + models: getCachedModels(result.providerId).length, + cacheAge: getCapabilityCacheAge(result.providerId), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('[sdk/warm] Failed to warm capabilities:', message); + return NextResponse.json({ ok: false, error: message }, { status: 500 }); + } +} diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 4870077b..5fffa928 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -99,6 +99,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal const pendingImageNoticesRef = useRef([]); const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise>(undefined); const initMetaRef = useRef<{ tools?: unknown; slash_commands?: unknown; skills?: unknown } | null>(null); + const warmupKeyRef = useRef(''); const handleModeChange = useCallback((newMode: string) => { setMode(newMode); @@ -171,6 +172,39 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal initMetaRef, }); + // Warm SDK capability cache when restoring an existing session after app launch. + // This repopulates dynamic model entries like "Default (recommended)" without + // mutating the session's persisted model choice. + useEffect(() => { + const providerKey = currentProviderId || 'env'; + const cwdKey = workingDirectory || ''; + if (!cwdKey) return; + + const warmupKey = `${sessionId}:${providerKey}:${cwdKey}`; + if (warmupKeyRef.current === warmupKey) return; + warmupKeyRef.current = warmupKey; + + let cancelled = false; + fetch('/api/sdk/warm', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providerId: providerKey, + workingDirectory: cwdKey, + }), + }) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (cancelled || !data?.ok) return; + window.dispatchEvent(new Event('provider-models-updated')); + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; + }, [sessionId, currentProviderId, workingDirectory]); + // Detect workspace mismatch useEffect(() => { if (!workingDirectory) return; diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index d54d1776..07a2c85c 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -81,17 +81,23 @@ export function MessageInput({ // --- Extracted hooks --- const popover = usePopoverState(modelName); - const { providerGroups, currentProviderIdValue, modelOptions, currentModelOption } = useProviderModels(providerId, modelName); + const { providerGroups, currentProviderIdValue, modelOptions, currentModelOption, currentModelsSource } = useProviderModels(providerId, modelName); // Auto-correct model when it doesn't exist in the current provider's model list. // This prevents sending an unsupported model name (e.g. 'opus' to MiniMax which only has 'sonnet'). useEffect(() => { + // Skip auto-correction while the env provider is still showing static fallback models. + // Old sessions may have a valid persisted model like "default" that only appears + // after capability warmup populates the dynamic SDK model list. + if (currentModelsSource === 'fallback') { + return; + } if (modelName && modelOptions.length > 0 && !modelOptions.some(m => m.value === modelName)) { const fallback = modelOptions[0].value; onModelChange?.(fallback); onProviderModelChange?.(currentProviderIdValue, fallback); } - }, [modelName, modelOptions, currentProviderIdValue, onModelChange, onProviderModelChange]); + }, [modelName, modelOptions, currentProviderIdValue, onModelChange, onProviderModelChange, currentModelsSource]); const { badge, setBadge, cliBadge, setCliBadge, removeBadge, removeCliBadge, hasBadge } = useCommandBadge(textareaRef); diff --git a/src/hooks/useProviderModels.ts b/src/hooks/useProviderModels.ts index 5c1baddb..ab0a1fe5 100644 --- a/src/hooks/useProviderModels.ts +++ b/src/hooks/useProviderModels.ts @@ -13,6 +13,7 @@ export interface UseProviderModelsReturn { currentProviderIdValue: string; modelOptions: typeof DEFAULT_MODEL_OPTIONS; currentModelOption: (typeof DEFAULT_MODEL_OPTIONS)[number]; + currentModelsSource?: ProviderModelGroup['modelsSource']; } export function useProviderModels( @@ -54,7 +55,11 @@ export function useProviderModels( fetchProviderModels(); const handler = () => fetchProviderModels(); window.addEventListener('provider-changed', handler); - return () => window.removeEventListener('provider-changed', handler); + window.addEventListener('provider-models-updated', handler); + return () => { + window.removeEventListener('provider-changed', handler); + window.removeEventListener('provider-models-updated', handler); + }; }, [fetchProviderModels]); // Derive flat model list for current provider @@ -75,5 +80,6 @@ export function useProviderModels( currentProviderIdValue, modelOptions, currentModelOption, + currentModelsSource: currentGroup?.modelsSource, }; } diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 9979e91e..dc395140 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -369,6 +369,81 @@ export async function generateTextViaSdk(params: { return resultText; } +/** + * Warm SDK capability cache (supported models, account info, MCP status) + * without mutating any session state or model selection. Used when restoring + * old sessions after app restart so the model picker can repopulate dynamic + * entries like "Default (recommended)". + */ +export async function warmCapabilitiesViaSdk(params: { + providerId?: string; + workingDirectory?: string; + abortSignal?: AbortSignal; +}): Promise<{ providerId: string; warmed: boolean }> { + const resolved = resolveForClaudeCode(undefined, { + providerId: params.providerId, + sessionProviderId: params.providerId === 'env' ? 'env' : undefined, + }); + + const sdkEnv: Record = { ...process.env as Record }; + if (!sdkEnv.HOME) sdkEnv.HOME = os.homedir(); + if (!sdkEnv.USERPROFILE) sdkEnv.USERPROFILE = os.homedir(); + sdkEnv.PATH = getExpandedPath(); + delete sdkEnv.CLAUDECODE; + + if (process.platform === 'win32' && !process.env.CLAUDE_CODE_GIT_BASH_PATH) { + const gitBashPath = findGitBash(); + if (gitBashPath) sdkEnv.CLAUDE_CODE_GIT_BASH_PATH = gitBashPath; + } + + const resolvedEnv = toClaudeCodeEnv(sdkEnv, resolved); + Object.assign(sdkEnv, resolvedEnv); + + const abortController = new AbortController(); + if (params.abortSignal) { + params.abortSignal.addEventListener('abort', () => abortController.abort(), { once: true }); + } + + const timeoutId = setTimeout(() => abortController.abort(), 20_000); + + const queryOptions: Options = { + cwd: params.workingDirectory || os.homedir(), + abortController, + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + env: sanitizeEnv(sdkEnv), + settingSources: resolved.settingSources as Options['settingSources'], + maxTurns: 1, + }; + + const claudePath = findClaudePath(); + if (claudePath) { + const ext = path.extname(claudePath).toLowerCase(); + if (ext === '.cmd' || ext === '.bat') { + const scriptPath = resolveScriptFromCmd(claudePath); + if (scriptPath) queryOptions.pathToClaudeCodeExecutable = scriptPath; + } else { + queryOptions.pathToClaudeCodeExecutable = claudePath; + } + } + + const capProviderId = params.providerId && params.providerId !== 'env' + ? params.providerId + : (resolved.provider?.id || 'env'); + const conversation = query({ + prompt: 'Warm capability metadata only. Do not execute tools.', + options: queryOptions, + }); + + try { + await captureCapabilities(`warm-${Date.now()}`, conversation, capProviderId); + return { providerId: capProviderId, warmed: true }; + } finally { + clearTimeout(timeoutId); + abortController.abort(); + } +} + export function streamClaude(options: ClaudeStreamOptions): ReadableStream { const { prompt, diff --git a/src/types/index.ts b/src/types/index.ts index 424f08ff..f332a715 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -193,6 +193,8 @@ export interface ProviderModelGroup { provider_type: string; /** True if this provider only supports Claude Code SDK wire protocol, not standard Messages API */ sdkProxyOnly?: boolean; + /** Where this model list came from: fallback static defaults, live SDK capabilities, or configured provider metadata */ + modelsSource?: 'fallback' | 'sdk' | 'configured'; models: Array<{ value: string; // internal/UI model ID label: string; // display name