mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
fix(cliproxy): denylist deprecated agy 4.5 models
This commit is contained in:
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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)' },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user