From ce5edc42e1d1ef2d0ebd014c6b402527a20147d3 Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 16 Mar 2026 08:51:40 +0800 Subject: [PATCH] Add support for claude 1M context. close #291 --- cli/src/claude/loop.ts | 5 ++--- cli/src/claude/modelMode.test.ts | 23 +++++++++++++++++++++ cli/src/claude/modelMode.ts | 18 ++++++++++++++++ cli/src/claude/runClaude.ts | 3 ++- shared/src/modes.ts | 10 +++++++-- web/README.md | 2 +- web/src/chat/modelConfig.test.ts | 16 ++++++++++++++ web/src/chat/modelConfig.ts | 6 ++++-- web/src/components/NewSession/types.test.ts | 21 +++++++++++++++++++ web/src/components/NewSession/types.ts | 2 ++ web/src/components/SessionHeader.tsx | 4 +++- web/src/components/SessionList.tsx | 4 +++- 12 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 cli/src/claude/modelMode.test.ts create mode 100644 cli/src/claude/modelMode.ts create mode 100644 web/src/chat/modelConfig.test.ts create mode 100644 web/src/components/NewSession/types.test.ts diff --git a/cli/src/claude/loop.ts b/cli/src/claude/loop.ts index 7f337cc3..a493f110 100644 --- a/cli/src/claude/loop.ts +++ b/cli/src/claude/loop.ts @@ -8,6 +8,7 @@ import { claudeRemoteLauncher } from "./claudeRemoteLauncher" import { ApiClient } from "@/lib" import type { SessionModelMode } from "@/api/types" import type { ClaudePermissionMode } from "@hapi/protocol/types" +import { resolveClaudeSessionModelMode } from "./modelMode" export type PermissionMode = ClaudePermissionMode; @@ -45,9 +46,7 @@ export async function loop(opts: LoopOptions) { const logPath = logger.logFilePath; const startedBy = opts.startedBy ?? 'terminal'; const startingMode = opts.startingMode ?? 'local'; - const modelMode: SessionModelMode = opts.model === 'sonnet' || opts.model === 'opus' - ? opts.model - : 'default'; + const modelMode: SessionModelMode = resolveClaudeSessionModelMode(opts.model) const session = new Session({ api: opts.api, client: opts.session, diff --git a/cli/src/claude/modelMode.test.ts b/cli/src/claude/modelMode.test.ts new file mode 100644 index 00000000..06d1cdf5 --- /dev/null +++ b/cli/src/claude/modelMode.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { resolveClaudeSessionModelMode } from './modelMode' + +describe('resolveClaudeSessionModelMode', () => { + it('returns default when model is missing', () => { + expect(resolveClaudeSessionModelMode()).toBe('default') + }) + + it('returns default for auto and unsupported models', () => { + expect(resolveClaudeSessionModelMode('auto')).toBe('default') + expect(resolveClaudeSessionModelMode('claude-sonnet-4-5')).toBe('default') + }) + + it('returns standard Claude session model modes', () => { + expect(resolveClaudeSessionModelMode('sonnet')).toBe('sonnet') + expect(resolveClaudeSessionModelMode('opus')).toBe('opus') + }) + + it('returns 1m Claude session model modes', () => { + expect(resolveClaudeSessionModelMode('sonnet[1m]')).toBe('sonnet[1m]') + expect(resolveClaudeSessionModelMode('opus[1m]')).toBe('opus[1m]') + }) +}) diff --git a/cli/src/claude/modelMode.ts b/cli/src/claude/modelMode.ts new file mode 100644 index 00000000..b8f74f2b --- /dev/null +++ b/cli/src/claude/modelMode.ts @@ -0,0 +1,18 @@ +import type { SessionModelMode } from '@/api/types' + +const CLAUDE_SESSION_MODEL_MODES = new Set([ + 'sonnet', + 'sonnet[1m]', + 'opus', + 'opus[1m]' +]) + +export function resolveClaudeSessionModelMode(model?: string): SessionModelMode { + if (!model) { + return 'default' + } + + return CLAUDE_SESSION_MODEL_MODES.has(model as SessionModelMode) + ? model as SessionModelMode + : 'default' +} diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 796365b3..8730fc39 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -17,6 +17,7 @@ import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } f import { isModelModeAllowedForFlavor, isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { resolveClaudeSessionModelMode } from './modelMode'; export interface StartOptions { model?: string @@ -143,7 +144,7 @@ export async function runClaude(options: StartOptions = {}): Promise { // Forward messages to the queue let currentPermissionMode: PermissionMode = options.permissionMode ?? 'default'; - let currentModelMode: SessionModelMode = options.model === 'sonnet' || options.model === 'opus' ? options.model : 'default'; + let currentModelMode: SessionModelMode = resolveClaudeSessionModelMode(options.model); let currentFallbackModel: string | undefined = undefined; // Track current fallback model let currentCustomSystemPrompt: string | undefined = undefined; // Track current custom system prompt let currentAppendSystemPrompt: string | undefined = undefined; // Track current append system prompt diff --git a/shared/src/modes.ts b/shared/src/modes.ts index d870bb38..24817293 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -25,7 +25,7 @@ export const PERMISSION_MODES = [ ] as const export type PermissionMode = typeof PERMISSION_MODES[number] -export const MODEL_MODES = ['default', 'sonnet', 'opus'] as const +export const MODEL_MODES = ['default', 'sonnet', 'sonnet[1m]', 'opus', 'opus[1m]'] as const export type ModelMode = typeof MODEL_MODES[number] export type AgentFlavor = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' @@ -63,7 +63,13 @@ export type PermissionModeOption = { export const MODEL_MODE_LABELS: Record = { default: 'Default', sonnet: 'Sonnet', - opus: 'Opus' + 'sonnet[1m]': 'Sonnet 1M', + opus: 'Opus', + 'opus[1m]': 'Opus 1M' +} + +export function getModelModeLabel(mode: ModelMode): string { + return MODEL_MODE_LABELS[mode] } export function getPermissionModeLabel(mode: PermissionMode): string { diff --git a/web/README.md b/web/README.md index 199a0913..383eee1f 100644 --- a/web/README.md +++ b/web/README.md @@ -48,7 +48,7 @@ See `src/router.tsx` for route definitions. - Message thread with infinite scroll. - Composer for sending messages. - Permission mode toggle (default/acceptEdits/bypassPermissions/plan). -- Model selection (default/sonnet/opus). +- Model selection (default/sonnet/sonnet[1m]/opus/opus[1m]). - Session abort and mode switch controls. - Context size display. diff --git a/web/src/chat/modelConfig.test.ts b/web/src/chat/modelConfig.test.ts new file mode 100644 index 00000000..7392f5db --- /dev/null +++ b/web/src/chat/modelConfig.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { getContextBudgetTokens } from './modelConfig' + +describe('getContextBudgetTokens', () => { + it('uses the existing 200k budget for default Claude modes', () => { + expect(getContextBudgetTokens(undefined)).toBe(190_000) + expect(getContextBudgetTokens('default')).toBe(190_000) + expect(getContextBudgetTokens('sonnet')).toBe(190_000) + expect(getContextBudgetTokens('opus')).toBe(190_000) + }) + + it('uses the 1m budget for Claude 1m modes', () => { + expect(getContextBudgetTokens('sonnet[1m]')).toBe(990_000) + expect(getContextBudgetTokens('opus[1m]')).toBe(990_000) + }) +}) diff --git a/web/src/chat/modelConfig.ts b/web/src/chat/modelConfig.ts index 72099bde..4a54a0f9 100644 --- a/web/src/chat/modelConfig.ts +++ b/web/src/chat/modelConfig.ts @@ -13,10 +13,12 @@ import type { ModelMode } from '@/types/api' const CONTEXT_HEADROOM_TOKENS = 10_000 const MODEL_CONTEXT_WINDOWS: Record = { - // Claude Code modes used in this app; currently treated as ~200k context. + // Claude Code modes used in this app. 1M variants get the larger budget. default: 200_000, sonnet: 200_000, - opus: 200_000 + 'sonnet[1m]': 1_000_000, + opus: 200_000, + 'opus[1m]': 1_000_000 } export function getContextBudgetTokens(modelMode: ModelMode | undefined): number | null { diff --git a/web/src/components/NewSession/types.test.ts b/web/src/components/NewSession/types.test.ts new file mode 100644 index 00000000..61f7ae03 --- /dev/null +++ b/web/src/components/NewSession/types.test.ts @@ -0,0 +1,21 @@ +import { getModelModeLabel, MODEL_MODES } from '@hapi/protocol' +import { describe, expect, it } from 'vitest' +import { MODEL_OPTIONS } from './types' + +describe('Claude model options', () => { + it('includes 1m model options in the expected order', () => { + expect(MODEL_OPTIONS.claude).toEqual([ + { value: 'auto', label: 'Auto' }, + { value: 'opus', label: 'Opus' }, + { value: 'opus[1m]', label: 'Opus 1M' }, + { value: 'sonnet', label: 'Sonnet' }, + { value: 'sonnet[1m]', label: 'Sonnet 1M' }, + ]) + }) + + it('exposes friendly labels for session model modes', () => { + expect(MODEL_MODES).toEqual(['default', 'sonnet', 'sonnet[1m]', 'opus', 'opus[1m]']) + expect(getModelModeLabel('sonnet[1m]')).toBe('Sonnet 1M') + expect(getModelModeLabel('opus[1m]')).toBe('Opus 1M') + }) +}) diff --git a/web/src/components/NewSession/types.ts b/web/src/components/NewSession/types.ts index 84996fa0..71f308e4 100644 --- a/web/src/components/NewSession/types.ts +++ b/web/src/components/NewSession/types.ts @@ -5,7 +5,9 @@ export const MODEL_OPTIONS: Record getSessionTitle(session), [session]) const worktreeBranch = session.metadata?.worktree?.branch + const modelModeLabel = getModelModeLabel(session.modelMode ?? 'default') const [menuOpen, setMenuOpen] = useState(false) const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) @@ -139,7 +141,7 @@ export function SessionHeader(props: { {session.metadata?.flavor?.trim() || 'unknown'} - {t('session.item.modelMode')}: {session.modelMode || 'default'} + {t('session.item.modelMode')}: {modelModeLabel} {worktreeBranch ? ( {t('session.item.worktree')}: {worktreeBranch} diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 69c71c37..fc53062b 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -1,3 +1,4 @@ +import { getModelModeLabel } from '@hapi/protocol' import { useEffect, useMemo, useState } from 'react' import type { SessionSummary } from '@/types/api' import type { ApiClient } from '@/api/client' @@ -198,6 +199,7 @@ function SessionItem(props: { }) const sessionName = getSessionTitle(s) + const modelModeLabel = getModelModeLabel(s.modelMode ?? 'default') const statusDotClass = s.active ? (s.thinking ? 'bg-[#007AFF]' : 'bg-[var(--app-badge-success-text)]') : 'bg-[var(--app-hint)]' @@ -259,7 +261,7 @@ function SessionItem(props: { {getAgentLabel(s)} - {t('session.item.modelMode')}: {s.modelMode || 'default'} + {t('session.item.modelMode')}: {modelModeLabel} {s.metadata?.worktree?.branch ? ( {t('session.item.worktree')}: {s.metadata.worktree.branch} ) : null}