From f63e2cd5a31a73c980d199b65eb3a320094c46ae Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 3 Mar 2026 03:10:21 +0700 Subject: [PATCH] fix(cliproxy): denylist deprecated agy 4.5 models --- src/api/services/profile-writer.ts | 39 ++++++ src/cliproxy/catalog-cache.ts | 17 ++- src/cliproxy/composite-validator.ts | 14 +++ src/cliproxy/config/env-builder.ts | 17 ++- src/cliproxy/config/generator.ts | 9 +- src/cliproxy/model-id-normalizer.ts | 59 ++++++--- src/cliproxy/services/variant-service.ts | 17 +++ src/cliproxy/tool-sanitization-proxy.ts | 15 ++- .../routes/cliproxy-stats-routes.ts | 7 ++ src/web-server/routes/profile-routes.ts | 10 +- src/web-server/routes/route-helpers.ts | 62 ++++++++++ src/web-server/routes/settings-routes.ts | 115 +++++++++++++++++- .../unit/api/profile-writer-denylist.test.ts | 48 ++++++++ .../unit/cliproxy/composite-validator.test.ts | 15 ++- .../composite-variant-service.test.ts | 36 +++--- tests/unit/cliproxy/config-generator.test.js | 9 +- .../cliproxy/env-builder-provider-url.test.ts | 48 +++++++- .../unit/cliproxy/model-id-normalizer.test.ts | 33 ++++- ...ool-sanitization-proxy-integration.test.ts | 25 ++++ .../cliproxy/variant-update-service.test.ts | 9 ++ ...cliproxy-stats-routes-model-update.test.ts | 27 ++++ .../route-helpers-model-denylist.test.ts | 74 +++++++++++ ...ings-routes-model-canonicalization.test.ts | 82 +++++++++++++ .../components/cliproxy/cliproxy-dialog.tsx | 22 ++++ .../cliproxy/cliproxy-edit-dialog.tsx | 25 ++++ .../cliproxy/provider-editor/index.tsx | 58 ++++++++- .../setup/wizard/steps/variant-step.tsx | 20 ++- ui/src/lib/i18n.ts | 4 +- ui/src/lib/utils.ts | 11 +- 29 files changed, 850 insertions(+), 77 deletions(-) create mode 100644 tests/unit/api/profile-writer-denylist.test.ts create mode 100644 tests/unit/web-server/route-helpers-model-denylist.test.ts diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 6e21c2e4..1b9ae1b3 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -14,6 +14,12 @@ import { import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; import type { TargetType } from '../../targets/target-adapter'; import { resolveDroidProvider } from '../../targets/droid-provider'; +import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import { + extractProviderFromPathname, + getDeniedModelIdReasonForProvider, +} from '../../cliproxy/model-id-normalizer'; +import type { CLIProxyProvider } from '../../cliproxy/types'; import type { ModelMapping, CreateApiProfileResult, @@ -26,6 +32,34 @@ function isOpenRouterUrl(baseUrl: string): boolean { return baseUrl.toLowerCase().includes('openrouter.ai'); } +function resolveProviderFromBaseUrl(baseUrl: string): CLIProxyProvider | null { + if (baseUrl.trim().length === 0) { + return null; + } + + try { + const parsed = new URL(baseUrl); + const extracted = extractProviderFromPathname(parsed.pathname); + return extracted ? mapExternalProviderName(extracted) : null; + } catch { + const extracted = extractProviderFromPathname(baseUrl); + return extracted ? mapExternalProviderName(extracted) : null; + } +} + +function getDeniedModelReason(baseUrl: string, models: ModelMapping): string | null { + const provider = resolveProviderFromBaseUrl(baseUrl); + if (!provider) return null; + + for (const modelId of [models.default, models.opus, models.sonnet, models.haiku]) { + if (modelId.trim().length === 0) continue; + const deniedReason = getDeniedModelIdReasonForProvider(modelId, provider); + if (deniedReason) return deniedReason; + } + + return null; +} + /** Create settings.json file for API profile (legacy format) */ function createSettingsFile( name: string, @@ -159,6 +193,11 @@ export function createApiProfile( provider?: string ): CreateApiProfileResult { try { + const deniedReason = getDeniedModelReason(baseUrl, models); + if (deniedReason) { + return { success: false, settingsFile: '', error: deniedReason }; + } + const settingsFile = `~/.ccs/${name}.settings.json`; if (isUnifiedMode()) { diff --git a/src/cliproxy/catalog-cache.ts b/src/cliproxy/catalog-cache.ts index 948f0b75..13038f2c 100644 --- a/src/cliproxy/catalog-cache.ts +++ b/src/cliproxy/catalog-cache.ts @@ -5,6 +5,7 @@ import type { CLIProxyProvider } from './types'; import type { ModelEntry, ProviderCatalog, ThinkingSupport } from './model-catalog'; import { MODEL_CATALOG } from './model-catalog'; import type { RemoteModelInfo, RemoteThinkingSupport } from './management-api-types'; +import { getDeniedModelIdReasonForProvider } from './model-id-normalizer'; const CACHE_FILE_NAME = 'model-catalog-cache.json'; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -143,11 +144,18 @@ export function mergeCatalog( provider: CLIProxyProvider, remoteModels: RemoteModelInfo[] ): ProviderCatalog | undefined { + const filteredRemoteModels = + provider === 'agy' + ? remoteModels.filter( + (remoteModel) => !getDeniedModelIdReasonForProvider(remoteModel.id, provider) + ) + : remoteModels; + const staticCatalog = MODEL_CATALOG[provider]; - if (!staticCatalog && remoteModels.length === 0) return undefined; + if (!staticCatalog && filteredRemoteModels.length === 0) return undefined; const displayName = staticCatalog?.displayName || provider; - const defaultModel = staticCatalog?.defaultModel || (remoteModels[0]?.id ?? ''); + const defaultModel = staticCatalog?.defaultModel || (filteredRemoteModels[0]?.id ?? ''); // Build map of static models by lowercase ID for fast lookup const staticMap = new Map(); @@ -161,7 +169,7 @@ export function mergeCatalog( const mergedIds = new Set(); const mergedModels: ModelEntry[] = []; - for (const remote of remoteModels) { + for (const remote of filteredRemoteModels) { const remoteEntry = mapRemoteToModelEntry(remote); const staticEntry = staticMap.get(remote.id.toLowerCase()); mergedIds.add(remote.id.toLowerCase()); @@ -185,6 +193,9 @@ export function mergeCatalog( // Add static-only models not in remote if (staticCatalog) { for (const model of staticCatalog.models) { + if (getDeniedModelIdReasonForProvider(model.id, provider)) { + continue; + } if (!mergedIds.has(model.id.toLowerCase())) { mergedModels.push(model); } diff --git a/src/cliproxy/composite-validator.ts b/src/cliproxy/composite-validator.ts index 981b58e0..52b24f3b 100644 --- a/src/cliproxy/composite-validator.ts +++ b/src/cliproxy/composite-validator.ts @@ -5,6 +5,7 @@ import { CLIPROXY_SUPPORTED_PROVIDERS, CompositeTierConfig } from '../config/unified-config-types'; import type { CLIProxyProvider } from './types'; +import { getDeniedModelIdReasonForProvider } from './model-id-normalizer'; export const VALID_COMPOSITE_TIERS = ['opus', 'sonnet', 'haiku'] as const; export type CompositeTierName = (typeof VALID_COMPOSITE_TIERS)[number]; @@ -90,6 +91,11 @@ export function validateCompositeTiers( return `Invalid provider '${provider}' for tier '${tier}': must be one of ${CLIPROXY_SUPPORTED_PROVIDERS.join(', ')}`; } + const deniedTierModelReason = getDeniedModelIdReasonForProvider(model, provider); + if (deniedTierModelReason) { + return `Invalid model for tier '${tier}': ${deniedTierModelReason}`; + } + if (tierValue.fallback !== undefined) { const fallback = tierValue.fallback; if (!isRecord(fallback)) { @@ -108,6 +114,14 @@ export function validateCompositeTiers( return `Invalid fallback provider '${fallback.provider}' for tier '${tier}': must be one of ${CLIPROXY_SUPPORTED_PROVIDERS.join(', ')}`; } + const deniedFallbackReason = getDeniedModelIdReasonForProvider( + fallback.model, + fallback.provider + ); + if (deniedFallbackReason) { + return `Invalid fallback model for tier '${tier}': ${deniedFallbackReason}`; + } + if (fallback.provider === provider && fallback.model === model) { return `Circular fallback in tier '${tier}': fallback cannot point to same provider and model`; } diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index cca3b409..fb89ef34 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -23,6 +23,7 @@ import { getProviderSettingsPath } from './path-resolver'; import { canonicalizeModelIdForProvider, MODEL_ENV_VAR_KEYS, + migrateDeniedAntigravityModelAliases, normalizeModelEnvVarsForProvider, normalizeModelIdForProvider, } from '../model-id-normalizer'; @@ -79,6 +80,9 @@ function migrateDeprecatedModelNames( canonical = UPSTREAM_MODEL_PREFIX + canonical.slice(DEPRECATED_MODEL_PREFIX.length); } canonical = normalizeModelIdForProvider(canonical, provider); + if (provider === 'agy') { + canonical = migrateDeniedAntigravityModelAliases(canonical); + } if (canonical !== value) { settings.env[key] = canonical; @@ -94,7 +98,8 @@ function migrateDeprecatedModelNames( for (const key of PRESET_MODEL_KEYS) { const value = presetRecord[key]; if (typeof value !== 'string') continue; - const canonical = normalizeModelIdForProvider(value, provider); + let canonical = normalizeModelIdForProvider(value, provider); + canonical = migrateDeniedAntigravityModelAliases(canonical); if (canonical !== value) { presetRecord[key] = canonical; migrated = true; @@ -564,7 +569,10 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { for (const key of MODEL_ENV_VAR_KEYS) { const current = mergedEnv[key]; if (typeof current !== 'string' || current.trim().length === 0) continue; - const canonical = canonicalizeModelIdForProvider(current, provider); + let canonical = canonicalizeModelIdForProvider(current, provider); + if (provider === 'agy') { + canonical = migrateDeniedAntigravityModelAliases(canonical); + } if (canonical !== current) { mergedEnv[key] = canonical; mutated = true; @@ -579,7 +587,10 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { for (const key of PRESET_MODEL_KEYS) { const value = presetRecord[key]; if (typeof value !== 'string') continue; - const canonical = canonicalizeModelIdForProvider(value, provider); + let canonical = canonicalizeModelIdForProvider(value, provider); + if (provider === 'agy') { + canonical = migrateDeniedAntigravityModelAliases(canonical); + } if (canonical !== value) { presetRecord[key] = canonical; mutated = true; diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index b6f9a8e9..3a7729e0 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -11,6 +11,7 @@ import { getModelMappingFromConfig } from '../base-config-loader'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager'; import { getCachedCatalog } from '../catalog-cache'; +import { getDeniedModelIdReasonForProvider } from '../model-id-normalizer'; import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-resolver'; import { CLIPROXY_DEFAULT_PORT } from './port-manager'; @@ -33,8 +34,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v9: Added resilient alias compatibility expansion and cache-assisted alias enrichment * v10: Migrated deprecated gemini-claude-* aliases to upstream claude-* aliases * v11: Migrated deprecated claude-sonnet-4-6-thinking aliases to claude-sonnet-4-6 + * v12: Removed denylisted Antigravity Claude 4.5 aliases */ -export const CLIPROXY_CONFIG_VERSION = 11; +export const CLIPROXY_CONFIG_VERSION = 12; interface OAuthModelAliasEntry { name: string; @@ -63,9 +65,6 @@ const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [ { name: 'claude-sonnet-4-6', alias: 'claude-sonnet-4-6', fork: true }, // Backward compatibility: legacy sonnet thinking alias now routes to canonical model ID. { name: 'claude-sonnet-4-6-thinking', alias: 'claude-sonnet-4-6', fork: true }, - { name: 'claude-sonnet-4-5', alias: 'claude-sonnet-4-5', fork: true }, - { name: 'claude-sonnet-4-5-thinking', alias: 'claude-sonnet-4-5-thinking', fork: true }, - { name: 'claude-opus-4-5-thinking', alias: 'claude-opus-4-5-thinking', fork: true }, { name: 'claude-opus-4-6-thinking', alias: 'claude-opus-4-6-thinking', fork: true }, ]; @@ -134,6 +133,8 @@ function addAliasEntry( fork: entry.fork || undefined, }; if (!normalized.name || !normalized.alias) return; + if (getDeniedModelIdReasonForProvider(normalized.name, 'agy')) return; + if (getDeniedModelIdReasonForProvider(normalized.alias, 'agy')) return; const key = `${normalized.name}\u0000${normalized.alias}`; const existingIndex = indexByKey.get(key); diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index 4c94d105..15e4ad60 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -23,12 +23,13 @@ const CLAUDE_DOTTED_THINKING_REGEX = const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX = /claude-sonnet-4(?:[.-])6-thinking(?=(?:$|-|\[|\(|\/))/gi; const CANONICAL_ANTIGRAVITY_SONNET_46_MODEL = 'claude-sonnet-4-6'; -const DEPRECATED_ANTIGRAVITY_OPUS_45_THINKING_REGEX = - /claude-opus-4(?:[.-])5-thinking(?=(?:$|-|\[|\(|\/))/gi; -const CANONICAL_ANTIGRAVITY_OPUS_46_THINKING_MODEL = 'claude-opus-4-6-thinking'; -const DEPRECATED_ANTIGRAVITY_SONNET_45_THINKING_REGEX = - /claude-sonnet-4(?:[.-])5-thinking(?=(?:$|-|\[|\(|\/))/gi; -const DEPRECATED_ANTIGRAVITY_SONNET_45_REGEX = /claude-sonnet-4(?:[.-])5(?=(?:$|-|\[|\(|\/))/gi; +const DENIED_ANTIGRAVITY_MODEL_REGEX = + /claude-(?:opus|sonnet)-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/i; +const DENIED_ANTIGRAVITY_OPUS_45_REGEX = + /claude-opus-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi; +const DENIED_ANTIGRAVITY_SONNET_45_REGEX = + /claude-sonnet-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi; +const CANONICAL_ANTIGRAVITY_OPUS_46_MODEL = 'claude-opus-4-6-thinking'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; function trimModelId(model: string): string { @@ -92,17 +93,41 @@ export function normalizeClaudeDottedThinkingMajorMinor(model: string): string { * Canonicalize legacy aliases to `claude-sonnet-4-6` while preserving suffixes. */ export function normalizeDeprecatedAntigravityModelAliases(model: string): string { - return model - .replace( - DEPRECATED_ANTIGRAVITY_OPUS_45_THINKING_REGEX, - CANONICAL_ANTIGRAVITY_OPUS_46_THINKING_MODEL - ) - .replace(DEPRECATED_ANTIGRAVITY_SONNET_45_THINKING_REGEX, CANONICAL_ANTIGRAVITY_SONNET_46_MODEL) - .replace(DEPRECATED_ANTIGRAVITY_SONNET_45_REGEX, CANONICAL_ANTIGRAVITY_SONNET_46_MODEL) - .replace( - DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX, - CANONICAL_ANTIGRAVITY_SONNET_46_MODEL - ); + return model.replace( + DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX, + CANONICAL_ANTIGRAVITY_SONNET_46_MODEL + ); +} + +/** + * Migrate denylisted Antigravity Claude 4.5 aliases to supported Claude 4.6 IDs. + * This is intended for repairing stale local settings at runtime. + */ +export function migrateDeniedAntigravityModelAliases(model: string): string { + const normalized = normalizeClaudeDottedMajorMinor(trimModelId(model)); + return normalized + .replace(DENIED_ANTIGRAVITY_OPUS_45_REGEX, CANONICAL_ANTIGRAVITY_OPUS_46_MODEL) + .replace(DENIED_ANTIGRAVITY_SONNET_45_REGEX, CANONICAL_ANTIGRAVITY_SONNET_46_MODEL); +} + +/** + * Deprecated Antigravity models that are denylisted and should not be used. + */ +export function isDeniedAntigravityModelId(model: string): boolean { + return DENIED_ANTIGRAVITY_MODEL_REGEX.test(trimModelId(model)); +} + +/** + * Returns deny reason when model ID is blocked for the given provider. + */ +export function getDeniedModelIdReasonForProvider( + model: string, + provider: ProviderLike +): string | null { + if (!isAntigravityProvider(provider)) return null; + const normalized = normalizeClaudeDottedMajorMinor(trimModelId(model)); + if (!isDeniedAntigravityModelId(normalized)) return null; + return 'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'; } /** diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index fbb917a6..c16dbe94 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -20,6 +20,7 @@ import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker' import { warn } from '../../utils/ui'; import { getCcsDir } from '../../utils/config-manager'; import { validateCompositeTiers } from '../composite-validator'; +import { getDeniedModelIdReasonForProvider } from '../model-id-normalizer'; import { createSettingsFile, createSettingsFileUnified, @@ -115,6 +116,11 @@ export function createVariant( target: TargetType = 'claude' ): VariantOperationResult { try { + const deniedModelReason = getDeniedModelIdReasonForProvider(model, provider); + if (deniedModelReason) { + return { success: false, error: deniedModelReason }; + } + // Validate provider/backend compatibility (block kiro/ghcp on original backend) const backendError = validateProviderBackend(provider); if (backendError) { @@ -255,6 +261,17 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari }; } + if (hasModelUpdate) { + const providerForModelUpdate = updates.provider ?? (existing.provider as CLIProxyProfileName); + const deniedModelReason = getDeniedModelIdReasonForProvider( + updates.model?.trim() || '', + providerForModelUpdate + ); + if (deniedModelReason) { + return { success: false, error: deniedModelReason }; + } + } + // Update settings file if (existing.settings) { const settingsPath = existing.settings.replace(/^~/, os.homedir()); diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 3dc0b379..2c9e31c1 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -18,7 +18,11 @@ import * as os from 'os'; import { URL } from 'url'; import { ToolNameMapper, type Tool, type ContentBlock } from './tool-name-mapper'; import { sanitizeToolSchemas } from './schema-sanitizer'; -import { extractProviderFromPathname, normalizeModelIdForRouting } from './model-id-normalizer'; +import { + extractProviderFromPathname, + getDeniedModelIdReasonForProvider, + normalizeModelIdForRouting, +} from './model-id-normalizer'; import { getCcsDir } from '../utils/config-manager'; export interface ToolSanitizationProxyConfig { @@ -211,6 +215,15 @@ export class ToolSanitizationProxy { let modifiedBody = parsed; if (isRecord(modifiedBody) && typeof modifiedBody.model === 'string') { const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname); + const deniedReason = getDeniedModelIdReasonForProvider( + modifiedBody.model, + providerFromPath + ); + if (deniedReason) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: deniedReason })); + return; + } const normalizedModel = normalizeModelIdForRouting(modifiedBody.model, providerFromPath); if (normalizedModel !== modifiedBody.model) { this.writeLog( diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 1bd66127..15db9f5c 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -54,6 +54,7 @@ import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; import { MODEL_ENV_VAR_KEYS, canonicalizeModelIdForProvider, + getDeniedModelIdReasonForProvider, } from '../../cliproxy/model-id-normalizer'; const router = Router(); @@ -590,6 +591,12 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise { ); if (!result.success) { - res.status(500).json({ error: result.error || 'Failed to create profile' }); + const errorMessage = result.error || 'Failed to create profile'; + res.status(isDenylistError(errorMessage) ? 400 : 500).json({ error: errorMessage }); return; } @@ -203,7 +208,8 @@ router.put('/:name', (req: Request, res: Response): void => { ...(hasTargetUpdate && parsedTarget && { target: parsedTarget }), }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + const errorMessage = (error as Error).message; + res.status(isDenylistError(errorMessage) ? 400 : 500).json({ error: errorMessage }); } }); diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 24b54245..ba6934b8 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -8,6 +8,12 @@ import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../ut import { expandPath } from '../../utils/helpers'; import { getClaudeSettingsPath } from '../../utils/claude-config-path'; import { resolveDroidProvider } from '../../targets/droid-provider'; +import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import { + extractProviderFromPathname, + getDeniedModelIdReasonForProvider, +} from '../../cliproxy/model-id-normalizer'; +import type { CLIProxyProvider } from '../../cliproxy/types'; import type { Config, Settings } from '../../types/config'; /** Model mapping for API profiles */ @@ -18,6 +24,34 @@ export interface ModelMapping { haikuModel?: string; } +function resolveProviderFromBaseUrl(baseUrl: unknown): CLIProxyProvider | null { + if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) { + return null; + } + + try { + const parsed = new URL(baseUrl); + const extracted = extractProviderFromPathname(parsed.pathname); + return extracted ? mapExternalProviderName(extracted) : null; + } catch { + const extracted = extractProviderFromPathname(baseUrl); + return extracted ? mapExternalProviderName(extracted) : null; + } +} + +function getDeniedModelReasonForProvider( + provider: CLIProxyProvider | null, + values: Array +): string | null { + if (!provider) return null; + for (const value of values) { + if (typeof value !== 'string' || value.trim().length === 0) continue; + const deniedReason = getDeniedModelIdReasonForProvider(value, provider); + if (deniedReason) return deniedReason; + } + return null; +} + /** * Read config safely with fallback. * Uses loadConfigSafe which supports both unified (config.yaml) and legacy (config.json). @@ -70,6 +104,16 @@ export function createSettingsFile( ): string { const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); const { model, opusModel, sonnetModel, haikuModel } = models; + const providerFromBaseUrl = resolveProviderFromBaseUrl(baseUrl); + const deniedReason = getDeniedModelReasonForProvider(providerFromBaseUrl, [ + model, + opusModel, + sonnetModel, + haikuModel, + ]); + if (deniedReason) { + throw new Error(deniedReason); + } const droidProvider = resolveDroidProvider({ provider, baseUrl, @@ -114,6 +158,24 @@ export function updateSettingsFile( } const settings = loadSettings(settingsPath); + const providerForValidation = + resolveProviderFromBaseUrl(updates.baseUrl) ?? + resolveProviderFromBaseUrl(settings.env?.ANTHROPIC_BASE_URL); + const deniedReason = getDeniedModelReasonForProvider(providerForValidation, [ + updates.model !== undefined ? updates.model : settings.env?.ANTHROPIC_MODEL, + updates.opusModel !== undefined + ? updates.opusModel + : settings.env?.ANTHROPIC_DEFAULT_OPUS_MODEL, + updates.sonnetModel !== undefined + ? updates.sonnetModel + : settings.env?.ANTHROPIC_DEFAULT_SONNET_MODEL, + updates.haikuModel !== undefined + ? updates.haikuModel + : settings.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL, + ]); + if (deniedReason) { + throw new Error(deniedReason); + } if (updates.baseUrl) { settings.env = settings.env || {}; diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 6a9f1bcc..8e3c198e 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -27,7 +27,11 @@ import { import type { Settings } from '../../types/config'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; -import { canonicalizeModelIdForProvider } from '../../cliproxy/model-id-normalizer'; +import { + canonicalizeModelIdForProvider, + extractProviderFromPathname, + getDeniedModelIdReasonForProvider, +} from '../../cliproxy/model-id-normalizer'; const router = Router(); const MODEL_ENV_KEYS = [ @@ -139,14 +143,89 @@ function resolveProviderForProfile(profileOrVariant: string): CLIProxyProvider | return null; } -function canonicalizeProfileModelId(profileOrVariant: string, modelId: string): string { - const provider = resolveProviderForProfile(profileOrVariant); +function resolveProviderFromBaseUrl(baseUrl: unknown): CLIProxyProvider | null { + if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) { + return null; + } + + try { + const parsed = new URL(baseUrl); + const extracted = extractProviderFromPathname(parsed.pathname); + return extracted ? mapExternalProviderName(extracted) : null; + } catch { + const extracted = extractProviderFromPathname(baseUrl); + return extracted ? mapExternalProviderName(extracted) : null; + } +} + +function resolveProviderForSettings( + profileOrVariant: string, + settings?: Pick +): CLIProxyProvider | null { + const providerFromProfile = resolveProviderForProfile(profileOrVariant); + if (providerFromProfile) { + return providerFromProfile; + } + + const baseUrl = + settings?.env && typeof settings.env === 'object' ? settings.env.ANTHROPIC_BASE_URL : undefined; + return resolveProviderFromBaseUrl(baseUrl); +} + +function canonicalizeProfileModelId( + profileOrVariant: string, + modelId: string, + settings?: Settings +): string { + const provider = resolveProviderForSettings(profileOrVariant, settings); if (!provider) return modelId; return canonicalizeModelIdForProvider(modelId, provider); } +function findDeniedProfileModel( + profileOrVariant: string, + modelId: string, + settings?: Settings +): string | null { + const provider = resolveProviderForSettings(profileOrVariant, settings); + if (!provider) return null; + return getDeniedModelIdReasonForProvider(modelId, provider); +} + +function validateProfileSettingsModelDenylist( + profileOrVariant: string, + settings: Settings +): string | null { + if (settings.env && typeof settings.env === 'object') { + for (const key of MODEL_ENV_KEYS) { + const value = settings.env[key]; + if (typeof value !== 'string') continue; + const deniedReason = findDeniedProfileModel(profileOrVariant, value, settings); + if (deniedReason) { + return `${key}: ${deniedReason}`; + } + } + } + + if (!Array.isArray(settings.presets)) return null; + + for (const preset of settings.presets) { + for (const key of PRESET_MODEL_KEYS) { + const value = preset[key]; + if (typeof value !== 'string') continue; + const deniedReason = findDeniedProfileModel(profileOrVariant, value, settings); + if (deniedReason) { + const presetName = typeof preset.name === 'string' ? preset.name : 'unnamed'; + return `Preset '${presetName}' (${key}): ${deniedReason}`; + } + } + } + + return null; +} + function canonicalizeProfileSettings(profileOrVariant: string, settings: Settings): Settings { - const provider = resolveProviderForProfile(profileOrVariant); + const provider = resolveProviderForSettings(profileOrVariant, settings); if (!provider) return settings; let changed = false; @@ -167,13 +246,18 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting } if (Array.isArray(settings.presets)) { - const normalizedPresets = settings.presets.map((preset) => { + const normalizedPresets = settings.presets.flatMap((preset) => { const normalizedPreset = { ...preset }; let presetChanged = false; for (const key of PRESET_MODEL_KEYS) { const value = normalizedPreset[key]; if (typeof value !== 'string') continue; + const deniedReason = getDeniedModelIdReasonForProvider(value, provider); + if (deniedReason) { + changed = true; + return []; + } const canonical = canonicalizeModelIdForProvider(value, provider); if (canonical !== value) { normalizedPreset[key] = canonical; @@ -314,6 +398,12 @@ router.put('/:profile', (req: Request, res: Response): void => { return; } + const deniedModelReason = validateProfileSettingsModelDenylist(profile, settings as Settings); + if (deniedModelReason) { + res.status(400).json({ error: deniedModelReason }); + return; + } + const normalizedSettings = canonicalizeProfileSettings(profile, settings as Settings); // Deduplicate CCS hooks to prevent accumulation (fixes #450) @@ -437,7 +527,20 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { } const normalizePresetModel = (modelId: string): string => - canonicalizeProfileModelId(profile, modelId); + canonicalizeProfileModelId(profile, modelId, settings); + + for (const modelId of [ + defaultModel, + opus || defaultModel, + sonnet || defaultModel, + haiku || defaultModel, + ]) { + const deniedReason = findDeniedProfileModel(profile, modelId, settings); + if (deniedReason) { + res.status(400).json({ error: deniedReason }); + return; + } + } const normalizedDefaultModel = normalizePresetModel(defaultModel); const normalizedOpusModel = normalizePresetModel(opus || defaultModel); diff --git a/tests/unit/api/profile-writer-denylist.test.ts b/tests/unit/api/profile-writer-denylist.test.ts new file mode 100644 index 00000000..a1975e06 --- /dev/null +++ b/tests/unit/api/profile-writer-denylist.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createApiProfile } from '../../../src/api/services/profile-writer'; + +describe('profile-writer AGY denylist', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-writer-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('rejects denylisted AGY 4.5 models on profile create', () => { + const result = createApiProfile( + 'agy-denied', + 'http://127.0.0.1:8317/api/provider/agy', + 'test-token', + { + default: 'claude-opus-4.5', + opus: 'claude-opus-4.5', + sonnet: 'claude-sonnet-4.5', + haiku: 'claude-haiku-4.5', + }, + 'claude' + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('denylist'); + const settingsPath = path.join(tempHome, '.ccs', 'agy-denied.settings.json'); + expect(fs.existsSync(settingsPath)).toBe(false); + }); +}); diff --git a/tests/unit/cliproxy/composite-validator.test.ts b/tests/unit/cliproxy/composite-validator.test.ts index 3701b089..d762294d 100644 --- a/tests/unit/cliproxy/composite-validator.test.ts +++ b/tests/unit/cliproxy/composite-validator.test.ts @@ -10,7 +10,7 @@ import { const validTier = { provider: 'agy', - model: 'claude-sonnet-4-5-thinking', + model: 'claude-sonnet-4-6', }; describe('validateCompositeDefaultTier', () => { @@ -77,4 +77,17 @@ describe('validateCompositeTiers', () => { expect(error).toContain("Circular fallback in tier 'opus'"); }); + + it('rejects denylisted agy 4.5 models', () => { + const error = validateCompositeTiers( + { + opus: { provider: 'agy', model: 'claude-opus-4.5-thinking' }, + sonnet: { provider: 'gemini', model: 'gemini-2.5-pro' }, + haiku: { provider: 'gemini', model: 'gemini-2.5-flash' }, + }, + { defaultTier: 'opus', requireAllTiers: true } + ); + + expect(error).toContain('denylist'); + }); }); diff --git a/tests/unit/cliproxy/composite-variant-service.test.ts b/tests/unit/cliproxy/composite-variant-service.test.ts index 22ad8ae8..ccc47fda 100644 --- a/tests/unit/cliproxy/composite-variant-service.test.ts +++ b/tests/unit/cliproxy/composite-variant-service.test.ts @@ -73,7 +73,7 @@ cliproxy: default_tier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: 'cliproxy/composite-test.settings.json', @@ -99,7 +99,7 @@ cliproxy: expect(result.success).toBe(true); expect(result.variant?.tiers?.opus.model).toBe('claude-opus-4-6-thinking'); - expect(result.variant?.tiers?.sonnet.model).toBe('claude-sonnet-4-5-thinking'); // Unchanged + expect(result.variant?.tiers?.sonnet.model).toBe('claude-sonnet-4-6'); // Unchanged expect(result.variant?.tiers?.haiku.model).toBe('claude-haiku-4-5-20251001'); // Unchanged }); @@ -110,7 +110,7 @@ cliproxy: default_tier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: 'cliproxy/composite-test.settings.json', @@ -144,7 +144,7 @@ cliproxy: default_tier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: 'cliproxy/composite-test.settings.json', @@ -188,7 +188,7 @@ cliproxy: thinking: 'xhigh', account: 'team-a', }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: 'cliproxy/composite-test.settings.json', @@ -228,7 +228,7 @@ cliproxy: default_tier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: customSettingsPath, @@ -244,9 +244,9 @@ cliproxy: env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', - ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_MODEL: 'claude-sonnet-4-6', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', CUSTOM_ENV: 'preserve-this', }, @@ -261,7 +261,7 @@ cliproxy: const result = updateCompositeVariant('test', { tiers: { - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking(high)' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6(high)' }, }, }); @@ -273,7 +273,7 @@ cliproxy: hooks: { PreToolUse: unknown[] }; customPreset: boolean; }; - expect(updatedSettings.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); + expect(updatedSettings.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(high)'); expect(updatedSettings.env.CUSTOM_ENV).toBe('preserve-this'); expect(updatedSettings.hooks.PreToolUse.length).toBe(1); expect(updatedSettings.customPreset).toBe(true); @@ -377,7 +377,7 @@ cliproxy: defaultTier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-2.5-pro' }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, } as unknown as { opus: { provider: 'gemini' | 'codex' | 'agy'; model: string }; sonnet: { provider: 'gemini' | 'codex' | 'agy'; model: string }; @@ -395,7 +395,7 @@ cliproxy: defaultTier: 'sonnet', tiers: { opus: null, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, } as unknown as { opus: { provider: 'gemini' | 'codex' | 'agy'; model: string }; @@ -414,7 +414,7 @@ cliproxy: defaultTier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-2.5-pro' }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, }); @@ -479,7 +479,7 @@ cliproxy: default_tier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, - sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: 'cliproxy/composite-test.settings.json', @@ -547,7 +547,7 @@ cliproxy: model: gemini-3-pro-preview sonnet: provider: agy - model: claude-sonnet-4-5-thinking + model: claude-sonnet-4-6 haiku: provider: agy model: claude-haiku-4-5-20251001 @@ -588,7 +588,7 @@ cliproxy: thinking: xhigh sonnet: provider: agy - model: claude-sonnet-4-5-thinking + model: claude-sonnet-4-6 thinking: medium haiku: provider: agy @@ -632,7 +632,7 @@ cliproxy: model: claude-opus-4-6-thinking sonnet: provider: agy - model: claude-sonnet-4-5-thinking + model: claude-sonnet-4-6 haiku: provider: agy model: claude-haiku-4-5-20251001 @@ -671,7 +671,7 @@ cliproxy: model: claude-opus-4-6-thinking sonnet: provider: agy - model: claude-sonnet-4-5-thinking + model: claude-sonnet-4-6 settings: cliproxy/bad.settings.json port: 8318 good: diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index 6e3bfd10..751bd766 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -584,9 +584,16 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); // Claude aliases should have fork: true - assert(config.includes('claude-sonnet-4-5'), 'Should include Claude sonnet model'); assert(config.includes('claude-sonnet-4-6'), 'Should include Claude Sonnet 4.6 model'); assert(config.includes('fork: true'), 'Should include fork: true for Claude aliases'); + assert( + !config.includes('claude-sonnet-4-5'), + 'Should not include denylisted Claude Sonnet 4.5 aliases' + ); + assert( + !config.includes('claude-opus-4-5'), + 'Should not include denylisted Claude Opus 4.5 aliases' + ); assert( !config.includes('alias: gemini-claude-'), 'Should not emit deprecated gemini-claude aliases' diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index 6b1efdd7..cc17f718 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -118,6 +118,50 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); }); + it('migrates denylisted AGY 4.5 model IDs to supported 4.6 fallbacks', () => { + writeSettings( + settingsPath, + { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-opus-4.5', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-5', + }, + { + presets: [ + { + name: 'legacy-45', + default: 'claude-opus-4.5', + opus: 'claude-opus-4-5-thinking', + sonnet: 'claude-sonnet-4.5-thinking', + haiku: 'claude-sonnet-4-5', + }, + ], + } + ); + + const env = getEffectiveEnvVars('agy', 8317, settingsPath); + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + presets?: Array>; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6'); + expect(persisted.presets?.[0]?.default).toBe('claude-opus-4-6-thinking'); + expect(persisted.presets?.[0]?.opus).toBe('claude-opus-4-6-thinking'); + expect(persisted.presets?.[0]?.sonnet).toBe('claude-sonnet-4-6'); + expect(persisted.presets?.[0]?.haiku).toBe('claude-sonnet-4-6'); + }); + it('migrates codex preset model mappings to canonical IDs', () => { writeSettings( settingsPath, @@ -269,11 +313,11 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(repaired.env?.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(8192)'); expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); - expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-5'); + expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6'); expect(repaired.presets?.[0]?.default).toBe('claude-sonnet-4-6'); expect(repaired.presets?.[0]?.opus).toBe('claude-opus-4-6-thinking'); expect(repaired.presets?.[0]?.sonnet).toBe('claude-sonnet-4-6'); - expect(repaired.presets?.[0]?.haiku).toBe('claude-sonnet-4-5'); + expect(repaired.presets?.[0]?.haiku).toBe('claude-sonnet-4-6'); }); it('migrates codex effort-suffixed preset IDs during ensureProviderSettings', () => { diff --git a/tests/unit/cliproxy/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts index 705e8dab..6978d7db 100644 --- a/tests/unit/cliproxy/model-id-normalizer.test.ts +++ b/tests/unit/cliproxy/model-id-normalizer.test.ts @@ -2,7 +2,10 @@ import { describe, it, expect } from 'bun:test'; import { canonicalizeModelIdForProvider, extractProviderFromPathname, + getDeniedModelIdReasonForProvider, + isDeniedAntigravityModelId, isAntigravityProvider, + migrateDeniedAntigravityModelAliases, normalizeClaudeDottedMajorMinor, normalizeClaudeDottedThinkingMajorMinor, normalizeModelIdForProvider, @@ -65,12 +68,12 @@ describe('model-id-normalizer', () => { 'claude-opus-4-6-thinking' ); expect(normalizeModelIdForProvider('claude-opus-4.5-thinking', 'agy')).toBe( - 'claude-opus-4-6-thinking' + 'claude-opus-4-5-thinking' ); expect(normalizeModelIdForProvider('claude-sonnet-4.5-thinking', 'agy')).toBe( - 'claude-sonnet-4-6' + 'claude-sonnet-4-5-thinking' ); - expect(normalizeModelIdForProvider('claude-sonnet-4.5', 'agy')).toBe('claude-sonnet-4-6'); + expect(normalizeModelIdForProvider('claude-sonnet-4.5', 'agy')).toBe('claude-sonnet-4-5'); expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'gemini')).toBe( 'claude-opus-4.6-thinking' ); @@ -113,9 +116,9 @@ describe('model-id-normalizer', () => { }; const normalized = normalizeModelEnvVarsForProvider(input, 'agy'); - expect(normalized.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); - expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); - expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(normalized.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking'); + expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-thinking'); + expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5'); expect(normalized.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); expect(normalized.UNRELATED).toBe('keep-me'); @@ -123,5 +126,23 @@ describe('model-id-normalizer', () => { expect(unchanged.ANTHROPIC_MODEL).toBe('claude-sonnet-4.5-thinking'); expect(unchanged.UNRELATED).toBe('keep-me'); }); + + it('flags denylisted antigravity models', () => { + expect(isDeniedAntigravityModelId('claude-opus-4-5-thinking')).toBe(true); + expect(isDeniedAntigravityModelId('claude-sonnet-4.5')).toBe(true); + expect(getDeniedModelIdReasonForProvider('claude-sonnet-4.5', 'agy')).toContain('denylist'); + expect(getDeniedModelIdReasonForProvider('claude-sonnet-4.5', 'gemini')).toBeNull(); + expect(getDeniedModelIdReasonForProvider('claude-haiku-4.5', 'agy')).toBeNull(); + }); + + it('migrates denylisted antigravity IDs to supported 4.6 fallbacks', () => { + expect(migrateDeniedAntigravityModelAliases('claude-opus-4.5')).toBe( + 'claude-opus-4-6-thinking' + ); + expect(migrateDeniedAntigravityModelAliases('claude-sonnet-4-5-thinking(8192)')).toBe( + 'claude-sonnet-4-6(8192)' + ); + expect(migrateDeniedAntigravityModelAliases('claude-haiku-4-5')).toBe('claude-haiku-4-5'); + }); }); }); diff --git a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts index a07ee192..1e35be92 100644 --- a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts +++ b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts @@ -253,6 +253,31 @@ describe('ToolSanitizationProxy Integration', () => { } }); + it('rejects denylisted AGY 4.5 model IDs on antigravity provider route', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/provider/agy/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'test' }], + }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('denylist'); + expect(lastRequest).toBeNull(); + } finally { + proxy.stop(); + } + }); + it('does not rewrite AGY legacy aliases on non-AGY provider routes', async () => { const proxy = new ToolSanitizationProxy({ upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, diff --git a/tests/unit/cliproxy/variant-update-service.test.ts b/tests/unit/cliproxy/variant-update-service.test.ts index ae3874a4..af491a6a 100644 --- a/tests/unit/cliproxy/variant-update-service.test.ts +++ b/tests/unit/cliproxy/variant-update-service.test.ts @@ -83,6 +83,15 @@ cliproxy: expect(result.error).toContain('Changing provider requires model update'); }); + it('rejects denylisted AGY 4.5 model updates', () => { + const result = updateVariant('demo', { + provider: 'agy', + model: 'claude-sonnet-4.5', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('denylist'); + }); + it('updates provider and regenerates provider-specific core env in same settings file', () => { const result = updateVariant('demo', { provider: 'codex', diff --git a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts index 1b15727a..28651028 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts @@ -91,6 +91,33 @@ describe('cliproxy-stats-routes model update canonicalization', () => { expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); }); + it('rejects denylisted AGY 4.5 model updates', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'agy.settings.json'); + writeSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5', + }); + + const response = await fetch(`${baseUrl}/api/cliproxy/models/agy`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'claude-opus-4.5' }), + }); + expect(response.status).toBe(400); + + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('denylist'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); + }); + it('canonicalizes Codex effort suffix and syncs linked core model env vars', async () => { const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'codex.settings.json'); writeSettings(settingsPath, { diff --git a/tests/unit/web-server/route-helpers-model-denylist.test.ts b/tests/unit/web-server/route-helpers-model-denylist.test.ts new file mode 100644 index 00000000..c84fa8db --- /dev/null +++ b/tests/unit/web-server/route-helpers-model-denylist.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + createSettingsFile, + updateSettingsFile, +} from '../../../src/web-server/routes/route-helpers'; + +describe('route-helpers AGY denylist', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-route-helpers-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('rejects denylisted AGY models on settings create', () => { + expect(() => + createSettingsFile( + 'agy-denied', + 'http://127.0.0.1:8317/api/provider/agy', + 'test-token', + { + model: 'claude-sonnet-4.5', + opusModel: 'claude-opus-4.5', + sonnetModel: 'claude-sonnet-4.5', + haikuModel: 'claude-haiku-4.5', + } + ) + ).toThrow(/denylist/i); + }); + + it('rejects denylisted AGY models on settings update', () => { + const settingsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(settingsDir, { recursive: true }); + const settingsPath = path.join(settingsDir, 'agy-profile.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5', + }, + }, + null, + 2 + ) + '\n' + ); + + expect(() => updateSettingsFile('agy-profile', { model: 'claude-opus-4.5' })).toThrow( + /denylist/i + ); + }); +}); diff --git a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts index 5f7f861c..2cd7e1d8 100644 --- a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts +++ b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts @@ -324,6 +324,88 @@ describe('settings-routes model canonicalization', () => { expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); }); + it('rejects denylisted AGY models on PUT /:profile', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { env: {} }); + + const response = await fetch(`${baseUrl}/api/settings/agy`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4.5', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5', + }, + presets: [], + }, + }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error?: string }; + expect(body.error).toContain('denylist'); + }); + + it('rejects denylisted AGY models on POST /:profile/presets', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + }, + presets: [], + }); + + const response = await fetch(`${baseUrl}/api/settings/agy/presets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'legacy', + default: 'claude-opus-4.5-thinking', + }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error?: string }; + expect(body.error).toContain('denylist'); + }); + + it('drops denylisted AGY presets on read canonicalization', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + }, + presets: [ + { + name: 'denylisted', + default: 'claude-sonnet-4.5', + opus: 'claude-opus-4-6-thinking', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4.5', + }, + ], + }); + + const response = await fetch(`${baseUrl}/api/settings/agy/presets`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { presets: Array> }; + expect(body.presets.length).toBe(0); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + presets: Array>; + }; + expect(persisted.presets.length).toBe(0); + }); + it('does not crash on malformed non-string preset model values', async () => { const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); writeSettings(settingsPath, { diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index f070eac5..18a57cb5 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -14,10 +14,12 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy'; import { usePrivacy } from '@/contexts/privacy-context'; import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; +import { isDeniedAgyModelId } from '@/lib/utils'; const singleProviderSchema = z.object({ name: z @@ -68,6 +70,12 @@ const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({ value: id, label: getProviderDisplayName(id), })); +const AGY_DENYLIST_MESSAGE = + 'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'; + +function isDeniedAgyModelForProvider(provider: string, modelId: string | undefined): boolean { + return provider === 'agy' && typeof modelId === 'string' && isDeniedAgyModelId(modelId); +} export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { const { t } = useTranslation(); @@ -99,6 +107,12 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { const providerAccounts = providerAuth?.accounts || []; const onSubmitSingle = async (data: SingleProviderFormData) => { + if (isDeniedAgyModelForProvider(data.provider, data.model)) { + singleForm.setError('model', { message: AGY_DENYLIST_MESSAGE }); + toast.error(AGY_DENYLIST_MESSAGE); + return; + } + try { await createMutation.mutateAsync(data); singleForm.reset(); @@ -109,6 +123,14 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { }; const onSubmitComposite = async (data: CompositeFormData) => { + for (const tier of ['opus', 'sonnet', 'haiku'] as const) { + const tierConfig = data.tiers[tier]; + if (!isDeniedAgyModelForProvider(tierConfig.provider, tierConfig.model)) continue; + compositeForm.setError(`tiers.${tier}.model`, { message: AGY_DENYLIST_MESSAGE }); + toast.error(AGY_DENYLIST_MESSAGE); + return; + } + try { await createMutation.mutateAsync({ name: data.name, diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index ba532a99..07d3b430 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -12,10 +12,12 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { useUpdateVariant } from '@/hooks/use-cliproxy'; import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; import type { UpdateVariant, Variant } from '@/lib/api-client'; +import { isDeniedAgyModelId } from '@/lib/utils'; const singleProviderSchema = z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), @@ -61,12 +63,18 @@ const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({ })); const COMPOSITE_TIERS = ['opus', 'sonnet', 'haiku'] as const; +const AGY_DENYLIST_MESSAGE = + 'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'; function normalizeOptionalValue(value?: string): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } +function isDeniedAgyModelForProvider(provider: string, modelId: string | undefined): boolean { + return provider === 'agy' && typeof modelId === 'string' && isDeniedAgyModelId(modelId); +} + function isSingleVariantOnlyTargetChange(variant: Variant, data: SingleProviderFormData): boolean { const currentTarget = variant.target || 'claude'; const currentModel = normalizeOptionalValue(variant.model); @@ -190,6 +198,14 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit } } + const nextProvider = payload.provider || data.provider || variant.provider; + const nextModelForValidation = payload.model ?? normalizeOptionalValue(data.model); + if (isDeniedAgyModelForProvider(nextProvider, nextModelForValidation)) { + singleForm.setError('model', { message: AGY_DENYLIST_MESSAGE }); + toast.error(AGY_DENYLIST_MESSAGE); + return; + } + if (Object.keys(payload).length === 0) { onOpenChange(false); return; @@ -243,6 +259,15 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit } } + const tiersToValidate = payload.tiers ?? data.tiers; + for (const tier of COMPOSITE_TIERS) { + const tierConfig = tiersToValidate[tier]; + if (!isDeniedAgyModelForProvider(tierConfig.provider, tierConfig.model)) continue; + compositeForm.setError(`tiers.${tier}.model`, { message: AGY_DENYLIST_MESSAGE }); + toast.error(AGY_DENYLIST_MESSAGE); + return; + } + if (Object.keys(payload).length === 0) { onOpenChange(false); return; diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index b290dbdb..502b8583 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -17,6 +17,7 @@ import { useDeletePreset, } from '@/hooks/use-cliproxy'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; +import { isDeniedAgyModelId } from '@/lib/utils'; import i18n from '@/lib/i18n'; import { usePrivacy } from '@/contexts/privacy-context'; import { useProviderEditor } from './use-provider-editor'; @@ -57,10 +58,23 @@ export function ProviderEditor({ const { data: presetsData } = usePresets(provider); const createPresetMutation = useCreatePreset(); const deletePresetMutation = useDeletePreset(); - const savedPresets = presetsData?.presets || []; // Use baseProvider for model filtering (for variants, this is the parent provider) const modelFilterProvider = baseProvider || provider; + const isAgyProvider = modelFilterProvider.toLowerCase() === 'agy'; + + const savedPresets = useMemo(() => { + const presets = presetsData?.presets || []; + if (!isAgyProvider) return presets; + + return presets.filter( + (preset) => + !isDeniedAgyModelId(preset.default) && + !isDeniedAgyModelId(preset.opus) && + !isDeniedAgyModelId(preset.sonnet) && + !isDeniedAgyModelId(preset.haiku) + ); + }, [isAgyProvider, presetsData?.presets]); const providerModels = useMemo(() => { if (!modelsData?.models) return []; @@ -77,10 +91,12 @@ export function ProviderEditor({ const owners = ownerMap[modelFilterProvider.toLowerCase()] || [ modelFilterProvider.toLowerCase(), ]; - return modelsData.models.filter((m) => - owners.some((o) => m.owned_by.toLowerCase().includes(o)) - ); - }, [modelsData, modelFilterProvider]); + return modelsData.models.filter((m) => { + if (!owners.some((o) => m.owned_by.toLowerCase().includes(o))) return false; + if (!isAgyProvider) return true; + return !isDeniedAgyModelId(m.id); + }); + }, [isAgyProvider, modelsData, modelFilterProvider]); const providerRoute = (baseProvider || provider).toLowerCase(); @@ -131,6 +147,19 @@ export function ProviderEditor({ const effectiveApiKey = authTokens?.apiKey?.value ?? 'ccs-internal-managed'; const handleApplyPreset = (updates: Record) => { + if ( + isAgyProvider && + [ + updates.ANTHROPIC_MODEL, + updates.ANTHROPIC_DEFAULT_OPUS_MODEL, + updates.ANTHROPIC_DEFAULT_SONNET_MODEL, + updates.ANTHROPIC_DEFAULT_HAIKU_MODEL, + ].some((modelId) => typeof modelId === 'string' && isDeniedAgyModelId(modelId)) + ) { + toast.error('Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'); + return; + } + const effectivePort = port ?? CLIPROXY_DEFAULT_PORT; updateEnvValues({ ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${providerRoute}`, @@ -141,6 +170,16 @@ export function ProviderEditor({ }; const handleCustomPresetApply = (values: ModelMappingValues, presetName?: string) => { + if ( + isAgyProvider && + [values.default, values.opus, values.sonnet, values.haiku].some((modelId) => + isDeniedAgyModelId(modelId) + ) + ) { + toast.error('Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'); + return; + } + const effectivePort = port ?? CLIPROXY_DEFAULT_PORT; updateEnvValues({ ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${providerRoute}`, @@ -159,6 +198,15 @@ export function ProviderEditor({ toast.error(i18n.t('commonToast.enterPresetName')); return; } + if ( + isAgyProvider && + [values.default, values.opus, values.sonnet, values.haiku].some((modelId) => + isDeniedAgyModelId(modelId) + ) + ) { + toast.error('Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'); + return; + } createPresetMutation.mutate({ profile: provider, data: { name: presetName, ...values } }); setCustomPresetOpen(false); }; diff --git a/ui/src/components/setup/wizard/steps/variant-step.tsx b/ui/src/components/setup/wizard/steps/variant-step.tsx index 15befb3c..1e185081 100644 --- a/ui/src/components/setup/wizard/steps/variant-step.tsx +++ b/ui/src/components/setup/wizard/steps/variant-step.tsx @@ -14,7 +14,7 @@ import { SelectValue, } from '@/components/ui/select'; import { ArrowLeft, User } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import { cn, isDeniedAgyModelId } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { MODEL_CATALOGS } from '@/lib/model-catalogs'; import type { VariantStepProps } from '../types'; @@ -40,6 +40,10 @@ export function VariantStep({ const catalogModels = MODEL_CATALOGS[selectedProvider]?.models || []; const isCustomModel = modelName && !catalogModels.some((m) => m.id === modelName); const [showCustomInput, setShowCustomInput] = useState(isCustomModel); + const deniedCustomModel = + selectedProvider === 'agy' && modelName.trim().length > 0 + ? isDeniedAgyModelId(modelName) + : false; const handleModelSelect = (value: string) => { if (value === CUSTOM_MODEL_VALUE) { @@ -87,10 +91,20 @@ export function VariantStep({ onChange={(e) => onModelChange(e.target.value)} placeholder={t('setupVariant.modelPlaceholder')} /> + {deniedCustomModel && ( +

+ Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated. +

+ )} @@ -140,7 +154,7 @@ export function VariantStep({ - diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 4585d5f1..7738cb44 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -110,7 +110,7 @@ const resources = { variantNamePlaceholder: 'e.g., my-gemini, g3, flash', invokeHintPrefix: 'Use this name to invoke:', model: 'Model', - modelPlaceholder: 'e.g., gemini-2.5-pro, claude-opus-4-5', + modelPlaceholder: 'e.g., gemini-2.5-pro, claude-opus-4-6-thinking', choosePresetInstead: 'Choose from presets instead', selectModel: 'Select a model', customModelName: 'Custom model name...', @@ -1241,7 +1241,7 @@ const resources = { variantNamePlaceholder: '例如:my-gemini、g3、flash', invokeHintPrefix: '可通过该名称调用:', model: '模型', - modelPlaceholder: '例如:gemini-2.5-pro、claude-opus-4-5', + modelPlaceholder: '例如:gemini-2.5-pro、claude-opus-4-6-thinking', choosePresetInstead: '改为从预设中选择', selectModel: '选择模型', customModelName: '自定义模型名称...', diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 1fbbe124..37650ad7 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -217,10 +217,15 @@ export function getClaudeResetTime< } // Known primary models to show when exhausted (removed from API response) +const AGY_DENYLIST_REGEX = /claude-(?:opus|sonnet)-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/i; + +export function isDeniedAgyModelId(modelId: string): boolean { + return AGY_DENYLIST_REGEX.test((modelId || '').trim()); +} + const KNOWN_PRIMARY_MODELS = [ - { name: 'claude-opus-4-5-thinking', displayName: 'Claude Opus 4.5 (Thinking)' }, - { name: 'claude-sonnet-4-5', displayName: 'Claude Sonnet 4.5' }, - { name: 'claude-sonnet-4-5-thinking', displayName: 'Claude Sonnet 4.5 (Thinking)' }, + { name: 'claude-opus-4-6-thinking', displayName: 'Claude Opus 4.6 (Thinking)' }, + { name: 'claude-sonnet-4-6', displayName: 'Claude Sonnet 4.6' }, { name: 'gpt-oss-120b', displayName: 'GPT-OSS 120B (Medium)' }, ];