fix(model): migrate retired claude-sonnet-4-7 default instead of pinning dead model

claude-sonnet-4-7 was retired from the Anthropic API but remains the
default model hardcoded in both the webview (DEFAULT_CLAUDE_MODEL_ID,
default props) and the Java side (SessionState, HandlerContext,
ChatWindowDelegate, ClaudePricingTable). Every flow that falls back to
the default - or restores a persisted tab that had Sonnet 4.7 selected -
now fails on every send:

  There is an issue with the selected model (claude-sonnet-4-7[1m]).
  It may not exist or you may not have access to it.

LEGACY_CLAUDE_MODEL_ID_ALIASES also mapped sonnet-4-6 -> sonnet-4-7,
i.e. one retired id to another retired id, so normalizeClaudeModelId()
kept returning a dead id (#1678).

Webview:
- DEFAULT_CLAUDE_MODEL_ID: sonnet-4-7 -> sonnet-5 (same-tier successor)
- legacy aliases now map every retired id to a live model:
  sonnet-4-6/sonnet-4-7 -> sonnet-5, opus-4-6 -> opus-4-8
- remove claude-sonnet-4-7 from CLAUDE_MODELS and sonnet47 i18n entries;
  move the "Use the default model" description to Sonnet 5 (all locales)
- default selectedModel props use DEFAULT_CLAUDE_MODEL_ID instead of
  duplicated literals

Java:
- sync all four defaults to claude-sonnet-5
- SessionState.setModel() migrates retired ids on write so restored
  .idea/claudeCodeTabState.xml tabs self-heal instead of spawning a CLI
  pinned to a dead model; [1m] suffix is preserved
- lookup-table entries (pricing/context-limit/effort sets) keep the
  retired ids so historical usage stats keep resolving

Fixes #1678
This commit is contained in:
hebulin
2026-08-17 09:14:20 +08:00
parent 077cccff67
commit 04f6e4ef88
22 changed files with 156 additions and 75 deletions
@@ -17,7 +17,7 @@ import java.util.function.Supplier;
*/
public class HandlerContext {
public static final String DEFAULT_MODEL = "claude-sonnet-4-7";
public static final String DEFAULT_MODEL = "claude-sonnet-5";
public static final String DEFAULT_PROVIDER = "claude";
private final Project project;
@@ -13,7 +13,7 @@ import java.util.Map;
*/
public final class ClaudePricingTable {
public static final String DEFAULT_MODEL = "claude-sonnet-4-7";
public static final String DEFAULT_MODEL = "claude-sonnet-5";
private static final ClaudePricing DEFAULT_PRICING = new ClaudePricing(3.0, 15.0, 3.75, 0.30);
private static final ClaudePricing TIERED_SONNET_PRICING = new ClaudePricing(3.0, 15.0, 3.75, 0.30, 6.0, 22.5, 7.5, 0.60);
@@ -83,7 +83,7 @@ public class SessionState {
// explicit, informed opt-in — see security remediation A: shipping bypass as the
// out-of-the-box default removed the only confirmation gate for AI-issued commands.
private volatile String permissionMode = "default";
private volatile String model = "claude-sonnet-4-7";
private volatile String model = "claude-sonnet-5";
private volatile String provider = "claude";
// Reasoning effort (thinking depth). Null means "do not override SDK/settings".
private volatile String reasoningEffort = null;
@@ -213,7 +213,49 @@ public class SessionState {
}
public void setModel(String model) {
this.model = model;
this.model = normalizeRetiredModelId(model);
}
/**
* Migrate retired Claude model ids to their live replacement on write.
*
* <p>Persisted tab state (.idea/claudeCodeTabState.xml) and history sessions keep
* whatever model id was saved forever. When a model is retired from the API
* (sonnet-4-6, sonnet-4-7, ...), restoring such a tab would otherwise spawn a CLI
* pinned to a dead model that fails on every send ("It may not exist or you may
* not have access to it") - see #1678. Migrating here self-heals restored tabs
* without touching the persisted XML.</p>
*
* @param model raw model id (may be null, blank, carry a [1m] suffix, or be retired)
* @return the model id to store - retired ids mapped to their live replacement,
* anything else (including non-Claude ids) passed through unchanged
*/
static String normalizeRetiredModelId(String model) {
if (model == null) {
return null;
}
String trimmed = model.trim();
if (trimmed.isEmpty()) {
return model;
}
String base = trimmed;
boolean oneM = false;
if (base.endsWith("[1m]")) {
base = base.substring(0, base.length() - "[1m]".length());
oneM = true;
}
switch (base) {
case "claude-sonnet-4-6":
case "claude-sonnet-4-7":
base = "claude-sonnet-5";
break;
case "claude-opus-4-6":
base = "claude-opus-4-8";
break;
default:
return trimmed;
}
return oneM ? base + "[1m]" : base;
}
public void setProvider(String provider) {
@@ -440,7 +440,7 @@ public class ChatWindowDelegate {
String mode = session != null ? session.getPermissionMode() : "default";
com.github.claudecodegui.notifications.ClaudeNotifier.setMode(project, mode);
String model = session != null ? session.getModel() : "claude-sonnet-4-7";
String model = session != null ? session.getModel() : "claude-sonnet-5";
com.github.claudecodegui.notifications.ClaudeNotifier.setModel(project, model);
try {
@@ -0,0 +1,75 @@
package com.github.claudecodegui.session;
import org.junit.Assert;
import org.junit.Test;
/**
* Regression tests for retired Claude model id migration on session state writes
* (persisted tab / history restore self-heal) - see #1678.
*/
public class SessionStateTest {
@Test
public void setModelMigratesRetiredSonnet47ToSonnet5() {
SessionState state = new SessionState();
// Saved by versions <= 0.5.2 where sonnet-4-7 was the default model.
state.setModel("claude-sonnet-4-7");
Assert.assertEquals("claude-sonnet-5", state.getModel());
}
@Test
public void setModelMigratesRetiredSonnet46ToSonnet5() {
SessionState state = new SessionState();
state.setModel("claude-sonnet-4-6");
Assert.assertEquals("claude-sonnet-5", state.getModel());
}
@Test
public void setModelMigratesRetiredOpus46ToOpus48() {
SessionState state = new SessionState();
state.setModel("claude-opus-4-6");
Assert.assertEquals("claude-opus-4-8", state.getModel());
}
@Test
public void setModelPreserves1MSuffixWhenMigrating() {
SessionState state = new SessionState();
state.setModel("claude-sonnet-4-7[1m]");
Assert.assertEquals("claude-sonnet-5[1m]", state.getModel());
}
@Test
public void setModelLeavesLiveModelsUntouched() {
SessionState state = new SessionState();
state.setModel("claude-sonnet-5");
Assert.assertEquals("claude-sonnet-5", state.getModel());
state.setModel("claude-opus-4-8[1m]");
Assert.assertEquals("claude-opus-4-8[1m]", state.getModel());
}
@Test
public void setModelLeavesNonClaudeAndUnknownIdsUntouched() {
SessionState state = new SessionState();
// Non-Claude provider models must pass through unchanged.
state.setModel("gpt-5.6-sol");
Assert.assertEquals("gpt-5.6-sol", state.getModel());
state.setModel("qwen3.5-plus");
Assert.assertEquals("qwen3.5-plus", state.getModel());
}
@Test
public void setModelHandlesNullAndBlank() {
SessionState state = new SessionState();
state.setModel(null);
Assert.assertNull(state.getModel());
state.setModel(" ");
Assert.assertEquals(" ", state.getModel());
}
@Test
public void defaultModelIsTheLiveSonnet5() {
SessionState state = new SessionState();
// The initial value must never be a retired id (#1678).
Assert.assertEquals("claude-sonnet-5", state.getModel());
}
}
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { ButtonAreaProps, CodexFastMode, ModelInfo, PermissionMode, ReasoningEffort } from './types';
import { DEFAULT_CLAUDE_MODEL_ID } from './types';
import { CodexFastModeSelect, ConfigSelect, ModelSelect, ModeSelect, ProviderSelect, ReasoningSelect } from './selectors';
import { STORAGE_KEYS, validateCodexCustomModels } from '../../types/provider';
import type { CodexCustomModel } from '../../types/provider';
@@ -73,7 +74,7 @@ export const ButtonArea = ({
hasInputContent = false,
isLoading = false,
isEnhancing = false,
selectedModel = 'claude-sonnet-4-7',
selectedModel = DEFAULT_CLAUDE_MODEL_ID,
permissionMode = 'default',
currentProvider = 'claude',
reasoningEffort = 'high',
@@ -14,6 +14,7 @@ import type {
ChatInputBoxProps,
PermissionMode,
} from './types.js';
import { DEFAULT_CLAUDE_MODEL_ID } from './types.js';
import { ChatInputBoxHeader } from './ChatInputBoxHeader.js';
import { ChatInputBoxFooter } from './ChatInputBoxFooter.js';
import { ResizeHandles } from './ResizeHandles.js';
@@ -76,7 +77,7 @@ export const ChatInputBox = memo(forwardRef<ChatInputBoxHandle, ChatInputBoxProp
(
{
isLoading = false,
selectedModel = 'claude-sonnet-4-7',
selectedModel = DEFAULT_CLAUDE_MODEL_ID,
permissionMode = 'default',
currentProvider = 'claude',
usagePercentage = 0,
@@ -83,13 +83,13 @@ describe('ModelSelect', () => {
'claude-opus-5',
'claude-opus-4-8',
'claude-sonnet-5',
'claude-sonnet-4-7',
'claude-haiku-4-5',
]);
const ids = CLAUDE_MODELS.map((model) => model.id);
expect(ids).not.toContain('claude-opus-4-7');
expect(ids).not.toContain('claude-opus-4-6');
expect(ids).not.toContain('claude-sonnet-4-6');
expect(ids).not.toContain('claude-sonnet-4-7');
expect(ids.some((id) => id.endsWith('[1m]'))).toBe(false);
});
@@ -69,7 +69,6 @@ const DEFAULT_MODEL_MAP: Record<string, ModelInfo> = AVAILABLE_MODELS.reduce(
const MODEL_LABEL_KEYS: Record<string, string> = {
'claude-opus-5': 'models.claude.opus5.label',
'claude-sonnet-5': 'models.claude.sonnet5.label',
'claude-sonnet-4-7': 'models.claude.sonnet47.label',
'claude-sonnet-4-6': 'models.claude.sonnet46.label',
'claude-fable-5': 'models.claude.fable5.label',
'claude-opus-4-8': 'models.claude.opus48.label',
@@ -89,7 +88,6 @@ const MODEL_LABEL_KEYS: Record<string, string> = {
const MODEL_DESCRIPTION_KEYS: Record<string, string> = {
'claude-opus-5': 'models.claude.opus5.description',
'claude-sonnet-5': 'models.claude.sonnet5.description',
'claude-sonnet-4-7': 'models.claude.sonnet47.description',
'claude-sonnet-4-6': 'models.claude.sonnet46.description',
'claude-fable-5': 'models.claude.fable5.description',
'claude-opus-4-8': 'models.claude.opus48.description',
@@ -20,7 +20,12 @@ describe('normalizeClaudeModelId', () => {
it('migrates retired Sonnet 4.6 to the current default', () => {
// Saved by versions <= 0.4.7 where sonnet-4-6 was the default model.
expect(normalizeClaudeModelId('claude-sonnet-4-6')).toBe('claude-sonnet-4-7');
expect(normalizeClaudeModelId('claude-sonnet-4-6')).toBe('claude-sonnet-5');
});
it('migrates retired Sonnet 4.7 to the current default', () => {
// Saved by versions <= 0.5.2 where sonnet-4-7 was the default model (#1678).
expect(normalizeClaudeModelId('claude-sonnet-4-7')).toBe('claude-sonnet-5');
});
it('migrates retired Opus 4.6 to Opus 4.8', () => {
@@ -28,7 +33,8 @@ describe('normalizeClaudeModelId', () => {
});
it('migrates retired IDs carrying a [1m] suffix', () => {
expect(normalizeClaudeModelId('claude-sonnet-4-6[1m]')).toBe('claude-sonnet-4-7');
expect(normalizeClaudeModelId('claude-sonnet-4-6[1m]')).toBe('claude-sonnet-5');
expect(normalizeClaudeModelId('claude-sonnet-4-7[1m]')).toBe('claude-sonnet-5');
expect(normalizeClaudeModelId('claude-opus-4-6[1m]')).toBe('claude-opus-4-8');
});
+7 -9
View File
@@ -301,15 +301,18 @@ export function strip1MContextSuffix(modelId: string | undefined | null): string
* CLAUDE_MODELS[0], which is the newest tier and the most likely to be missing
* from a user's API relay.
*/
export const DEFAULT_CLAUDE_MODEL_ID = 'claude-sonnet-4-7';
export const DEFAULT_CLAUDE_MODEL_ID = 'claude-sonnet-5';
/**
* Retired model IDs their current-generation replacement. Lookup happens after
* Retired model IDs -> their current-generation replacement. Lookup happens after
* the [1m] suffix is stripped, so keys must be base IDs. Without an entry here a
* saved retired model fails validation and silently resets to the fallback.
* Retired ids must always map to a LIVE model - mapping one retired id to another
* (sonnet-4-6 -> sonnet-4-7) kept restoring tabs pinned to a dead model (#1678).
*/
const LEGACY_CLAUDE_MODEL_ID_ALIASES: Record<string, string> = {
'claude-sonnet-4-6': 'claude-sonnet-4-7',
'claude-sonnet-4-6': 'claude-sonnet-5',
'claude-sonnet-4-7': 'claude-sonnet-5',
'claude-opus-4-6': 'claude-opus-4-8',
};
@@ -345,12 +348,7 @@ export const CLAUDE_MODELS: ModelInfo[] = [
{
id: 'claude-sonnet-5',
label: 'Sonnet 5',
description: 'Sonnet 5 · Upgraded Sonnet model',
},
{
id: 'claude-sonnet-4-7',
label: 'Sonnet 4.7',
description: 'Sonnet 4.7 · Use the default model',
description: 'Sonnet 5 · Use the default model',
},
{
id: 'claude-haiku-4-5',
@@ -207,9 +207,9 @@ describe('useModelStatePersistence — retired model migration', () => {
renderHook(() => useModelStatePersistence(makeOptions({ setSelectedClaudeModel })));
vi.advanceTimersByTime(200);
expect(setSelectedClaudeModel).toHaveBeenCalledWith('claude-sonnet-4-7');
expect(setSelectedClaudeModel).toHaveBeenCalledWith('claude-sonnet-5');
expect(setSelectedClaudeModel).not.toHaveBeenCalledWith('claude-fable-5');
expect(bridgeEventsFor('set_model')).toEqual([['set_model', 'claude-sonnet-4-7']]);
expect(bridgeEventsFor('set_model')).toEqual([['set_model', 'claude-sonnet-5']]);
});
it('migrates a backend-supplied retired model via __INITIAL_TAB_MODEL__', () => {
@@ -225,8 +225,8 @@ describe('useModelStatePersistence — retired model migration', () => {
renderHook(() => useModelStatePersistence(makeOptions({ setSelectedClaudeModel })));
vi.advanceTimersByTime(200);
expect(setSelectedClaudeModel).toHaveBeenCalledWith('claude-sonnet-4-7');
expect(bridgeEventsFor('set_model')).toEqual([['set_model', 'claude-sonnet-4-7']]);
expect(setSelectedClaudeModel).toHaveBeenCalledWith('claude-sonnet-5');
expect(bridgeEventsFor('set_model')).toEqual([['set_model', 'claude-sonnet-5']]);
});
it('falls back to the default model (not the list head) for unrecognized saved models', () => {
+1 -5
View File
@@ -1915,11 +1915,7 @@
},
"sonnet5": {
"label": "Sonnet 5",
"description": "Sonnet 5 · Upgraded Sonnet model"
},
"sonnet47": {
"label": "Sonnet 4.7",
"description": "Sonnet 4.7 · Use the default model"
"description": "Sonnet 5 · Use the default model"
},
"opus5": {
"label": "Opus 5",
+1 -5
View File
@@ -1539,11 +1539,7 @@
},
"sonnet5": {
"label": "Sonnet 5",
"description": "Sonnet 5 · Modelo mejorado de Sonnet 4.6"
},
"sonnet47": {
"label": "Sonnet 4.7",
"description": "Sonnet 4.7 · Modelo recomendado por defecto"
"description": "Sonnet 5 · Modelo recomendado por defecto"
},
"opus5": {
"label": "Opus 5",
+1 -5
View File
@@ -1539,11 +1539,7 @@
},
"sonnet5": {
"label": "Sonnet 5",
"description": "Sonnet 5 · Modèle amélioré de Sonnet 4.6"
},
"sonnet47": {
"label": "Sonnet 4.7",
"description": "Sonnet 4.7 · Modèle recommandé par défaut"
"description": "Sonnet 5 · Modèle recommandé par défaut"
},
"opus5": {
"label": "Opus 5",
+1 -5
View File
@@ -1538,11 +1538,7 @@
},
"sonnet5": {
"label": "Sonnet 5",
"description": "Sonnet 5 · Sonnet 4.6 का अपग्रेड मॉडल"
},
"sonnet47": {
"label": "Sonnet 4.7",
"description": "Sonnet 4.7 · डिफ़ॉल्ट अनुशंसित मॉडल"
"description": "Sonnet 5 · डिफ़ॉल्ट अनुशंसित मॉडल"
},
"opus5": {
"label": "Opus 5",
+1 -5
View File
@@ -1550,11 +1550,7 @@
},
"sonnet5": {
"label": "Sonnet 5",
"description": "Sonnet 5 · Sonnet 4.6 のアップグレードモデル"
},
"sonnet47": {
"label": "Sonnet 4.7",
"description": "Sonnet 4.7 · デフォルトモデルを使用"
"description": "Sonnet 5 · デフォルトモデルを使用"
},
"opus5": {
"label": "Opus 5",
+1 -5
View File
@@ -1634,11 +1634,7 @@
},
"sonnet5": {
"label": "Sonnet 5",
"description": "Sonnet 5 · Sonnet 4.6 업그레이드 모델"
},
"sonnet47": {
"label": "Sonnet 4.7",
"description": "Sonnet 4.7 · 기본 모델 사용"
"description": "Sonnet 5 · 기본 모델 사용"
},
"opus5": {
"label": "Opus 5",
+1 -5
View File
@@ -1689,11 +1689,7 @@
},
"sonnet5": {
"label": "Sonnet 5",
"description": "Sonnet 5 · Modelo aprimorado do Sonnet 4.6"
},
"sonnet47": {
"label": "Sonnet 4.7",
"description": "Sonnet 4.7 · Usar o modelo padrão"
"description": "Sonnet 5 · Usar o modelo padrão"
},
"opus5": {
"label": "Opus 5",
+1 -5
View File
@@ -1565,11 +1565,7 @@
},
"sonnet5": {
"label": "Sonnet 5",
"description": "Sonnet 5 · Обновленная модель Sonnet 4.6"
},
"sonnet47": {
"label": "Sonnet 4.7",
"description": "Sonnet 4.7 · Модель по умолчанию"
"description": "Sonnet 5 · Модель по умолчанию"
},
"opus5": {
"label": "Opus 5",
+1 -5
View File
@@ -1544,11 +1544,7 @@
},
"sonnet5": {
"label": "Sonnet 5",
"description": "Sonnet 5 · Sonnet 4.6 升級模型"
},
"sonnet47": {
"label": "Sonnet 4.7",
"description": "Sonnet 4.7 · 預設推薦模型"
"description": "Sonnet 5 · 預設推薦模型"
},
"opus5": {
"label": "Opus 5",
+1 -5
View File
@@ -1920,11 +1920,7 @@
},
"sonnet5": {
"label": "Sonnet 5",
"description": "Sonnet 5 · Sonnet 4.6 升级模型"
},
"sonnet47": {
"label": "Sonnet 4.7",
"description": "Sonnet 4.7 · 默认推荐模型"
"description": "Sonnet 5 · 默认推荐模型"
},
"opus5": {
"label": "Opus 5",