fix(model): resolve [1m]-tagged aliases when 1M context is disabled (#1822)

* fix(model): resolve [1m]-tagged aliases when 1M context is disabled

parseUserSpecifiedModel gated stripping of the [1m] tag on has1mContext,
which returns false when CLAUDE_CODE_DISABLE_1M_CONTEXT is set (the
C4E/HIPAA admin control). With the flag on, an aliased request like
'sonnet[1m]' kept the tag attached, never matched the 'sonnet' alias, and
returned the literal unservable string 'sonnet[1m]' — so disabling 1M
broke [1m]-aliased model selection entirely instead of gracefully serving
the base model.

Separate 'tag is present in the input' (always strip before matching) from
'1M is active' (re-append the suffix). When 1M is disabled, the tag is
dropped and the alias/custom id resolves to its base model. Covers the
alias and custom-model paths; the ant path already stripped unconditionally.

Adds regression coverage for both the disabled (tag dropped, base resolved)
and enabled (tag preserved) directions.

* fix(model): drop disabled [1m] from custom default alias targets

The alias branches resolved to getDefaultSonnetModel()/getDefaultOpusModel()
etc. and appended the tag, but a custom default override such as
ANTHROPIC_DEFAULT_SONNET_MODEL=MySonnetDeploy[1m] bakes the suffix into the
resolved value. With CLAUDE_CODE_DISABLE_1M_CONTEXT=1 a request like
`sonnet[1m]` still resolved to `MySonnetDeploy[1m]`, leaving the disabled tag
on (and a tagged input doubled it to `...[1m][1m]`).

Route the alias- and legacy-opus-resolved defaults through a small helper that
strips whatever [1m] is present and re-attaches it only when a tag was
requested (input or resolved default) AND 1M context is enabled. Disabled 1M
now drops the tag regardless of source; enabled still honors an env default's
opt-in without duplicating it. Custom deployment casing is preserved.

Adds coverage for default-model env overrides (disabled + enabled directions)
and a mixed-case custom id.

* test(model): cover disabled-1M mixed-case custom ids and Codex aliases

The disabled-1M block covered the Claude-family aliases and a lowercase custom
id, but not two paths flagged in review: a mixed-case custom deployment id
(must drop [1m]/[1M] while preserving casing) and the Codex aliases, which are
resolved by a separate branch from the Claude-family aliases and so need their
own disabled-1M assertions (codexplan[1m] / codexspark[1M] drop the tag).
This commit is contained in:
0xfandom
2026-07-03 07:59:05 +08:00
committed by GitHub
parent ac2b575b6e
commit e6019d3797
2 changed files with 178 additions and 11 deletions
+35 -11
View File
@@ -756,23 +756,42 @@ export function parseUserSpecifiedModel(
}
const normalizedModel = modelInputTrimmed.toLowerCase()
// Separate "the [1m] tag is present in the input" from "1M context is active".
// The tag must ALWAYS be stripped before alias/model matching, otherwise an
// aliased request like `sonnet[1m]` fails to resolve to its base model. Whether
// to re-append the tag depends on has1mContext, which returns false when 1M is
// disabled (CLAUDE_CODE_DISABLE_1M_CONTEXT) — in that case the request resolves
// to the base model with the tag dropped, not left as an unresolved alias.
const hasTagSyntax = /\[1m]$/i.test(normalizedModel)
const has1mTag = has1mContext(normalizedModel)
const modelString = has1mTag
const modelString = hasTagSyntax
? normalizedModel.replace(/\[1m]$/i, '').trim()
: normalizedModel
// Re-apply the [1m] tag policy to a resolved model. The resolved value may
// itself carry a [1m] suffix — e.g. a custom default override like
// ANTHROPIC_DEFAULT_SONNET_MODEL=Deploy[1m] baked into getDefaultSonnetModel().
// Strip whatever tag is present, then re-attach [1m] only when a tag was
// requested (on the user input OR the resolved default) AND 1M context is
// enabled. This guarantees CLAUDE_CODE_DISABLE_1M_CONTEXT drops the tag no
// matter where it came from, while still honoring an env default's opt-in.
const applyOneMTag = (resolved: ModelName): ModelName => {
const base = resolved.replace(/\[1m]$/i, '').trim()
return has1mTag || has1mContext(resolved) ? base + '[1m]' : base
}
if (isModelAlias(modelString)) {
switch (modelString) {
case 'opusplan':
return getDefaultSonnetModel() + (has1mTag ? '[1m]' : '') // Sonnet is default, Opus in plan mode
return applyOneMTag(getDefaultSonnetModel()) // Sonnet is default, Opus in plan mode
case 'sonnet':
return getDefaultSonnetModel() + (has1mTag ? '[1m]' : '')
return applyOneMTag(getDefaultSonnetModel())
case 'haiku':
return getDefaultHaikuModel() + (has1mTag ? '[1m]' : '')
return applyOneMTag(getDefaultHaikuModel())
case 'opus':
return getDefaultOpusModel() + (has1mTag ? '[1m]' : '')
return applyOneMTag(getDefaultOpusModel())
case 'best':
return getBestModel() + (has1mTag ? '[1m]' : '')
return applyOneMTag(getBestModel())
default:
}
}
@@ -799,7 +818,7 @@ export function parseUserSpecifiedModel(
isLegacyOpusFirstParty(modelString) &&
isLegacyModelRemapEnabled()
) {
return getDefaultOpusModel() + (has1mTag ? '[1m]' : '')
return applyOneMTag(getDefaultOpusModel())
}
if (process.env.USER_TYPE === 'ant') {
@@ -817,10 +836,15 @@ export function parseUserSpecifiedModel(
// can tell the user to restart/wait for flag cache refresh to get the latest values.
}
// Preserve original case for custom model names (e.g., Azure Foundry deployment IDs)
// Only strip [1m] suffix if present, maintaining case of the base model
if (has1mTag) {
return modelInputTrimmed.replace(/\[1m\]$/i, '').trim() + '[1m]'
// Preserve original case for custom model names (e.g., Azure Foundry deployment IDs).
// Strip a present [1m] suffix (maintaining base-model case) and re-append it
// only when 1M is active — when disabled, a custom `mydeploy[1m]` must resolve
// to the base `mydeploy`, not an unservable `mydeploy[1m]` model id.
if (hasTagSyntax) {
return (
modelInputTrimmed.replace(/\[1m\]$/i, '').trim() +
(has1mTag ? '[1m]' : '')
)
}
return modelInputTrimmed
}
@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { parseUserSpecifiedModel } from './model.js'
// Regression: when 1M context is disabled (CLAUDE_CODE_DISABLE_1M_CONTEXT, used
// by C4E/HIPAA admins), `has1mContext` returns false. The parser gated the
// stripping of the `[1m]` tag on that flag, so an aliased request like
// `sonnet[1m]` kept the tag attached, never matched the `sonnet` alias, and
// returned the literal, unservable string `sonnet[1m]`. The tag must be stripped
// for matching regardless; only the re-appended suffix depends on 1M being on.
describe('parseUserSpecifiedModel — [1m] tag when 1M context is disabled', () => {
const original = process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT
beforeEach(() => {
process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1'
})
afterEach(() => {
if (original === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT
} else {
process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = original
}
})
for (const alias of ['sonnet', 'opus', 'haiku', 'best', 'opusplan']) {
test(`${alias}[1m] resolves to the base model (no [1m]) when 1M is disabled`, () => {
const base = parseUserSpecifiedModel(alias)
const tagged = parseUserSpecifiedModel(`${alias}[1m]`)
// Base model is returned, with the 1M tag dropped — not a literal alias.
expect(tagged).toBe(base)
expect(tagged.endsWith('[1m]')).toBe(false)
expect(tagged).not.toBe(`${alias}[1m]`)
})
}
test('case-insensitive tag is also resolved (SONNET[1M] → base sonnet)', () => {
expect(parseUserSpecifiedModel('SONNET[1M]')).toBe(
parseUserSpecifiedModel('sonnet'),
)
})
test('custom model id drops the [1m] suffix when 1M is disabled', () => {
expect(parseUserSpecifiedModel('my-custom-deploy[1m]')).toBe(
'my-custom-deploy',
)
})
test('mixed-case custom id drops [1m]/[1M] but preserves casing when disabled', () => {
expect(parseUserSpecifiedModel('MyCustomDeploy[1M]')).toBe('MyCustomDeploy')
expect(parseUserSpecifiedModel('MyCustomDeploy[1m]')).toBe('MyCustomDeploy')
})
// Codex aliases are resolved by a separate branch from the Claude-family
// aliases, so they need their own disabled-1M coverage.
test('codex aliases drop the [1m] tag when 1M is disabled', () => {
const codexplan = parseUserSpecifiedModel('codexplan')
const codexspark = parseUserSpecifiedModel('codexspark')
expect(parseUserSpecifiedModel('codexplan[1m]')).toBe(codexplan)
expect(parseUserSpecifiedModel('codexspark[1M]')).toBe(codexspark)
expect(parseUserSpecifiedModel('codexplan[1m]').endsWith('[1m]')).toBe(false)
expect(parseUserSpecifiedModel('codexspark[1M]').endsWith('[1m]')).toBe(
false,
)
})
})
// Guard the opposite direction: with 1M enabled (default), the tag is preserved.
describe('parseUserSpecifiedModel — [1m] tag when 1M context is enabled', () => {
const original = process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT
beforeEach(() => {
delete process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT
})
afterEach(() => {
if (original === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT
} else {
process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = original
}
})
test('sonnet[1m] keeps the tag on the resolved base model', () => {
const base = parseUserSpecifiedModel('sonnet')
expect(parseUserSpecifiedModel('sonnet[1m]')).toBe(`${base}[1m]`)
})
test('custom model id keeps the [1m] suffix', () => {
expect(parseUserSpecifiedModel('my-custom-deploy[1m]')).toBe(
'my-custom-deploy[1m]',
)
})
// Mixed-case custom deployment ids keep their casing; only the [1m]/[1M] tag
// is normalized/reattached.
test('mixed-case custom id preserves casing, tag normalized to [1m]', () => {
expect(parseUserSpecifiedModel('MyCustomDeploy[1M]')).toBe(
'MyCustomDeploy[1m]',
)
expect(parseUserSpecifiedModel('MyCustomDeploy[1m]')).toBe(
'MyCustomDeploy[1m]',
)
})
})
// Custom default-model env overrides (ANTHROPIC_DEFAULT_SONNET_MODEL etc.) can
// bake a [1m] suffix into the resolved alias target. The disabled-1M path must
// strip that too, and the enabled path must honor it without duplicating it.
describe('parseUserSpecifiedModel — [1m] on custom default env overrides', () => {
const KEYS = [
'CLAUDE_CODE_DISABLE_1M_CONTEXT',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
] as const
const saved: Record<string, string | undefined> = {}
beforeEach(() => {
for (const k of KEYS) saved[k] = process.env[k]
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'MySonnetDeploy[1m]'
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'MyOpusDeploy[1M]'
})
afterEach(() => {
for (const k of KEYS) {
if (saved[k] === undefined) delete process.env[k]
else process.env[k] = saved[k]
}
})
test('disabled: alias drops the baked [1m] from the resolved default', () => {
process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1'
// Casing of the custom deployment id is preserved; only the tag is dropped.
expect(parseUserSpecifiedModel('sonnet[1m]')).toBe('MySonnetDeploy')
expect(parseUserSpecifiedModel('sonnet')).toBe('MySonnetDeploy')
expect(parseUserSpecifiedModel('opus[1M]')).toBe('MyOpusDeploy')
})
test('enabled: baked [1m] honored and never duplicated', () => {
delete process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT
// Bare alias honors the env default's opt-in; tag normalized to [1m].
expect(parseUserSpecifiedModel('sonnet')).toBe('MySonnetDeploy[1m]')
// Tagged alias does not double the suffix.
expect(parseUserSpecifiedModel('sonnet[1m]')).toBe('MySonnetDeploy[1m]')
expect(parseUserSpecifiedModel('opus')).toBe('MyOpusDeploy[1m]')
})
})