diff --git a/src/api/services/profile-lifecycle-validation.ts b/src/api/services/profile-lifecycle-validation.ts index d7902584..6897d113 100644 --- a/src/api/services/profile-lifecycle-validation.ts +++ b/src/api/services/profile-lifecycle-validation.ts @@ -10,22 +10,16 @@ import { } from '../../cliproxy/ai-providers/model-id-normalizer'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import type { CLIProxyProvider } from '../../cliproxy/types'; +import { ANTHROPIC_MODEL_ENV_KEYS } from '../../shared/extended-context-utils'; import type { ProfileValidationIssue, ProfileValidationSummary } from './profile-types'; -const MODEL_ENV_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', -] as const; - const ALLOWED_ANTHROPIC_ENV_KEYS = new Set([ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', // Written by profile-writer when --extra-models is supplied 'ANTHROPIC_EXTRA_MODELS', - ...MODEL_ENV_KEYS, + ...ANTHROPIC_MODEL_ENV_KEYS, ]); function resolveProviderFromBaseUrl(baseUrl: string): CLIProxyProvider | null { @@ -118,7 +112,7 @@ export function validateApiProfileSettingsPayload(settings: unknown): ProfileVal } const provider = resolveProviderFromBaseUrl(baseUrl); - for (const modelKey of MODEL_ENV_KEYS) { + for (const modelKey of ANTHROPIC_MODEL_ENV_KEYS) { const value = envObj[modelKey]; if (typeof value !== 'string' || value.trim().length === 0) continue; const denyReason = getDeniedModelIdReasonForProvider(value, provider); diff --git a/src/cliproxy/executor/__tests__/launch-settings.test.ts b/src/cliproxy/executor/__tests__/launch-settings.test.ts index b08d27bc..6080ab27 100644 --- a/src/cliproxy/executor/__tests__/launch-settings.test.ts +++ b/src/cliproxy/executor/__tests__/launch-settings.test.ts @@ -37,6 +37,7 @@ describe('buildLaunchSettingsOverlay', () => { env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', ANTHROPIC_MODEL: 'gpt-5.5', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.4-mini', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', }, }); @@ -44,12 +45,14 @@ describe('buildLaunchSettingsOverlay', () => { const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex', ANTHROPIC_MODEL: 'gpt-5.5-high', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.4-mini-medium', } as NodeJS.ProcessEnv); expect(changed).toBe(true); const env = settings.env as Record; expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:50118/api/provider/codex'); expect(env.ANTHROPIC_MODEL).toBe('gpt-5.5-high'); + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('gpt-5.4-mini-medium'); // Untouched keys are preserved. expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); }); diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 68a9f1f6..b68b10af 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -18,6 +18,7 @@ import { import SharedManager from '../management/shared-manager'; import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade'; +import { ANTHROPIC_MODEL_ENV_KEYS as SHARED_ANTHROPIC_MODEL_ENV_KEYS } from '../shared/extended-context-utils'; /** * Strip ANTHROPIC_* env vars from an environment object. @@ -40,17 +41,13 @@ export const ANTHROPIC_ROUTING_ENV_KEYS = [ 'ANTHROPIC_API_KEY', ]; const ANTHROPIC_ROUTING_ENV_KEY_SET = new Set(ANTHROPIC_ROUTING_ENV_KEYS); -// NOTE: This is the intentional routing-overlay SUPERSET of model env keys -// (includes ANTHROPIC_SMALL_FAST_MODEL). A separate 4-key `ANTHROPIC_MODEL_ENV_KEYS` -// exists in src/shared/extended-context-utils.ts (re-exported as MODEL_ENV_VAR_KEYS). -// The two are NOT interchangeable — import deliberately by purpose. See issue #1609. +// NOTE: This is the intentional routing-overlay SUPERSET of shared model env keys +// (includes legacy ANTHROPIC_SMALL_FAST_MODEL). Keep the shared list authoritative +// for current tier mappings while preserving the legacy runtime alias. See issue #1609. export const ANTHROPIC_MODEL_ENV_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + ...SHARED_ANTHROPIC_MODEL_ENV_KEYS, 'ANTHROPIC_SMALL_FAST_MODEL', -]; +] as const; const TMUX_SYNC_ENV_KEYS = [ 'CLAUDE_CONFIG_DIR', 'CCS_PROFILE_TYPE', diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts index 5d984973..4d124e2b 100644 --- a/tests/unit/api/profile-lifecycle-service.test.ts +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -9,6 +9,7 @@ import { importApiProfileBundle, registerApiProfileOrphans, } from '../../../src/api/services/profile-lifecycle-service'; +import { validateApiProfileSettingsPayload } from '../../../src/api/services/profile-lifecycle-validation'; import { createApiProfile } from '../../../src/api/services/profile-writer'; import { loadConfigSafe, @@ -69,6 +70,38 @@ describe('profile lifecycle service', () => { } }); + it('accepts ANTHROPIC_DEFAULT_FABLE_MODEL as a supported model mapping', () => { + const validation = validateApiProfileSettingsPayload({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'token', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.4-mini', + }, + }); + + expect(validation.valid).toBe(true); + expect(validation.issues).toEqual([]); + }); + + it('enforces the provider denylist for ANTHROPIC_DEFAULT_FABLE_MODEL', () => { + const validation = validateApiProfileSettingsPayload({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'token', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-sonnet-4.5', + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.issues).toContainEqual( + expect.objectContaining({ + level: 'error', + code: 'model_denylisted', + field: 'env.ANTHROPIC_DEFAULT_FABLE_MODEL', + }) + ); + }); + it('discovers grandfathered xai/grok orphans while skipping other reserved names', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index ddb3ab71..5187e094 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -264,6 +264,7 @@ describe('CLAUDECODE environment stripping', () => { delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL; delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL; delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL; + delete process.env.ANTHROPIC_DEFAULT_FABLE_MODEL; delete process.env.ANTHROPIC_SMALL_FAST_MODEL; delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS; @@ -543,6 +544,7 @@ describe('CLAUDECODE environment stripping', () => { process.env.ANTHROPIC_API_KEY = 'parent-api-key'; process.env.ANTHROPIC_MODEL = 'gpt-5.4'; process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4'; + process.env.ANTHROPIC_DEFAULT_FABLE_MODEL = 'gpt-5.4-mini'; execClaude('claude', ['--help'], { CCS_PROFILE_TYPE: 'settings', @@ -570,10 +572,18 @@ describe('CLAUDECODE environment stripping', () => { call.args[1] === 'ANTHROPIC_MODEL' && call.args[2] === 'gpt-5.4' ); + const fableModelCall = spawnSyncCalls.find( + (call) => + call.command === 'tmux' && + call.args[0] === 'setenv' && + call.args[1] === 'ANTHROPIC_DEFAULT_FABLE_MODEL' && + call.args[2] === 'gpt-5.4-mini' + ); expect(unsetBaseUrlCall).toBeDefined(); expect(unsetAuthTokenCall).toBeDefined(); expect(modelCall).toBeDefined(); + expect(fableModelCall).toBeDefined(); }); it('headless executor spawn path strips CLAUDECODE before spawn', async () => {