fix(cliproxy): denylist deprecated agy 4.5 models

This commit is contained in:
Tam Nhu Tran
2026-03-03 03:10:53 +07:00
parent 5913f730ac
commit f63e2cd5a3
29 changed files with 850 additions and 77 deletions
+39
View File
@@ -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()) {
+14 -3
View File
@@ -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<string, ModelEntry>();
@@ -161,7 +169,7 @@ export function mergeCatalog(
const mergedIds = new Set<string>();
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);
}
+14
View File
@@ -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`;
}
+14 -3
View File
@@ -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;
+5 -4
View File
@@ -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);
+42 -17
View File
@@ -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.';
}
/**
+17
View File
@@ -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());
+14 -1
View File
@@ -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(
@@ -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<voi
return;
}
const deniedReason = getDeniedModelIdReasonForProvider(model, provider);
if (deniedReason) {
res.status(400).json({ error: deniedReason });
return;
}
// Get the settings file for this provider
const ccsDir = getCliproxyWritablePath();
const settingsPath = path.join(ccsDir, `${provider}.settings.json`);
+8 -2
View File
@@ -19,6 +19,10 @@ import { updateSettingsFile } from './route-helpers';
const router = Router();
function isDenylistError(message: string | undefined): boolean {
return typeof message === 'string' && message.toLowerCase().includes('denylist');
}
export function parseTarget(rawTarget: unknown): TargetType | null {
if (rawTarget === undefined || rawTarget === null || rawTarget === '') {
return null;
@@ -113,7 +117,8 @@ router.post('/', (req: Request, res: Response): void => {
);
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 });
}
});
+62
View File
@@ -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 | undefined>
): 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 || {};
+109 -6
View File
@@ -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<Settings, 'env'>
): 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);
@@ -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);
});
});
@@ -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');
});
});
@@ -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:
+8 -1
View File
@@ -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'
@@ -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<string, string>;
presets?: Array<Record<string, string>>;
};
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', () => {
@@ -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');
});
});
});
@@ -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}`,
@@ -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',
@@ -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<string, string>;
};
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, {
@@ -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
);
});
});
@@ -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<Record<string, string>> };
expect(body.presets.length).toBe(0);
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
presets: Array<Record<string, string>>;
};
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, {
@@ -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,
@@ -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;
@@ -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<string, string>) => {
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);
};
@@ -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 && (
<p className="text-xs text-destructive">
Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.
</p>
)}
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setShowCustomInput(false)}
onClick={() => {
setShowCustomInput(false);
if (selectedProvider === 'agy' && isDeniedAgyModelId(modelName)) {
onModelChange('');
}
}}
>
{t('setupVariant.choosePresetInstead')}
</button>
@@ -140,7 +154,7 @@ export function VariantStep({
<Button variant="ghost" onClick={onSkip}>
{t('setupVariant.skip')}
</Button>
<Button onClick={onCreate} disabled={!variantName || isPending}>
<Button onClick={onCreate} disabled={!variantName || isPending || deniedCustomModel}>
{isPending ? t('setupVariant.creating') : t('setupVariant.createVariant')}
</Button>
</div>
+2 -2
View File
@@ -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: '自定义模型名称...',
+8 -3
View File
@@ -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)' },
];