From cee62bc654241d044e0bb20caca75c1ed2e2c3c8 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Tue, 21 Apr 2026 16:10:16 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20model=20alias=20?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E6=97=A0=E9=99=90=E9=80=92=E5=BD=92=E6=A0=88?= =?UTF-8?q?=E6=BA=A2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当用户 settings 中配置 model = "opus[1m]" 等 alias 值时, getDefaultOpusModel() → parseUserSpecifiedModel() → getDefaultOpusModel() 形成无限递归,导致启动时 RangeError: Maximum call stack size exceeded。 在 getDefaultOpusModel/Sonnet/Haiku 的 fallback 路径中增加 isAliasOrAliasWithSuffix 守卫,跳过 alias 值直接使用硬编码默认值。 Co-Authored-By: Claude Opus 4.6 --- .../__tests__/model-alias-recursion.test.ts | 78 +++++++++++++++++++ src/utils/model/model.ts | 22 +++++- 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 src/utils/model/__tests__/model-alias-recursion.test.ts diff --git a/src/utils/model/__tests__/model-alias-recursion.test.ts b/src/utils/model/__tests__/model-alias-recursion.test.ts new file mode 100644 index 000000000..300068529 --- /dev/null +++ b/src/utils/model/__tests__/model-alias-recursion.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; +import { isModelAlias } from "../aliases"; + +/** + * Replicate the guard used in getDefault*Model to verify it catches + * all alias forms that would cause recursion. + */ +function isAliasOrAliasWithSuffix(value: string): boolean { + const base = value.replace(/\[1m\]$/i, "").trim(); + return isModelAlias(base); +} + +describe("isAliasOrAliasWithSuffix", () => { + test("detects bare 'opus' alias", () => { + expect(isAliasOrAliasWithSuffix("opus")).toBe(true); + }); + + test("detects 'opus[1m]' alias", () => { + expect(isAliasOrAliasWithSuffix("opus[1m]")).toBe(true); + }); + + test("detects 'sonnet' alias", () => { + expect(isAliasOrAliasWithSuffix("sonnet")).toBe(true); + }); + + test("detects 'sonnet[1m]' alias", () => { + expect(isAliasOrAliasWithSuffix("sonnet[1m]")).toBe(true); + }); + + test("detects 'haiku' alias", () => { + expect(isAliasOrAliasWithSuffix("haiku")).toBe(true); + }); + + test("detects 'haiku[1m]' alias", () => { + expect(isAliasOrAliasWithSuffix("haiku[1m]")).toBe(true); + }); + + test("detects 'opusplan' alias", () => { + expect(isAliasOrAliasWithSuffix("opusplan")).toBe(true); + }); + + test("detects 'best' alias", () => { + expect(isAliasOrAliasWithSuffix("best")).toBe(true); + }); + + test("passes through concrete model IDs", () => { + expect(isAliasOrAliasWithSuffix("claude-opus-4-6")).toBe(false); + expect(isAliasOrAliasWithSuffix("claude-sonnet-4-6")).toBe(false); + expect(isAliasOrAliasWithSuffix("claude-haiku-4-5-20251001")).toBe(false); + }); + + test("passes through concrete model IDs with [1m] suffix", () => { + expect(isAliasOrAliasWithSuffix("claude-opus-4-6[1m]")).toBe(false); + expect(isAliasOrAliasWithSuffix("claude-sonnet-4-6[1m]")).toBe(false); + }); + + test("passes through 3P provider model IDs", () => { + expect( + isAliasOrAliasWithSuffix("us.anthropic.claude-opus-4-6-v1:0"), + ).toBe(false); + expect(isAliasOrAliasWithSuffix("claude-opus-4-6@20251001")).toBe(false); + }); + + test("passes through arbitrary custom model names", () => { + expect(isAliasOrAliasWithSuffix("my-custom-model")).toBe(false); + expect(isAliasOrAliasWithSuffix("gpt-4o")).toBe(false); + }); + + test("handles whitespace around alias", () => { + expect(isAliasOrAliasWithSuffix(" opus ")).toBe(true); + expect(isAliasOrAliasWithSuffix(" opus[1m] ")).toBe(true); + }); + + test("handles case insensitivity of [1m] suffix", () => { + expect(isAliasOrAliasWithSuffix("opus[1M]")).toBe(true); + expect(isAliasOrAliasWithSuffix("sonnet[1M]")).toBe(true); + }); +}); diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index a7e93098a..7bf8b3939 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -28,6 +28,18 @@ import { getAPIProvider } from './providers.js' import { LIGHTNING_BOLT } from '../../constants/figures.js' import { isModelAllowed } from './modelAllowlist.js' import { type ModelAlias, isModelAlias } from './aliases.js' + +/** + * Returns true if the value is a model alias or a model alias with a suffix + * like [1m] (e.g. "opus", "opus[1m]", "sonnet", "haiku[1m]"). + * Used to guard against infinite recursion when getDefault*Model() falls back + * to the user-specified setting — an alias like "opus[1m]" would cause + * parseUserSpecifiedModel → getDefaultOpusModel → parseUserSpecifiedModel loop. + */ +function isAliasOrAliasWithSuffix(value: string): boolean { + const base = value.replace(/\[1m\]$/i, '').trim() + return isModelAlias(base) +} import { capitalize } from '../stringUtils.js' export type ModelShortName = string @@ -128,8 +140,10 @@ export function getDefaultOpusModel(): ModelName { } // Fall back to user's configured model — custom providers may not // recognize hardcoded Anthropic model IDs. + // Skip if the user setting is a model alias (e.g. "opus", "opus[1m]") to + // avoid infinite recursion: parseUserSpecifiedModel(alias) → getDefaultOpusModel(). const userSpecifiedOpus = getUserSpecifiedModelSetting() - if (userSpecifiedOpus) { + if (userSpecifiedOpus && !isAliasOrAliasWithSuffix(userSpecifiedOpus)) { return parseUserSpecifiedModel(userSpecifiedOpus) } // 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch @@ -162,8 +176,9 @@ export function getDefaultSonnetModel(): ModelName { // Fall back to user's configured model (ANTHROPIC_MODEL / settings) — // custom providers (proxies, national clouds) may not recognize the // hardcoded Anthropic model IDs. + // Skip if the user setting is a model alias to avoid infinite recursion. const userSpecified = getUserSpecifiedModelSetting() - if (userSpecified) { + if (userSpecified && !isAliasOrAliasWithSuffix(userSpecified)) { return parseUserSpecifiedModel(userSpecified) } // Default to Sonnet 4.5 for 3P since they may not have 4.6 yet @@ -190,8 +205,9 @@ export function getDefaultHaikuModel(): ModelName { } // Fall back to user's configured model — custom providers may not // recognize hardcoded Anthropic model IDs. + // Skip if the user setting is a model alias to avoid infinite recursion. const userSpecifiedHaiku = getUserSpecifiedModelSetting() - if (userSpecifiedHaiku) { + if (userSpecifiedHaiku && !isAliasOrAliasWithSuffix(userSpecifiedHaiku)) { return parseUserSpecifiedModel(userSpecifiedHaiku) }