Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/app/api/providers/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
});
Expand Down
51 changes: 51 additions & 0 deletions src/app/api/sdk/warm/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
34 changes: 34 additions & 0 deletions src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const pendingImageNoticesRef = useRef<string[]>([]);
const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise<void>>(undefined);
const initMetaRef = useRef<{ tools?: unknown; slash_commands?: unknown; skills?: unknown } | null>(null);
const warmupKeyRef = useRef<string>('');

const handleModeChange = useCallback((newMode: string) => {
setMode(newMode);
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions src/components/chat/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
8 changes: 7 additions & 1 deletion src/hooks/useProviderModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -75,5 +80,6 @@ export function useProviderModels(
currentProviderIdValue,
modelOptions,
currentModelOption,
currentModelsSource: currentGroup?.modelsSource,
};
}
75 changes: 75 additions & 0 deletions src/lib/claude-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = { ...process.env as Record<string, string> };
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<string> {
const {
prompt,
Expand Down
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down