Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 35 additions & 11 deletions src/utils/model/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
}
}
Expand All @@ -799,7 +818,7 @@ export function parseUserSpecifiedModel(
isLegacyOpusFirstParty(modelString) &&
isLegacyModelRemapEnabled()
) {
return getDefaultOpusModel() + (has1mTag ? '[1m]' : '')
return applyOneMTag(getDefaultOpusModel())
}

if (process.env.USER_TYPE === 'ant') {
Expand All @@ -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
}
Expand Down
143 changes: 143 additions & 0 deletions src/utils/model/parseUserSpecifiedModel.disable1m.test.ts
Original file line number Diff line number Diff line change
@@ -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',
)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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]')
})
})