Compare commits

..

4 Commits

Author SHA1 Message Date
Haile
b3d0f9037d Merge branch 'main' into chore/update-claude-fallback-models 2026-06-05 15:58:05 +03:00
Haile
957f53fb99 Merge branch 'main' into chore/update-claude-fallback-models 2026-06-05 15:19:13 +03:00
Haileyesus
cdcac182d4 fix: load claude models directly from provider
Claude's model catalog changes quickly enough that a shared three-day cache can
leave users selecting stale defaults or missing newly available model aliases.
Route Claude model lookups through the provider every time so the UI and slash
commands reflect the current provider result instead of an old disk snapshot.

Keep the static fallback catalog aligned with the latest Claude defaults so the
provider still has a sensible response when live discovery is unavailable.
2026-06-05 15:14:32 +03:00
Haileyesus
94785bfa57 chore: update Claude fallback models 2026-06-05 15:02:25 +03:00
7 changed files with 142 additions and 43 deletions

View File

@@ -11,7 +11,8 @@ export const CLAUDE_MODELS = {
{ {
value: "default", value: "default",
label: "Default (recommended)", label: "Default (recommended)",
description: "Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok", description:
"Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
}, },
{ {
value: "sonnet", value: "sonnet",
@@ -23,6 +24,12 @@ export const CLAUDE_MODELS = {
label: "Sonnet (1M context)", label: "Sonnet (1M context)",
description: "Sonnet 4.6 for long sessions · $3/$15 per Mtok", description: "Sonnet 4.6 for long sessions · $3/$15 per Mtok",
}, },
{
value: "opus[1m]",
label: "Opus 4.8 (1M context)",
description:
"Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok",
},
{ {
value: "haiku", value: "haiku",
label: "Haiku", label: "Haiku",

View File

@@ -18,18 +18,23 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
{ {
value: 'default', value: 'default',
label: 'Default (recommended)', label: 'Default (recommended)',
description: 'Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok', description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
}, },
{ {
value: 'sonnet', value: "sonnet",
label: 'Sonnet', label: "Sonnet",
description: 'Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok', description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
}, },
{ {
value: 'sonnet[1m]', value: 'sonnet[1m]',
label: 'Sonnet (1M context)', label: 'Sonnet (1M context)',
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok', description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
}, },
{
value: 'opus[1m]',
label: 'Opus 4.8 (1M context)',
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
},
{ {
value: 'haiku', value: 'haiku',
label: 'Haiku', label: 'Haiku',

View File

@@ -17,6 +17,7 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000; export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
const PROVIDER_MODELS_CACHE_VERSION = 1; const PROVIDER_MODELS_CACHE_VERSION = 1;
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
type ProviderModelsServiceDependencies = { type ProviderModelsServiceDependencies = {
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>; resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
@@ -232,10 +233,42 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
return request; return request;
}; };
const loadDirectModels = (
provider: LLMProvider,
): Promise<ProviderModelsResult> => {
const request = resolveProvider(provider).models.getSupportedModels()
.then((models) => {
const currentTime = now();
return {
models,
cache: {
updatedAt: new Date(currentTime).toISOString(),
expiresAt: new Date(currentTime).toISOString(),
source: 'fresh' as const,
},
};
})
.finally(() => {
pendingRequests.delete(provider);
});
pendingRequests.set(provider, request);
return request;
};
const getProviderModels = async ( const getProviderModels = async (
provider: LLMProvider, provider: LLMProvider,
options: ProviderModelsOptions = {}, options: ProviderModelsOptions = {},
): Promise<ProviderModelsResult> => { ): Promise<ProviderModelsResult> => {
if (UNCACHED_PROVIDERS.has(provider)) {
const pendingRequest = pendingRequests.get(provider);
if (pendingRequest) {
return pendingRequest;
}
return loadDirectModels(provider);
}
if (options.bypassCache) { if (options.bypassCache) {
const pendingRequest = pendingRequests.get(provider); const pendingRequest = pendingRequests.get(provider);
if (pendingRequest) { if (pendingRequest) {

View File

@@ -130,6 +130,37 @@ test('provider models are cached for the three-day ttl', async () => {
} }
}); });
test('claude provider models are always loaded directly from the provider', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-claude-direct-'));
let loadCount = 0;
try {
const service = createProviderModelsService({
cachePath: path.join(tempRoot, 'models-cache.json'),
resolveProvider: (provider) => ({
models: {
getSupportedModels: async () => {
loadCount += 1;
return createModels(`${provider}-${loadCount}`);
},
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
},
}),
});
const first = await service.getProviderModels('claude');
const second = await service.getProviderModels('claude');
assert.equal(loadCount, 2);
assert.equal(first.models.DEFAULT, 'claude-1');
assert.equal(second.models.DEFAULT, 'claude-2');
assert.equal(second.cache.source, 'fresh');
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
test('provider model cache is persisted across service instances', async () => { test('provider model cache is persisted across service instances', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-')); const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
const cachePath = path.join(tempRoot, 'models-cache.json'); const cachePath = path.join(tempRoot, 'models-cache.json');

View File

@@ -7,12 +7,6 @@ import type { NormalizedMessage } from '../../../stores/useSessionStore';
import type { ChatMessage, SubagentChildTool } from '../types/types'; import type { ChatMessage, SubagentChildTool } from '../types/types';
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting'; import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
function formatToolResultContent(content: unknown): string {
const text = typeof content === 'string' ? content : JSON.stringify(content);
const toolUseErrorMatch = /^<tool_use_error>([\s\S]*)<\/tool_use_error>$/.exec(text.trim());
return toolUseErrorMatch ? toolUseErrorMatch[1] : text;
}
/** /**
* Convert NormalizedMessage[] from the session store into ChatMessage[] * Convert NormalizedMessage[] from the session store into ChatMessage[]
* that the existing UI components expect. * that the existing UI components expect.
@@ -26,12 +20,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
// First pass: collect tool results for attachment // First pass: collect tool results for attachment
const toolResultMap = new Map<string, NormalizedMessage>(); const toolResultMap = new Map<string, NormalizedMessage>();
const toolUseIds = new Set<string>();
for (const msg of messages) { for (const msg of messages) {
if (msg.kind === 'tool_use' && msg.toolId) {
toolUseIds.add(msg.toolId);
}
if (msg.kind === 'tool_result' && msg.toolId) { if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg); toolResultMap.set(msg.toolId, msg);
} }
@@ -108,7 +97,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
const toolResult = tr const toolResult = tr
? { ? {
content: formatToolResultContent(tr.content), content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
isError: Boolean(tr.isError), isError: Boolean(tr.isError),
toolUseResult: (tr as any).toolUseResult, toolUseResult: (tr as any).toolUseResult,
} }
@@ -202,25 +191,8 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
break; break;
// tool_result is handled via attachment to tool_use above // tool_result is handled via attachment to tool_use above
case 'tool_result': { case 'tool_result':
if (msg.toolId && toolUseIds.has(msg.toolId)) {
break;
}
const content = formatToolResultContent(msg.content || '');
if (!content.trim()) {
break;
}
converted.push({
type: msg.isError ? 'error' : 'assistant',
content,
timestamp: msg.timestamp,
toolId: msg.toolId,
...sharedMetadata,
});
break; break;
}
default: default:
break; break;

View File

@@ -564,15 +564,11 @@ export function shouldHideToolResult(toolName: string, toolResult: any): boolean
if (!config.result) return false; if (!config.result) return false;
// Hidden/success-only configs suppress noisy successful output, but errors
// still need to be visible so failed tool calls are diagnosable.
if (toolResult?.isError) return false;
// Always hidden // Always hidden
if (config.result.hidden) return true; if (config.result.hidden) return true;
// Hide on success only // Hide on success only
if (config.result.hideOnSuccess && toolResult) { if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
return true; return true;
} }

View File

@@ -1,6 +1,5 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { import type {
ChatMessage, ChatMessage,
@@ -9,10 +8,10 @@ import type {
Provider, Provider,
} from '../../types/types'; } from '../../types/types';
import { formatUsageLimitText } from '../../utils/chatFormatting'; import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import type { Project } from '../../../../types/app'; import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools'; import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui'; import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl'; import MessageCopyControl from './MessageCopyControl';
@@ -42,9 +41,10 @@ type InteractiveOption = {
isSelected: boolean; isSelected: boolean;
}; };
type PermissionGrantState = 'idle' | 'granted' | 'error';
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']); const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => { const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type && const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') || ((prevMessage.type === 'assistant') ||
@@ -53,6 +53,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
(prevMessage.type === 'error')); (prevMessage.type === 'error'));
const messageRef = useRef<HTMLDivElement | null>(null); const messageRef = useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
const userCopyContent = String(message.content || ''); const userCopyContent = String(message.content || '');
const formattedMessageContent = useMemo( const formattedMessageContent = useMemo(
() => formatUsageLimitText(String(message.content || '')), () => formatUsageLimitText(String(message.content || '')),
@@ -71,6 +73,10 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
!message.isThinking; !message.isThinking;
useEffect(() => {
setPermissionGrantState('idle');
}, [permissionSuggestion?.entry, message.toolId]);
useEffect(() => { useEffect(() => {
const node = messageRef.current; const node = messageRef.current;
if (!autoExpandTools || !node || !message.isToolUse) return; if (!autoExpandTools || !node || !message.isToolUse) return;
@@ -235,6 +241,55 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert"> <Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
{String(message.toolResult.content || '')} {String(message.toolResult.content || '')}
</Markdown> </Markdown>
{permissionSuggestion && (
<div className="mt-4 border-t border-red-200/60 pt-3 dark:border-red-800/60">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
if (!onGrantToolPermission) return;
const result = onGrantToolPermission(permissionSuggestion);
if (result?.success) {
setPermissionGrantState('granted');
} else {
setPermissionGrantState('error');
}
}}
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'cursor-default border-green-300/70 bg-green-100 text-green-800 dark:border-green-800/60 dark:bg-green-900/30 dark:text-green-200'
: 'border-red-300/70 bg-white/80 text-red-700 hover:bg-white dark:border-red-800/60 dark:bg-gray-900/40 dark:text-red-200 dark:hover:bg-gray-900/70'
}`}
>
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? t('permissions.added')
: t('permissions.grant', { tool: permissionSuggestion.toolName })}
</button>
{onShowSettings && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
className="text-xs text-red-700 underline hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
>
{t('permissions.openSettings')}
</button>
)}
</div>
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
{t('permissions.addTo', { entry: permissionSuggestion.entry })}
</div>
{permissionGrantState === 'error' && (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
{t('permissions.error')}
</div>
)}
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
{t('permissions.retry')}
</div>
)}
</div>
)}
</div> </div>
</div> </div>
) : ( ) : (