Skip to content

Commit f95eed3

Browse files
authored
🤖 Add Haiku 4-5 support and centralize default model logic (#267)
## Summary Adds support for Claude Haiku 4-5 (released today) and simplifies default model selection by centralizing logic in the LRU system. ## Changes ### Haiku 4-5 Support - Added `haiku: "anthropic:claude-haiku-4-5"` to MODEL_ABBREVIATIONS - Added pricing/config to `models-extra.ts`: - Input: $1 per million tokens - Output: $5 per million tokens - Cache creation: $1.25 per million tokens - Cache read: $0.10 per million tokens - 200K context window, 8K output ### Centralized Default Model Logic **Problem:** `defaultModel` was imported in 7+ files, spreading model prescription throughout the codebase. **Solution:** Created `getDefaultModelFromLRU()` helper that reads the most recently used model from the LRU cache. This is now the **single source of truth** for default model selection. **Architecture:** ``` MODEL_ABBREVIATIONS → useModelLRU initialization → getDefaultModelFromLRU() → all consumers ``` **Updated files:** - `src/hooks/useModelLRU.ts` - Added `getDefaultModelFromLRU()` helper - `src/hooks/useSendMessageOptions.ts` - Use LRU instead of hardcoded default - `src/utils/messages/sendOptions.ts` - Use LRU for non-hook contexts - `src/hooks/useAIViewKeybinds.ts` - Use LRU for keybind fallbacks - Debug scripts - Use LRU instead of hardcoded defaults ### Model Ordering - Reordered MODEL_ABBREVIATIONS to put `sonnet` first - Sonnet 4-5 is now the default for first-time users - After that, LRU drives defaults (user behavior takes over) ## Benefits ✅ **Less prescriptive:** Most recently used model becomes the default ✅ **Single source of truth:** Only `useModelLRU.ts` imports `defaultModel` ✅ **Cross-workspace memory:** Using Haiku in workspace A makes it default for workspace B ✅ **Natural discovery:** As users try models, they automatically become defaults ## Testing - ✅ Typechecks pass - ✅ All imports verified (only 2 files import `defaultModel` now) - ✅ Model selection paths tested via existing hooks _Generated with `cmux`_
1 parent 16ca4e5 commit f95eed3

File tree

12 files changed

+1236
-333
lines changed

12 files changed

+1236
-333
lines changed

scripts/update_models.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
/**
44
* Downloads the latest model prices and context window data from LiteLLM
5-
* and saves it to src/utils/models.json
5+
* and saves it to src/utils/tokens/models.json
66
*/
77

88
const LITELLM_URL =
99
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
10-
const OUTPUT_PATH = "src/utils/models.json";
10+
const OUTPUT_PATH = "src/utils/tokens/models.json";
1111

1212
async function updateModels() {
1313
console.log(`Fetching model data from ${LITELLM_URL}...`);

src/components/ChatInput.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
350350
const inputRef = useRef<HTMLTextAreaElement>(null);
351351
const modelSelectorRef = useRef<ModelSelectorRef>(null);
352352
const [mode, setMode] = useMode();
353-
const { recentModels } = useModelLRU();
353+
const { recentModels, addModel } = useModelLRU();
354354
const commandListId = useId();
355355

356356
// Get current send message options from shared hook (must be at component top level)
@@ -359,8 +359,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
359359
const preferredModel = sendMessageOptions.model;
360360
// Setter for model - updates localStorage directly so useSendMessageOptions picks it up
361361
const setPreferredModel = useCallback(
362-
(model: string) => updatePersistedState(getModelKey(workspaceId), model),
363-
[workspaceId]
362+
(model: string) => {
363+
addModel(model); // Update LRU
364+
updatePersistedState(getModelKey(workspaceId), model); // Update workspace-specific
365+
},
366+
[workspaceId, addModel]
364367
);
365368

366369
const focusMessageInput = useCallback(() => {

src/debug/agentSessionCli.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
type SendMessageOptions,
2323
type WorkspaceChatMessage,
2424
} from "@/types/ipc";
25-
import { defaultModel } from "@/utils/ai/models";
25+
import { getDefaultModelFromLRU } from "@/hooks/useModelLRU";
2626
import { ensureProvidersConfig } from "@/utils/providers/ensureProvidersConfig";
2727
import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/utils/ui/modeUtils";
2828
import { extractAssistantText, extractReasoning, extractToolCalls } from "@/debug/chatExtractors";
@@ -184,7 +184,8 @@ async function main(): Promise<void> {
184184
throw new Error("Message must be provided via --message or stdin");
185185
}
186186

187-
const model = values.model && values.model.trim().length > 0 ? values.model.trim() : defaultModel;
187+
const model =
188+
values.model && values.model.trim().length > 0 ? values.model.trim() : getDefaultModelFromLRU();
188189
const timeoutMs = parseTimeout(values.timeout);
189190
const thinkingLevel = parseThinkingLevel(values["thinking-level"]);
190191
const initialMode = parseMode(values.mode);

src/debug/costs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as path from "path";
33
import { defaultConfig } from "@/config";
44
import type { CmuxMessage } from "@/types/message";
55
import { calculateTokenStats } from "@/utils/tokens/tokenStatsCalculator";
6-
import { defaultModel } from "@/utils/ai/models";
6+
import { getDefaultModelFromLRU } from "@/hooks/useModelLRU";
77

88
/**
99
* Debug command to display cost/token statistics for a workspace
@@ -35,7 +35,7 @@ export function costsCommand(workspaceId: string) {
3535

3636
// Detect model from first assistant message
3737
const firstAssistantMessage = messages.find((msg) => msg.role === "assistant");
38-
const model = firstAssistantMessage?.metadata?.model ?? defaultModel;
38+
const model = firstAssistantMessage?.metadata?.model ?? getDefaultModelFromLRU();
3939

4040
// Calculate stats using shared logic (now synchronous)
4141
const stats = calculateTokenStats(messages, model);

src/debug/send-message.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as path from "path";
33
import { defaultConfig } from "@/config";
44
import type { CmuxMessage } from "@/types/message";
55
import type { SendMessageOptions } from "@/types/ipc";
6-
import { defaultModel } from "@/utils/ai/models";
6+
import { getDefaultModelFromLRU } from "@/hooks/useModelLRU";
77

88
/**
99
* Debug command to send a message to a workspace, optionally editing an existing message
@@ -103,7 +103,7 @@ export function sendMessageCommand(
103103

104104
// Prepare options
105105
const options: SendMessageOptions = {
106-
model: defaultModel,
106+
model: getDefaultModelFromLRU(),
107107
};
108108

109109
if (editMessageId) {

src/hooks/useAIViewKeybinds.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { updatePersistedState, readPersistedState } from "@/hooks/usePersistedSt
66
import type { ThinkingLevel, ThinkingLevelOn } from "@/types/thinking";
77
import { DEFAULT_THINKING_LEVEL } from "@/types/thinking";
88
import { getThinkingPolicyForModel } from "@/utils/thinking/policy";
9-
import { defaultModel } from "@/utils/ai/models";
9+
import { getDefaultModelFromLRU } from "@/hooks/useModelLRU";
1010

1111
interface UseAIViewKeybindsParams {
1212
workspaceId: string;
@@ -66,10 +66,10 @@ export function useAIViewKeybinds({
6666
e.preventDefault();
6767

6868
// Get selected model from localStorage (what user sees in UI)
69-
// Fall back to message history model, then to default model
69+
// Fall back to message history model, then to most recent model from LRU
7070
// This matches the same logic as useSendMessageOptions
7171
const selectedModel = readPersistedState<string | null>(getModelKey(workspaceId), null);
72-
const modelToUse = selectedModel ?? currentModel ?? defaultModel;
72+
const modelToUse = selectedModel ?? currentModel ?? getDefaultModelFromLRU();
7373

7474
// Storage key for remembering this model's last-used active thinking level
7575
const lastThinkingKey = getLastThinkingByModelKey(modelToUse);

src/hooks/useModelLRU.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,45 @@
11
import { useCallback, useEffect } from "react";
2-
import { usePersistedState } from "./usePersistedState";
2+
import { usePersistedState, readPersistedState } from "./usePersistedState";
33
import { MODEL_ABBREVIATIONS } from "@/utils/slashCommands/registry";
4+
import { defaultModel } from "@/utils/ai/models";
45

56
const MAX_LRU_SIZE = 8;
67
const LRU_KEY = "model-lru";
78

89
// Default models from abbreviations (for initial LRU population)
910
const DEFAULT_MODELS = Object.values(MODEL_ABBREVIATIONS);
1011

12+
/**
13+
* Get the default model from LRU (non-hook version for use outside React)
14+
* This is the ONLY place that reads from LRU outside of the hook.
15+
*
16+
* @returns The most recently used model, or defaultModel if LRU is empty
17+
*/
18+
export function getDefaultModelFromLRU(): string {
19+
const lru = readPersistedState<string[]>(LRU_KEY, DEFAULT_MODELS.slice(0, MAX_LRU_SIZE));
20+
return lru[0] ?? defaultModel;
21+
}
22+
1123
/**
1224
* Hook to manage a Least Recently Used (LRU) cache of AI models.
1325
* Stores up to 8 recently used models in localStorage.
1426
* Initializes with default abbreviated models if empty.
1527
*/
1628
export function useModelLRU() {
17-
const [recentModels, setRecentModels] = usePersistedState<string[]>(LRU_KEY, []);
29+
const [recentModels, setRecentModels] = usePersistedState<string[]>(
30+
LRU_KEY,
31+
DEFAULT_MODELS.slice(0, MAX_LRU_SIZE)
32+
);
1833

19-
// Ensure default models are always present in the LRU (only once on mount)
34+
// Merge any new defaults from MODEL_ABBREVIATIONS (only once on mount)
2035
useEffect(() => {
2136
setRecentModels((prev) => {
22-
// If empty, just use defaults
23-
if (prev.length === 0) {
24-
return DEFAULT_MODELS.slice(0, MAX_LRU_SIZE);
25-
}
26-
27-
// If we have some models, merge with defaults (keeping existing order, adding missing defaults at end)
2837
const merged = [...prev];
2938
for (const defaultModel of DEFAULT_MODELS) {
3039
if (!merged.includes(defaultModel)) {
3140
merged.push(defaultModel);
3241
}
3342
}
34-
35-
// Limit to MAX_LRU_SIZE
3643
return merged.slice(0, MAX_LRU_SIZE);
3744
});
3845
// eslint-disable-next-line react-hooks/exhaustive-deps

src/hooks/useSendMessageOptions.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { use1MContext } from "./use1MContext";
22
import { useThinkingLevel } from "./useThinkingLevel";
33
import { useMode } from "@/contexts/ModeContext";
44
import { usePersistedState } from "./usePersistedState";
5+
import { useModelLRU } from "./useModelLRU";
56
import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/utils/ui/modeUtils";
6-
import { defaultModel } from "@/utils/ai/models";
77
import { getModelKey } from "@/constants/storage";
88
import type { SendMessageOptions } from "@/types/ipc";
99
import type { UIMode } from "@/types/mode";
@@ -19,13 +19,14 @@ function constructSendMessageOptions(
1919
mode: UIMode,
2020
thinkingLevel: ThinkingLevel,
2121
preferredModel: string | null | undefined,
22-
use1M: boolean
22+
use1M: boolean,
23+
fallbackModel: string
2324
): SendMessageOptions {
2425
const additionalSystemInstructions = mode === "plan" ? PLAN_MODE_INSTRUCTION : undefined;
2526

2627
// Ensure model is always a valid string (defensive against corrupted localStorage)
2728
const model =
28-
typeof preferredModel === "string" && preferredModel ? preferredModel : defaultModel;
29+
typeof preferredModel === "string" && preferredModel ? preferredModel : fallbackModel;
2930

3031
// Enforce thinking policy at the UI boundary as well (e.g., gpt-5-pro → high only)
3132
const uiThinking = enforceThinkingPolicy(model, thinkingLevel);
@@ -58,13 +59,14 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptions {
5859
const [use1M] = use1MContext();
5960
const [thinkingLevel] = useThinkingLevel();
6061
const [mode] = useMode();
62+
const { recentModels } = useModelLRU();
6163
const [preferredModel] = usePersistedState<string>(
6264
getModelKey(workspaceId),
63-
defaultModel,
65+
recentModels[0], // Most recently used model (LRU is never empty)
6466
{ listener: true } // Listen for changes from ModelSelector and other sources
6567
);
6668

67-
return constructSendMessageOptions(mode, thinkingLevel, preferredModel, use1M);
69+
return constructSendMessageOptions(mode, thinkingLevel, preferredModel, use1M, recentModels[0]);
6870
}
6971

7072
/**

src/utils/messages/sendOptions.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import {
44
getModeKey,
55
USE_1M_CONTEXT_KEY,
66
} from "@/constants/storage";
7-
import { defaultModel } from "@/utils/ai/models";
87
import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/utils/ui/modeUtils";
98
import { readPersistedState } from "@/hooks/usePersistedState";
109
import type { SendMessageOptions } from "@/types/ipc";
1110
import type { UIMode } from "@/types/mode";
1211
import type { ThinkingLevel } from "@/types/thinking";
1312
import { enforceThinkingPolicy } from "@/utils/thinking/policy";
13+
import { getDefaultModelFromLRU } from "@/hooks/useModelLRU";
1414

1515
/**
1616
* Get send options from localStorage
@@ -20,8 +20,8 @@ import { enforceThinkingPolicy } from "@/utils/thinking/policy";
2020
* This ensures DRY - single source of truth for option extraction.
2121
*/
2222
export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptions {
23-
// Read model preference (workspace-specific)
24-
const model = readPersistedState<string>(getModelKey(workspaceId), defaultModel);
23+
// Read model preference (workspace-specific), fallback to most recent from LRU
24+
const model = readPersistedState<string>(getModelKey(workspaceId), getDefaultModelFromLRU());
2525

2626
// Read thinking level (workspace-specific)
2727
const thinkingLevel = readPersistedState<ThinkingLevel>(

src/utils/slashCommands/registry.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import type {
1111
import minimist from "minimist";
1212

1313
// Model abbreviations for common models
14+
// Order matters: first model becomes the default for new chats
1415
export const MODEL_ABBREVIATIONS: Record<string, string> = {
15-
opus: "anthropic:claude-opus-4-1",
1616
sonnet: "anthropic:claude-sonnet-4-5",
17+
haiku: "anthropic:claude-haiku-4-5",
18+
opus: "anthropic:claude-opus-4-1",
1719
"gpt-5": "openai:gpt-5",
1820
"gpt-5-pro": "openai:gpt-5-pro",
1921
codex: "openai:gpt-5-codex",

0 commit comments

Comments
 (0)