mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +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 { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
|
||||||
import type { TargetType } from '../../targets/target-adapter';
|
import type { TargetType } from '../../targets/target-adapter';
|
||||||
import { resolveDroidProvider } from '../../targets/droid-provider';
|
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 {
|
import type {
|
||||||
ModelMapping,
|
ModelMapping,
|
||||||
CreateApiProfileResult,
|
CreateApiProfileResult,
|
||||||
@@ -26,6 +32,34 @@ function isOpenRouterUrl(baseUrl: string): boolean {
|
|||||||
return baseUrl.toLowerCase().includes('openrouter.ai');
|
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) */
|
/** Create settings.json file for API profile (legacy format) */
|
||||||
function createSettingsFile(
|
function createSettingsFile(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -159,6 +193,11 @@ export function createApiProfile(
|
|||||||
provider?: string
|
provider?: string
|
||||||
): CreateApiProfileResult {
|
): CreateApiProfileResult {
|
||||||
try {
|
try {
|
||||||
|
const deniedReason = getDeniedModelReason(baseUrl, models);
|
||||||
|
if (deniedReason) {
|
||||||
|
return { success: false, settingsFile: '', error: deniedReason };
|
||||||
|
}
|
||||||
|
|
||||||
const settingsFile = `~/.ccs/${name}.settings.json`;
|
const settingsFile = `~/.ccs/${name}.settings.json`;
|
||||||
|
|
||||||
if (isUnifiedMode()) {
|
if (isUnifiedMode()) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { CLIProxyProvider } from './types';
|
|||||||
import type { ModelEntry, ProviderCatalog, ThinkingSupport } from './model-catalog';
|
import type { ModelEntry, ProviderCatalog, ThinkingSupport } from './model-catalog';
|
||||||
import { MODEL_CATALOG } from './model-catalog';
|
import { MODEL_CATALOG } from './model-catalog';
|
||||||
import type { RemoteModelInfo, RemoteThinkingSupport } from './management-api-types';
|
import type { RemoteModelInfo, RemoteThinkingSupport } from './management-api-types';
|
||||||
|
import { getDeniedModelIdReasonForProvider } from './model-id-normalizer';
|
||||||
|
|
||||||
const CACHE_FILE_NAME = 'model-catalog-cache.json';
|
const CACHE_FILE_NAME = 'model-catalog-cache.json';
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
@@ -143,11 +144,18 @@ export function mergeCatalog(
|
|||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
remoteModels: RemoteModelInfo[]
|
remoteModels: RemoteModelInfo[]
|
||||||
): ProviderCatalog | undefined {
|
): ProviderCatalog | undefined {
|
||||||
|
const filteredRemoteModels =
|
||||||
|
provider === 'agy'
|
||||||
|
? remoteModels.filter(
|
||||||
|
(remoteModel) => !getDeniedModelIdReasonForProvider(remoteModel.id, provider)
|
||||||
|
)
|
||||||
|
: remoteModels;
|
||||||
|
|
||||||
const staticCatalog = MODEL_CATALOG[provider];
|
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 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
|
// Build map of static models by lowercase ID for fast lookup
|
||||||
const staticMap = new Map<string, ModelEntry>();
|
const staticMap = new Map<string, ModelEntry>();
|
||||||
@@ -161,7 +169,7 @@ export function mergeCatalog(
|
|||||||
const mergedIds = new Set<string>();
|
const mergedIds = new Set<string>();
|
||||||
const mergedModels: ModelEntry[] = [];
|
const mergedModels: ModelEntry[] = [];
|
||||||
|
|
||||||
for (const remote of remoteModels) {
|
for (const remote of filteredRemoteModels) {
|
||||||
const remoteEntry = mapRemoteToModelEntry(remote);
|
const remoteEntry = mapRemoteToModelEntry(remote);
|
||||||
const staticEntry = staticMap.get(remote.id.toLowerCase());
|
const staticEntry = staticMap.get(remote.id.toLowerCase());
|
||||||
mergedIds.add(remote.id.toLowerCase());
|
mergedIds.add(remote.id.toLowerCase());
|
||||||
@@ -185,6 +193,9 @@ export function mergeCatalog(
|
|||||||
// Add static-only models not in remote
|
// Add static-only models not in remote
|
||||||
if (staticCatalog) {
|
if (staticCatalog) {
|
||||||
for (const model of staticCatalog.models) {
|
for (const model of staticCatalog.models) {
|
||||||
|
if (getDeniedModelIdReasonForProvider(model.id, provider)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!mergedIds.has(model.id.toLowerCase())) {
|
if (!mergedIds.has(model.id.toLowerCase())) {
|
||||||
mergedModels.push(model);
|
mergedModels.push(model);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { CLIPROXY_SUPPORTED_PROVIDERS, CompositeTierConfig } from '../config/unified-config-types';
|
import { CLIPROXY_SUPPORTED_PROVIDERS, CompositeTierConfig } from '../config/unified-config-types';
|
||||||
import type { CLIProxyProvider } from './types';
|
import type { CLIProxyProvider } from './types';
|
||||||
|
import { getDeniedModelIdReasonForProvider } from './model-id-normalizer';
|
||||||
|
|
||||||
export const VALID_COMPOSITE_TIERS = ['opus', 'sonnet', 'haiku'] as const;
|
export const VALID_COMPOSITE_TIERS = ['opus', 'sonnet', 'haiku'] as const;
|
||||||
export type CompositeTierName = (typeof VALID_COMPOSITE_TIERS)[number];
|
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(', ')}`;
|
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) {
|
if (tierValue.fallback !== undefined) {
|
||||||
const fallback = tierValue.fallback;
|
const fallback = tierValue.fallback;
|
||||||
if (!isRecord(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(', ')}`;
|
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) {
|
if (fallback.provider === provider && fallback.model === model) {
|
||||||
return `Circular fallback in tier '${tier}': fallback cannot point to same provider and 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 {
|
import {
|
||||||
canonicalizeModelIdForProvider,
|
canonicalizeModelIdForProvider,
|
||||||
MODEL_ENV_VAR_KEYS,
|
MODEL_ENV_VAR_KEYS,
|
||||||
|
migrateDeniedAntigravityModelAliases,
|
||||||
normalizeModelEnvVarsForProvider,
|
normalizeModelEnvVarsForProvider,
|
||||||
normalizeModelIdForProvider,
|
normalizeModelIdForProvider,
|
||||||
} from '../model-id-normalizer';
|
} from '../model-id-normalizer';
|
||||||
@@ -79,6 +80,9 @@ function migrateDeprecatedModelNames(
|
|||||||
canonical = UPSTREAM_MODEL_PREFIX + canonical.slice(DEPRECATED_MODEL_PREFIX.length);
|
canonical = UPSTREAM_MODEL_PREFIX + canonical.slice(DEPRECATED_MODEL_PREFIX.length);
|
||||||
}
|
}
|
||||||
canonical = normalizeModelIdForProvider(canonical, provider);
|
canonical = normalizeModelIdForProvider(canonical, provider);
|
||||||
|
if (provider === 'agy') {
|
||||||
|
canonical = migrateDeniedAntigravityModelAliases(canonical);
|
||||||
|
}
|
||||||
|
|
||||||
if (canonical !== value) {
|
if (canonical !== value) {
|
||||||
settings.env[key] = canonical;
|
settings.env[key] = canonical;
|
||||||
@@ -94,7 +98,8 @@ function migrateDeprecatedModelNames(
|
|||||||
for (const key of PRESET_MODEL_KEYS) {
|
for (const key of PRESET_MODEL_KEYS) {
|
||||||
const value = presetRecord[key];
|
const value = presetRecord[key];
|
||||||
if (typeof value !== 'string') continue;
|
if (typeof value !== 'string') continue;
|
||||||
const canonical = normalizeModelIdForProvider(value, provider);
|
let canonical = normalizeModelIdForProvider(value, provider);
|
||||||
|
canonical = migrateDeniedAntigravityModelAliases(canonical);
|
||||||
if (canonical !== value) {
|
if (canonical !== value) {
|
||||||
presetRecord[key] = canonical;
|
presetRecord[key] = canonical;
|
||||||
migrated = true;
|
migrated = true;
|
||||||
@@ -564,7 +569,10 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
|||||||
for (const key of MODEL_ENV_VAR_KEYS) {
|
for (const key of MODEL_ENV_VAR_KEYS) {
|
||||||
const current = mergedEnv[key];
|
const current = mergedEnv[key];
|
||||||
if (typeof current !== 'string' || current.trim().length === 0) continue;
|
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) {
|
if (canonical !== current) {
|
||||||
mergedEnv[key] = canonical;
|
mergedEnv[key] = canonical;
|
||||||
mutated = true;
|
mutated = true;
|
||||||
@@ -579,7 +587,10 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
|||||||
for (const key of PRESET_MODEL_KEYS) {
|
for (const key of PRESET_MODEL_KEYS) {
|
||||||
const value = presetRecord[key];
|
const value = presetRecord[key];
|
||||||
if (typeof value !== 'string') continue;
|
if (typeof value !== 'string') continue;
|
||||||
const canonical = canonicalizeModelIdForProvider(value, provider);
|
let canonical = canonicalizeModelIdForProvider(value, provider);
|
||||||
|
if (provider === 'agy') {
|
||||||
|
canonical = migrateDeniedAntigravityModelAliases(canonical);
|
||||||
|
}
|
||||||
if (canonical !== value) {
|
if (canonical !== value) {
|
||||||
presetRecord[key] = canonical;
|
presetRecord[key] = canonical;
|
||||||
mutated = true;
|
mutated = true;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getModelMappingFromConfig } from '../base-config-loader';
|
|||||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager';
|
import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager';
|
||||||
import { getCachedCatalog } from '../catalog-cache';
|
import { getCachedCatalog } from '../catalog-cache';
|
||||||
|
import { getDeniedModelIdReasonForProvider } from '../model-id-normalizer';
|
||||||
import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-resolver';
|
import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-resolver';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from './port-manager';
|
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
|
* v9: Added resilient alias compatibility expansion and cache-assisted alias enrichment
|
||||||
* v10: Migrated deprecated gemini-claude-* aliases to upstream claude-* aliases
|
* v10: Migrated deprecated gemini-claude-* aliases to upstream claude-* aliases
|
||||||
* v11: Migrated deprecated claude-sonnet-4-6-thinking aliases to claude-sonnet-4-6
|
* 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 {
|
interface OAuthModelAliasEntry {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -63,9 +65,6 @@ const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [
|
|||||||
{ name: 'claude-sonnet-4-6', alias: 'claude-sonnet-4-6', fork: true },
|
{ name: 'claude-sonnet-4-6', alias: 'claude-sonnet-4-6', fork: true },
|
||||||
// Backward compatibility: legacy sonnet thinking alias now routes to canonical model ID.
|
// 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-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 },
|
{ name: 'claude-opus-4-6-thinking', alias: 'claude-opus-4-6-thinking', fork: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -134,6 +133,8 @@ function addAliasEntry(
|
|||||||
fork: entry.fork || undefined,
|
fork: entry.fork || undefined,
|
||||||
};
|
};
|
||||||
if (!normalized.name || !normalized.alias) return;
|
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 key = `${normalized.name}\u0000${normalized.alias}`;
|
||||||
const existingIndex = indexByKey.get(key);
|
const existingIndex = indexByKey.get(key);
|
||||||
|
|||||||
@@ -23,12 +23,13 @@ const CLAUDE_DOTTED_THINKING_REGEX =
|
|||||||
const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX =
|
const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX =
|
||||||
/claude-sonnet-4(?:[.-])6-thinking(?=(?:$|-|\[|\(|\/))/gi;
|
/claude-sonnet-4(?:[.-])6-thinking(?=(?:$|-|\[|\(|\/))/gi;
|
||||||
const CANONICAL_ANTIGRAVITY_SONNET_46_MODEL = 'claude-sonnet-4-6';
|
const CANONICAL_ANTIGRAVITY_SONNET_46_MODEL = 'claude-sonnet-4-6';
|
||||||
const DEPRECATED_ANTIGRAVITY_OPUS_45_THINKING_REGEX =
|
const DENIED_ANTIGRAVITY_MODEL_REGEX =
|
||||||
/claude-opus-4(?:[.-])5-thinking(?=(?:$|-|\[|\(|\/))/gi;
|
/claude-(?:opus|sonnet)-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/i;
|
||||||
const CANONICAL_ANTIGRAVITY_OPUS_46_THINKING_MODEL = 'claude-opus-4-6-thinking';
|
const DENIED_ANTIGRAVITY_OPUS_45_REGEX =
|
||||||
const DEPRECATED_ANTIGRAVITY_SONNET_45_THINKING_REGEX =
|
/claude-opus-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi;
|
||||||
/claude-sonnet-4(?:[.-])5-thinking(?=(?:$|-|\[|\(|\/))/gi;
|
const DENIED_ANTIGRAVITY_SONNET_45_REGEX =
|
||||||
const DEPRECATED_ANTIGRAVITY_SONNET_45_REGEX = /claude-sonnet-4(?:[.-])5(?=(?:$|-|\[|\(|\/))/gi;
|
/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;
|
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
||||||
|
|
||||||
function trimModelId(model: string): string {
|
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.
|
* Canonicalize legacy aliases to `claude-sonnet-4-6` while preserving suffixes.
|
||||||
*/
|
*/
|
||||||
export function normalizeDeprecatedAntigravityModelAliases(model: string): string {
|
export function normalizeDeprecatedAntigravityModelAliases(model: string): string {
|
||||||
return model
|
return model.replace(
|
||||||
.replace(
|
DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX,
|
||||||
DEPRECATED_ANTIGRAVITY_OPUS_45_THINKING_REGEX,
|
CANONICAL_ANTIGRAVITY_SONNET_46_MODEL
|
||||||
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(
|
* Migrate denylisted Antigravity Claude 4.5 aliases to supported Claude 4.6 IDs.
|
||||||
DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX,
|
* This is intended for repairing stale local settings at runtime.
|
||||||
CANONICAL_ANTIGRAVITY_SONNET_46_MODEL
|
*/
|
||||||
);
|
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 { warn } from '../../utils/ui';
|
||||||
import { getCcsDir } from '../../utils/config-manager';
|
import { getCcsDir } from '../../utils/config-manager';
|
||||||
import { validateCompositeTiers } from '../composite-validator';
|
import { validateCompositeTiers } from '../composite-validator';
|
||||||
|
import { getDeniedModelIdReasonForProvider } from '../model-id-normalizer';
|
||||||
import {
|
import {
|
||||||
createSettingsFile,
|
createSettingsFile,
|
||||||
createSettingsFileUnified,
|
createSettingsFileUnified,
|
||||||
@@ -115,6 +116,11 @@ export function createVariant(
|
|||||||
target: TargetType = 'claude'
|
target: TargetType = 'claude'
|
||||||
): VariantOperationResult {
|
): VariantOperationResult {
|
||||||
try {
|
try {
|
||||||
|
const deniedModelReason = getDeniedModelIdReasonForProvider(model, provider);
|
||||||
|
if (deniedModelReason) {
|
||||||
|
return { success: false, error: deniedModelReason };
|
||||||
|
}
|
||||||
|
|
||||||
// Validate provider/backend compatibility (block kiro/ghcp on original backend)
|
// Validate provider/backend compatibility (block kiro/ghcp on original backend)
|
||||||
const backendError = validateProviderBackend(provider);
|
const backendError = validateProviderBackend(provider);
|
||||||
if (backendError) {
|
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
|
// Update settings file
|
||||||
if (existing.settings) {
|
if (existing.settings) {
|
||||||
const settingsPath = existing.settings.replace(/^~/, os.homedir());
|
const settingsPath = existing.settings.replace(/^~/, os.homedir());
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import * as os from 'os';
|
|||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import { ToolNameMapper, type Tool, type ContentBlock } from './tool-name-mapper';
|
import { ToolNameMapper, type Tool, type ContentBlock } from './tool-name-mapper';
|
||||||
import { sanitizeToolSchemas } from './schema-sanitizer';
|
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';
|
import { getCcsDir } from '../utils/config-manager';
|
||||||
|
|
||||||
export interface ToolSanitizationProxyConfig {
|
export interface ToolSanitizationProxyConfig {
|
||||||
@@ -211,6 +215,15 @@ export class ToolSanitizationProxy {
|
|||||||
let modifiedBody = parsed;
|
let modifiedBody = parsed;
|
||||||
if (isRecord(modifiedBody) && typeof modifiedBody.model === 'string') {
|
if (isRecord(modifiedBody) && typeof modifiedBody.model === 'string') {
|
||||||
const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname);
|
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);
|
const normalizedModel = normalizeModelIdForRouting(modifiedBody.model, providerFromPath);
|
||||||
if (normalizedModel !== modifiedBody.model) {
|
if (normalizedModel !== modifiedBody.model) {
|
||||||
this.writeLog(
|
this.writeLog(
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
|
|||||||
import {
|
import {
|
||||||
MODEL_ENV_VAR_KEYS,
|
MODEL_ENV_VAR_KEYS,
|
||||||
canonicalizeModelIdForProvider,
|
canonicalizeModelIdForProvider,
|
||||||
|
getDeniedModelIdReasonForProvider,
|
||||||
} from '../../cliproxy/model-id-normalizer';
|
} from '../../cliproxy/model-id-normalizer';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -590,6 +591,12 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise<voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deniedReason = getDeniedModelIdReasonForProvider(model, provider);
|
||||||
|
if (deniedReason) {
|
||||||
|
res.status(400).json({ error: deniedReason });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get the settings file for this provider
|
// Get the settings file for this provider
|
||||||
const ccsDir = getCliproxyWritablePath();
|
const ccsDir = getCliproxyWritablePath();
|
||||||
const settingsPath = path.join(ccsDir, `${provider}.settings.json`);
|
const settingsPath = path.join(ccsDir, `${provider}.settings.json`);
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import { updateSettingsFile } from './route-helpers';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
function isDenylistError(message: string | undefined): boolean {
|
||||||
|
return typeof message === 'string' && message.toLowerCase().includes('denylist');
|
||||||
|
}
|
||||||
|
|
||||||
export function parseTarget(rawTarget: unknown): TargetType | null {
|
export function parseTarget(rawTarget: unknown): TargetType | null {
|
||||||
if (rawTarget === undefined || rawTarget === null || rawTarget === '') {
|
if (rawTarget === undefined || rawTarget === null || rawTarget === '') {
|
||||||
return null;
|
return null;
|
||||||
@@ -113,7 +117,8 @@ router.post('/', (req: Request, res: Response): void => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +208,8 @@ router.put('/:name', (req: Request, res: Response): void => {
|
|||||||
...(hasTargetUpdate && parsedTarget && { target: parsedTarget }),
|
...(hasTargetUpdate && parsedTarget && { target: parsedTarget }),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 { expandPath } from '../../utils/helpers';
|
||||||
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
|
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
|
||||||
import { resolveDroidProvider } from '../../targets/droid-provider';
|
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';
|
import type { Config, Settings } from '../../types/config';
|
||||||
|
|
||||||
/** Model mapping for API profiles */
|
/** Model mapping for API profiles */
|
||||||
@@ -18,6 +24,34 @@ export interface ModelMapping {
|
|||||||
haikuModel?: string;
|
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.
|
* Read config safely with fallback.
|
||||||
* Uses loadConfigSafe which supports both unified (config.yaml) and legacy (config.json).
|
* Uses loadConfigSafe which supports both unified (config.yaml) and legacy (config.json).
|
||||||
@@ -70,6 +104,16 @@ export function createSettingsFile(
|
|||||||
): string {
|
): string {
|
||||||
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
||||||
const { model, opusModel, sonnetModel, haikuModel } = models;
|
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({
|
const droidProvider = resolveDroidProvider({
|
||||||
provider,
|
provider,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -114,6 +158,24 @@ export function updateSettingsFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settings = loadSettings(settingsPath);
|
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) {
|
if (updates.baseUrl) {
|
||||||
settings.env = settings.env || {};
|
settings.env = settings.env || {};
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ import {
|
|||||||
import type { Settings } from '../../types/config';
|
import type { Settings } from '../../types/config';
|
||||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
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 router = Router();
|
||||||
const MODEL_ENV_KEYS = [
|
const MODEL_ENV_KEYS = [
|
||||||
@@ -139,14 +143,89 @@ function resolveProviderForProfile(profileOrVariant: string): CLIProxyProvider |
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalizeProfileModelId(profileOrVariant: string, modelId: string): string {
|
function resolveProviderFromBaseUrl(baseUrl: unknown): CLIProxyProvider | null {
|
||||||
const provider = resolveProviderForProfile(profileOrVariant);
|
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;
|
if (!provider) return modelId;
|
||||||
return canonicalizeModelIdForProvider(modelId, provider);
|
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 {
|
function canonicalizeProfileSettings(profileOrVariant: string, settings: Settings): Settings {
|
||||||
const provider = resolveProviderForProfile(profileOrVariant);
|
const provider = resolveProviderForSettings(profileOrVariant, settings);
|
||||||
if (!provider) return settings;
|
if (!provider) return settings;
|
||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
@@ -167,13 +246,18 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(settings.presets)) {
|
if (Array.isArray(settings.presets)) {
|
||||||
const normalizedPresets = settings.presets.map((preset) => {
|
const normalizedPresets = settings.presets.flatMap((preset) => {
|
||||||
const normalizedPreset = { ...preset };
|
const normalizedPreset = { ...preset };
|
||||||
let presetChanged = false;
|
let presetChanged = false;
|
||||||
|
|
||||||
for (const key of PRESET_MODEL_KEYS) {
|
for (const key of PRESET_MODEL_KEYS) {
|
||||||
const value = normalizedPreset[key];
|
const value = normalizedPreset[key];
|
||||||
if (typeof value !== 'string') continue;
|
if (typeof value !== 'string') continue;
|
||||||
|
const deniedReason = getDeniedModelIdReasonForProvider(value, provider);
|
||||||
|
if (deniedReason) {
|
||||||
|
changed = true;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const canonical = canonicalizeModelIdForProvider(value, provider);
|
const canonical = canonicalizeModelIdForProvider(value, provider);
|
||||||
if (canonical !== value) {
|
if (canonical !== value) {
|
||||||
normalizedPreset[key] = canonical;
|
normalizedPreset[key] = canonical;
|
||||||
@@ -314,6 +398,12 @@ router.put('/:profile', (req: Request, res: Response): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deniedModelReason = validateProfileSettingsModelDenylist(profile, settings as Settings);
|
||||||
|
if (deniedModelReason) {
|
||||||
|
res.status(400).json({ error: deniedModelReason });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedSettings = canonicalizeProfileSettings(profile, settings as Settings);
|
const normalizedSettings = canonicalizeProfileSettings(profile, settings as Settings);
|
||||||
|
|
||||||
// Deduplicate CCS hooks to prevent accumulation (fixes #450)
|
// 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 =>
|
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 normalizedDefaultModel = normalizePresetModel(defaultModel);
|
||||||
const normalizedOpusModel = normalizePresetModel(opus || 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 = {
|
const validTier = {
|
||||||
provider: 'agy',
|
provider: 'agy',
|
||||||
model: 'claude-sonnet-4-5-thinking',
|
model: 'claude-sonnet-4-6',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('validateCompositeDefaultTier', () => {
|
describe('validateCompositeDefaultTier', () => {
|
||||||
@@ -77,4 +77,17 @@ describe('validateCompositeTiers', () => {
|
|||||||
|
|
||||||
expect(error).toContain("Circular fallback in tier 'opus'");
|
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',
|
default_tier: 'sonnet',
|
||||||
tiers: {
|
tiers: {
|
||||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
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' },
|
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||||
},
|
},
|
||||||
settings: 'cliproxy/composite-test.settings.json',
|
settings: 'cliproxy/composite-test.settings.json',
|
||||||
@@ -99,7 +99,7 @@ cliproxy:
|
|||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.variant?.tiers?.opus.model).toBe('claude-opus-4-6-thinking');
|
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
|
expect(result.variant?.tiers?.haiku.model).toBe('claude-haiku-4-5-20251001'); // Unchanged
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ cliproxy:
|
|||||||
default_tier: 'sonnet',
|
default_tier: 'sonnet',
|
||||||
tiers: {
|
tiers: {
|
||||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
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' },
|
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||||
},
|
},
|
||||||
settings: 'cliproxy/composite-test.settings.json',
|
settings: 'cliproxy/composite-test.settings.json',
|
||||||
@@ -144,7 +144,7 @@ cliproxy:
|
|||||||
default_tier: 'sonnet',
|
default_tier: 'sonnet',
|
||||||
tiers: {
|
tiers: {
|
||||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
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' },
|
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||||
},
|
},
|
||||||
settings: 'cliproxy/composite-test.settings.json',
|
settings: 'cliproxy/composite-test.settings.json',
|
||||||
@@ -188,7 +188,7 @@ cliproxy:
|
|||||||
thinking: 'xhigh',
|
thinking: 'xhigh',
|
||||||
account: 'team-a',
|
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' },
|
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||||
},
|
},
|
||||||
settings: 'cliproxy/composite-test.settings.json',
|
settings: 'cliproxy/composite-test.settings.json',
|
||||||
@@ -228,7 +228,7 @@ cliproxy:
|
|||||||
default_tier: 'sonnet',
|
default_tier: 'sonnet',
|
||||||
tiers: {
|
tiers: {
|
||||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
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' },
|
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||||
},
|
},
|
||||||
settings: customSettingsPath,
|
settings: customSettingsPath,
|
||||||
@@ -244,9 +244,9 @@ cliproxy:
|
|||||||
env: {
|
env: {
|
||||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
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_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',
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||||
CUSTOM_ENV: 'preserve-this',
|
CUSTOM_ENV: 'preserve-this',
|
||||||
},
|
},
|
||||||
@@ -261,7 +261,7 @@ cliproxy:
|
|||||||
|
|
||||||
const result = updateCompositeVariant('test', {
|
const result = updateCompositeVariant('test', {
|
||||||
tiers: {
|
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[] };
|
hooks: { PreToolUse: unknown[] };
|
||||||
customPreset: boolean;
|
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.env.CUSTOM_ENV).toBe('preserve-this');
|
||||||
expect(updatedSettings.hooks.PreToolUse.length).toBe(1);
|
expect(updatedSettings.hooks.PreToolUse.length).toBe(1);
|
||||||
expect(updatedSettings.customPreset).toBe(true);
|
expect(updatedSettings.customPreset).toBe(true);
|
||||||
@@ -377,7 +377,7 @@ cliproxy:
|
|||||||
defaultTier: 'sonnet',
|
defaultTier: 'sonnet',
|
||||||
tiers: {
|
tiers: {
|
||||||
opus: { provider: 'gemini', model: 'gemini-2.5-pro' },
|
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 {
|
} as unknown as {
|
||||||
opus: { provider: 'gemini' | 'codex' | 'agy'; model: string };
|
opus: { provider: 'gemini' | 'codex' | 'agy'; model: string };
|
||||||
sonnet: { provider: 'gemini' | 'codex' | 'agy'; model: string };
|
sonnet: { provider: 'gemini' | 'codex' | 'agy'; model: string };
|
||||||
@@ -395,7 +395,7 @@ cliproxy:
|
|||||||
defaultTier: 'sonnet',
|
defaultTier: 'sonnet',
|
||||||
tiers: {
|
tiers: {
|
||||||
opus: null,
|
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' },
|
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||||
} as unknown as {
|
} as unknown as {
|
||||||
opus: { provider: 'gemini' | 'codex' | 'agy'; model: string };
|
opus: { provider: 'gemini' | 'codex' | 'agy'; model: string };
|
||||||
@@ -414,7 +414,7 @@ cliproxy:
|
|||||||
defaultTier: 'sonnet',
|
defaultTier: 'sonnet',
|
||||||
tiers: {
|
tiers: {
|
||||||
opus: { provider: 'gemini', model: 'gemini-2.5-pro' },
|
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' },
|
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -479,7 +479,7 @@ cliproxy:
|
|||||||
default_tier: 'sonnet',
|
default_tier: 'sonnet',
|
||||||
tiers: {
|
tiers: {
|
||||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
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' },
|
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||||
},
|
},
|
||||||
settings: 'cliproxy/composite-test.settings.json',
|
settings: 'cliproxy/composite-test.settings.json',
|
||||||
@@ -547,7 +547,7 @@ cliproxy:
|
|||||||
model: gemini-3-pro-preview
|
model: gemini-3-pro-preview
|
||||||
sonnet:
|
sonnet:
|
||||||
provider: agy
|
provider: agy
|
||||||
model: claude-sonnet-4-5-thinking
|
model: claude-sonnet-4-6
|
||||||
haiku:
|
haiku:
|
||||||
provider: agy
|
provider: agy
|
||||||
model: claude-haiku-4-5-20251001
|
model: claude-haiku-4-5-20251001
|
||||||
@@ -588,7 +588,7 @@ cliproxy:
|
|||||||
thinking: xhigh
|
thinking: xhigh
|
||||||
sonnet:
|
sonnet:
|
||||||
provider: agy
|
provider: agy
|
||||||
model: claude-sonnet-4-5-thinking
|
model: claude-sonnet-4-6
|
||||||
thinking: medium
|
thinking: medium
|
||||||
haiku:
|
haiku:
|
||||||
provider: agy
|
provider: agy
|
||||||
@@ -632,7 +632,7 @@ cliproxy:
|
|||||||
model: claude-opus-4-6-thinking
|
model: claude-opus-4-6-thinking
|
||||||
sonnet:
|
sonnet:
|
||||||
provider: agy
|
provider: agy
|
||||||
model: claude-sonnet-4-5-thinking
|
model: claude-sonnet-4-6
|
||||||
haiku:
|
haiku:
|
||||||
provider: agy
|
provider: agy
|
||||||
model: claude-haiku-4-5-20251001
|
model: claude-haiku-4-5-20251001
|
||||||
@@ -671,7 +671,7 @@ cliproxy:
|
|||||||
model: claude-opus-4-6-thinking
|
model: claude-opus-4-6-thinking
|
||||||
sonnet:
|
sonnet:
|
||||||
provider: agy
|
provider: agy
|
||||||
model: claude-sonnet-4-5-thinking
|
model: claude-sonnet-4-6
|
||||||
settings: cliproxy/bad.settings.json
|
settings: cliproxy/bad.settings.json
|
||||||
port: 8318
|
port: 8318
|
||||||
good:
|
good:
|
||||||
|
|||||||
@@ -584,9 +584,16 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
|
|||||||
const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
|
const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
|
||||||
|
|
||||||
// Claude aliases should have fork: true
|
// 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('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('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(
|
assert(
|
||||||
!config.includes('alias: gemini-claude-'),
|
!config.includes('alias: gemini-claude-'),
|
||||||
'Should not emit deprecated gemini-claude aliases'
|
'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');
|
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', () => {
|
it('migrates codex preset model mappings to canonical IDs', () => {
|
||||||
writeSettings(
|
writeSettings(
|
||||||
settingsPath,
|
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_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_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_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]?.default).toBe('claude-sonnet-4-6');
|
||||||
expect(repaired.presets?.[0]?.opus).toBe('claude-opus-4-6-thinking');
|
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]?.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', () => {
|
it('migrates codex effort-suffixed preset IDs during ensureProviderSettings', () => {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { describe, it, expect } from 'bun:test';
|
|||||||
import {
|
import {
|
||||||
canonicalizeModelIdForProvider,
|
canonicalizeModelIdForProvider,
|
||||||
extractProviderFromPathname,
|
extractProviderFromPathname,
|
||||||
|
getDeniedModelIdReasonForProvider,
|
||||||
|
isDeniedAntigravityModelId,
|
||||||
isAntigravityProvider,
|
isAntigravityProvider,
|
||||||
|
migrateDeniedAntigravityModelAliases,
|
||||||
normalizeClaudeDottedMajorMinor,
|
normalizeClaudeDottedMajorMinor,
|
||||||
normalizeClaudeDottedThinkingMajorMinor,
|
normalizeClaudeDottedThinkingMajorMinor,
|
||||||
normalizeModelIdForProvider,
|
normalizeModelIdForProvider,
|
||||||
@@ -65,12 +68,12 @@ describe('model-id-normalizer', () => {
|
|||||||
'claude-opus-4-6-thinking'
|
'claude-opus-4-6-thinking'
|
||||||
);
|
);
|
||||||
expect(normalizeModelIdForProvider('claude-opus-4.5-thinking', 'agy')).toBe(
|
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(
|
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(
|
expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'gemini')).toBe(
|
||||||
'claude-opus-4.6-thinking'
|
'claude-opus-4.6-thinking'
|
||||||
);
|
);
|
||||||
@@ -113,9 +116,9 @@ describe('model-id-normalizer', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalized = normalizeModelEnvVarsForProvider(input, 'agy');
|
const normalized = normalizeModelEnvVarsForProvider(input, 'agy');
|
||||||
expect(normalized.ANTHROPIC_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-6-thinking');
|
expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-thinking');
|
||||||
expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5');
|
||||||
expect(normalized.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
|
expect(normalized.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
|
||||||
expect(normalized.UNRELATED).toBe('keep-me');
|
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.ANTHROPIC_MODEL).toBe('claude-sonnet-4.5-thinking');
|
||||||
expect(unchanged.UNRELATED).toBe('keep-me');
|
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 () => {
|
it('does not rewrite AGY legacy aliases on non-AGY provider routes', async () => {
|
||||||
const proxy = new ToolSanitizationProxy({
|
const proxy = new ToolSanitizationProxy({
|
||||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ cliproxy:
|
|||||||
expect(result.error).toContain('Changing provider requires model update');
|
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', () => {
|
it('updates provider and regenerates provider-specific core env in same settings file', () => {
|
||||||
const result = updateVariant('demo', {
|
const result = updateVariant('demo', {
|
||||||
provider: 'codex',
|
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');
|
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 () => {
|
it('canonicalizes Codex effort suffix and syncs linked core model env vars', async () => {
|
||||||
const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'codex.settings.json');
|
const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'codex.settings.json');
|
||||||
writeSettings(settingsPath, {
|
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');
|
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 () => {
|
it('does not crash on malformed non-string preset model values', async () => {
|
||||||
const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
|
const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
|
||||||
writeSettings(settingsPath, {
|
writeSettings(settingsPath, {
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||||
import { usePrivacy } from '@/contexts/privacy-context';
|
import { usePrivacy } from '@/contexts/privacy-context';
|
||||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
||||||
|
import { isDeniedAgyModelId } from '@/lib/utils';
|
||||||
|
|
||||||
const singleProviderSchema = z.object({
|
const singleProviderSchema = z.object({
|
||||||
name: z
|
name: z
|
||||||
@@ -68,6 +70,12 @@ const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({
|
|||||||
value: id,
|
value: id,
|
||||||
label: getProviderDisplayName(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) {
|
export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -99,6 +107,12 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
const providerAccounts = providerAuth?.accounts || [];
|
const providerAccounts = providerAuth?.accounts || [];
|
||||||
|
|
||||||
const onSubmitSingle = async (data: SingleProviderFormData) => {
|
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 {
|
try {
|
||||||
await createMutation.mutateAsync(data);
|
await createMutation.mutateAsync(data);
|
||||||
singleForm.reset();
|
singleForm.reset();
|
||||||
@@ -109,6 +123,14 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSubmitComposite = async (data: CompositeFormData) => {
|
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 {
|
try {
|
||||||
await createMutation.mutateAsync({
|
await createMutation.mutateAsync({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useUpdateVariant } from '@/hooks/use-cliproxy';
|
import { useUpdateVariant } from '@/hooks/use-cliproxy';
|
||||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
||||||
import type { UpdateVariant, Variant } from '@/lib/api-client';
|
import type { UpdateVariant, Variant } from '@/lib/api-client';
|
||||||
|
import { isDeniedAgyModelId } from '@/lib/utils';
|
||||||
|
|
||||||
const singleProviderSchema = z.object({
|
const singleProviderSchema = z.object({
|
||||||
provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }),
|
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 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 {
|
function normalizeOptionalValue(value?: string): string | undefined {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
return trimmed ? trimmed : undefined;
|
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 {
|
function isSingleVariantOnlyTargetChange(variant: Variant, data: SingleProviderFormData): boolean {
|
||||||
const currentTarget = variant.target || 'claude';
|
const currentTarget = variant.target || 'claude';
|
||||||
const currentModel = normalizeOptionalValue(variant.model);
|
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) {
|
if (Object.keys(payload).length === 0) {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
return;
|
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) {
|
if (Object.keys(payload).length === 0) {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
useDeletePreset,
|
useDeletePreset,
|
||||||
} from '@/hooks/use-cliproxy';
|
} from '@/hooks/use-cliproxy';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
||||||
|
import { isDeniedAgyModelId } from '@/lib/utils';
|
||||||
import i18n from '@/lib/i18n';
|
import i18n from '@/lib/i18n';
|
||||||
import { usePrivacy } from '@/contexts/privacy-context';
|
import { usePrivacy } from '@/contexts/privacy-context';
|
||||||
import { useProviderEditor } from './use-provider-editor';
|
import { useProviderEditor } from './use-provider-editor';
|
||||||
@@ -57,10 +58,23 @@ export function ProviderEditor({
|
|||||||
const { data: presetsData } = usePresets(provider);
|
const { data: presetsData } = usePresets(provider);
|
||||||
const createPresetMutation = useCreatePreset();
|
const createPresetMutation = useCreatePreset();
|
||||||
const deletePresetMutation = useDeletePreset();
|
const deletePresetMutation = useDeletePreset();
|
||||||
const savedPresets = presetsData?.presets || [];
|
|
||||||
|
|
||||||
// Use baseProvider for model filtering (for variants, this is the parent provider)
|
// Use baseProvider for model filtering (for variants, this is the parent provider)
|
||||||
const modelFilterProvider = baseProvider || 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(() => {
|
const providerModels = useMemo(() => {
|
||||||
if (!modelsData?.models) return [];
|
if (!modelsData?.models) return [];
|
||||||
@@ -77,10 +91,12 @@ export function ProviderEditor({
|
|||||||
const owners = ownerMap[modelFilterProvider.toLowerCase()] || [
|
const owners = ownerMap[modelFilterProvider.toLowerCase()] || [
|
||||||
modelFilterProvider.toLowerCase(),
|
modelFilterProvider.toLowerCase(),
|
||||||
];
|
];
|
||||||
return modelsData.models.filter((m) =>
|
return modelsData.models.filter((m) => {
|
||||||
owners.some((o) => m.owned_by.toLowerCase().includes(o))
|
if (!owners.some((o) => m.owned_by.toLowerCase().includes(o))) return false;
|
||||||
);
|
if (!isAgyProvider) return true;
|
||||||
}, [modelsData, modelFilterProvider]);
|
return !isDeniedAgyModelId(m.id);
|
||||||
|
});
|
||||||
|
}, [isAgyProvider, modelsData, modelFilterProvider]);
|
||||||
|
|
||||||
const providerRoute = (baseProvider || provider).toLowerCase();
|
const providerRoute = (baseProvider || provider).toLowerCase();
|
||||||
|
|
||||||
@@ -131,6 +147,19 @@ export function ProviderEditor({
|
|||||||
const effectiveApiKey = authTokens?.apiKey?.value ?? 'ccs-internal-managed';
|
const effectiveApiKey = authTokens?.apiKey?.value ?? 'ccs-internal-managed';
|
||||||
|
|
||||||
const handleApplyPreset = (updates: Record<string, string>) => {
|
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;
|
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
|
||||||
updateEnvValues({
|
updateEnvValues({
|
||||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${providerRoute}`,
|
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) => {
|
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;
|
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
|
||||||
updateEnvValues({
|
updateEnvValues({
|
||||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${providerRoute}`,
|
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'));
|
toast.error(i18n.t('commonToast.enterPresetName'));
|
||||||
return;
|
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 } });
|
createPresetMutation.mutate({ profile: provider, data: { name: presetName, ...values } });
|
||||||
setCustomPresetOpen(false);
|
setCustomPresetOpen(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { ArrowLeft, User } from 'lucide-react';
|
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 { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||||
import type { VariantStepProps } from '../types';
|
import type { VariantStepProps } from '../types';
|
||||||
@@ -40,6 +40,10 @@ export function VariantStep({
|
|||||||
const catalogModels = MODEL_CATALOGS[selectedProvider]?.models || [];
|
const catalogModels = MODEL_CATALOGS[selectedProvider]?.models || [];
|
||||||
const isCustomModel = modelName && !catalogModels.some((m) => m.id === modelName);
|
const isCustomModel = modelName && !catalogModels.some((m) => m.id === modelName);
|
||||||
const [showCustomInput, setShowCustomInput] = useState(isCustomModel);
|
const [showCustomInput, setShowCustomInput] = useState(isCustomModel);
|
||||||
|
const deniedCustomModel =
|
||||||
|
selectedProvider === 'agy' && modelName.trim().length > 0
|
||||||
|
? isDeniedAgyModelId(modelName)
|
||||||
|
: false;
|
||||||
|
|
||||||
const handleModelSelect = (value: string) => {
|
const handleModelSelect = (value: string) => {
|
||||||
if (value === CUSTOM_MODEL_VALUE) {
|
if (value === CUSTOM_MODEL_VALUE) {
|
||||||
@@ -87,10 +91,20 @@ export function VariantStep({
|
|||||||
onChange={(e) => onModelChange(e.target.value)}
|
onChange={(e) => onModelChange(e.target.value)}
|
||||||
placeholder={t('setupVariant.modelPlaceholder')}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-xs text-primary hover:underline"
|
className="text-xs text-primary hover:underline"
|
||||||
onClick={() => setShowCustomInput(false)}
|
onClick={() => {
|
||||||
|
setShowCustomInput(false);
|
||||||
|
if (selectedProvider === 'agy' && isDeniedAgyModelId(modelName)) {
|
||||||
|
onModelChange('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('setupVariant.choosePresetInstead')}
|
{t('setupVariant.choosePresetInstead')}
|
||||||
</button>
|
</button>
|
||||||
@@ -140,7 +154,7 @@ export function VariantStep({
|
|||||||
<Button variant="ghost" onClick={onSkip}>
|
<Button variant="ghost" onClick={onSkip}>
|
||||||
{t('setupVariant.skip')}
|
{t('setupVariant.skip')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onCreate} disabled={!variantName || isPending}>
|
<Button onClick={onCreate} disabled={!variantName || isPending || deniedCustomModel}>
|
||||||
{isPending ? t('setupVariant.creating') : t('setupVariant.createVariant')}
|
{isPending ? t('setupVariant.creating') : t('setupVariant.createVariant')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -110,7 +110,7 @@ const resources = {
|
|||||||
variantNamePlaceholder: 'e.g., my-gemini, g3, flash',
|
variantNamePlaceholder: 'e.g., my-gemini, g3, flash',
|
||||||
invokeHintPrefix: 'Use this name to invoke:',
|
invokeHintPrefix: 'Use this name to invoke:',
|
||||||
model: 'Model',
|
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',
|
choosePresetInstead: 'Choose from presets instead',
|
||||||
selectModel: 'Select a model',
|
selectModel: 'Select a model',
|
||||||
customModelName: 'Custom model name...',
|
customModelName: 'Custom model name...',
|
||||||
@@ -1241,7 +1241,7 @@ const resources = {
|
|||||||
variantNamePlaceholder: '例如:my-gemini、g3、flash',
|
variantNamePlaceholder: '例如:my-gemini、g3、flash',
|
||||||
invokeHintPrefix: '可通过该名称调用:',
|
invokeHintPrefix: '可通过该名称调用:',
|
||||||
model: '模型',
|
model: '模型',
|
||||||
modelPlaceholder: '例如:gemini-2.5-pro、claude-opus-4-5',
|
modelPlaceholder: '例如:gemini-2.5-pro、claude-opus-4-6-thinking',
|
||||||
choosePresetInstead: '改为从预设中选择',
|
choosePresetInstead: '改为从预设中选择',
|
||||||
selectModel: '选择模型',
|
selectModel: '选择模型',
|
||||||
customModelName: '自定义模型名称...',
|
customModelName: '自定义模型名称...',
|
||||||
|
|||||||
+8
-3
@@ -217,10 +217,15 @@ export function getClaudeResetTime<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Known primary models to show when exhausted (removed from API response)
|
// 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 = [
|
const KNOWN_PRIMARY_MODELS = [
|
||||||
{ name: 'claude-opus-4-5-thinking', displayName: 'Claude Opus 4.5 (Thinking)' },
|
{ name: 'claude-opus-4-6-thinking', displayName: 'Claude Opus 4.6 (Thinking)' },
|
||||||
{ name: 'claude-sonnet-4-5', displayName: 'Claude Sonnet 4.5' },
|
{ name: 'claude-sonnet-4-6', displayName: 'Claude Sonnet 4.6' },
|
||||||
{ name: 'claude-sonnet-4-5-thinking', displayName: 'Claude Sonnet 4.5 (Thinking)' },
|
|
||||||
{ name: 'gpt-oss-120b', displayName: 'GPT-OSS 120B (Medium)' },
|
{ name: 'gpt-oss-120b', displayName: 'GPT-OSS 120B (Medium)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user