mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 00:16:38 +00:00
Merge pull request #653 from kaitranntt/kai/fix/agy-sonnet-46-model-migration
fix(agy): normalize sonnet 4.6 model IDs and migrate legacy alias
This commit is contained in:
16 files changed
+621
-93
No files matched your search
@@ -60,7 +60,11 @@ function stripCodexEffortSuffix(modelId: string): string {
|
||||
*
|
||||
* Returns true if migration was performed and file was updated.
|
||||
*/
|
||||
function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSettings): boolean {
|
||||
function migrateDeprecatedModelNames(
|
||||
settingsPath: string,
|
||||
provider: CLIProxyProvider,
|
||||
settings: ProviderSettings
|
||||
): boolean {
|
||||
if (!settings.env || typeof settings.env !== 'object') return false;
|
||||
|
||||
let migrated = false;
|
||||
@@ -68,13 +72,36 @@ function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSet
|
||||
const value = settings.env[key];
|
||||
if (typeof value !== 'string') continue;
|
||||
|
||||
// Check if the base model name (before any suffixes) uses the deprecated prefix
|
||||
if (value.toLowerCase().startsWith(DEPRECATED_MODEL_PREFIX)) {
|
||||
settings.env[key] = UPSTREAM_MODEL_PREFIX + value.slice(DEPRECATED_MODEL_PREFIX.length);
|
||||
let canonical = value;
|
||||
// Check if the base model name (before any suffixes) uses the deprecated prefix.
|
||||
if (canonical.toLowerCase().startsWith(DEPRECATED_MODEL_PREFIX)) {
|
||||
canonical = UPSTREAM_MODEL_PREFIX + canonical.slice(DEPRECATED_MODEL_PREFIX.length);
|
||||
}
|
||||
canonical = normalizeModelIdForProvider(canonical, provider);
|
||||
|
||||
if (canonical !== value) {
|
||||
settings.env[key] = canonical;
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'agy' && Array.isArray(settings.presets)) {
|
||||
for (const preset of settings.presets) {
|
||||
if (!preset || typeof preset !== 'object') continue;
|
||||
const presetRecord = preset as Record<string, unknown>;
|
||||
|
||||
for (const key of PRESET_MODEL_KEYS) {
|
||||
const value = presetRecord[key];
|
||||
if (typeof value !== 'string') continue;
|
||||
const canonical = normalizeModelIdForProvider(value, provider);
|
||||
if (canonical !== value) {
|
||||
presetRecord[key] = canonical;
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (migrated) {
|
||||
try {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
|
||||
@@ -409,7 +436,7 @@ export function getEffectiveEnvVars(
|
||||
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
// Migrate deprecated gemini-claude-* model names if present
|
||||
migrateDeprecatedModelNames(expandedPath, settings);
|
||||
migrateDeprecatedModelNames(expandedPath, provider, settings);
|
||||
// Migrate codex effort suffixes to canonical IDs if present
|
||||
migrateCodexEffortSuffixes(expandedPath, provider, settings);
|
||||
// Migrate legacy iFlow placeholders to supported model IDs
|
||||
@@ -444,7 +471,7 @@ export function getEffectiveEnvVars(
|
||||
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
// Migrate deprecated gemini-claude-* model names if present
|
||||
migrateDeprecatedModelNames(settingsPath, settings);
|
||||
migrateDeprecatedModelNames(settingsPath, provider, settings);
|
||||
// Migrate codex effort suffixes to canonical IDs if present
|
||||
migrateCodexEffortSuffixes(settingsPath, provider, settings);
|
||||
// Migrate legacy iFlow placeholders to supported model IDs
|
||||
@@ -532,6 +559,17 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Canonicalize provider-specific model aliases (e.g., AGY Sonnet 4.6 thinking legacy IDs).
|
||||
for (const key of MODEL_ENV_VAR_KEYS) {
|
||||
const current = mergedEnv[key];
|
||||
if (typeof current !== 'string' || current.trim().length === 0) continue;
|
||||
const canonical = normalizeModelIdForProvider(current, provider);
|
||||
if (canonical !== current) {
|
||||
mergedEnv[key] = canonical;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mutated) {
|
||||
return;
|
||||
}
|
||||
@@ -584,7 +622,7 @@ export function getRemoteEnvVars(
|
||||
const content = fs.readFileSync(expandedPath, 'utf-8');
|
||||
const settings: ProviderSettings = JSON.parse(content);
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
migrateDeprecatedModelNames(expandedPath, settings);
|
||||
migrateDeprecatedModelNames(expandedPath, provider, settings);
|
||||
migrateCodexEffortSuffixes(expandedPath, provider, settings);
|
||||
migrateIFlowPlaceholderModel(expandedPath, provider, settings);
|
||||
userEnvVars = settings.env as Record<string, string>;
|
||||
@@ -604,7 +642,7 @@ export function getRemoteEnvVars(
|
||||
const content = fs.readFileSync(settingsPath, 'utf-8');
|
||||
const settings: ProviderSettings = JSON.parse(content);
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
migrateDeprecatedModelNames(settingsPath, settings);
|
||||
migrateDeprecatedModelNames(settingsPath, provider, settings);
|
||||
migrateCodexEffortSuffixes(settingsPath, provider, settings);
|
||||
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
|
||||
userEnvVars = settings.env as Record<string, string>;
|
||||
|
||||
@@ -32,8 +32,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
||||
* v8: Added Gemini 3.1 preview aliases for provider routing compatibility
|
||||
* v9: Added resilient alias compatibility expansion and cache-assisted alias enrichment
|
||||
* v10: Migrated deprecated gemini-claude-* aliases to upstream claude-* aliases
|
||||
* v11: Migrated deprecated claude-sonnet-4-6-thinking aliases to claude-sonnet-4-6
|
||||
*/
|
||||
export const CLIPROXY_CONFIG_VERSION = 10;
|
||||
export const CLIPROXY_CONFIG_VERSION = 11;
|
||||
|
||||
interface OAuthModelAliasEntry {
|
||||
name: string;
|
||||
@@ -44,6 +45,8 @@ interface OAuthModelAliasEntry {
|
||||
const GEMINI_MINOR_COMPAT_RANGE = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
|
||||
const DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX = 'gemini-claude-';
|
||||
const UPSTREAM_CLAUDE_ALIAS_PREFIX = 'claude-';
|
||||
const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_ALIAS = 'claude-sonnet-4-6-thinking';
|
||||
const CANONICAL_ANTIGRAVITY_SONNET_46_ALIAS = 'claude-sonnet-4-6';
|
||||
|
||||
/**
|
||||
* Default Antigravity oauth-model-alias entries.
|
||||
@@ -58,7 +61,8 @@ const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [
|
||||
{ name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview-customtools' },
|
||||
{ name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' },
|
||||
{ name: 'claude-sonnet-4-6', alias: 'claude-sonnet-4-6', fork: true },
|
||||
{ name: 'claude-sonnet-4-6-thinking', alias: 'claude-sonnet-4-6-thinking', fork: true },
|
||||
// Backward compatibility: legacy sonnet thinking alias now routes to canonical model ID.
|
||||
{ name: 'claude-sonnet-4-6-thinking', alias: 'claude-sonnet-4-6', fork: true },
|
||||
{ name: 'claude-sonnet-4-5', alias: 'claude-sonnet-4-5', fork: true },
|
||||
{ name: 'claude-sonnet-4-5-thinking', alias: 'claude-sonnet-4-5-thinking', fork: true },
|
||||
{ name: 'claude-opus-4-5-thinking', alias: 'claude-opus-4-5-thinking', fork: true },
|
||||
@@ -107,10 +111,15 @@ function sanitizeYamlScalar(rawValue: string): string {
|
||||
function normalizeAntigravityAlias(rawAlias: string): string {
|
||||
const normalized = sanitizeYamlScalar(rawAlias);
|
||||
if (normalized.toLowerCase().startsWith(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX)) {
|
||||
return (
|
||||
UPSTREAM_CLAUDE_ALIAS_PREFIX + normalized.slice(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX.length)
|
||||
);
|
||||
const migratedPrefix =
|
||||
UPSTREAM_CLAUDE_ALIAS_PREFIX + normalized.slice(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX.length);
|
||||
return normalizeAntigravityAlias(migratedPrefix);
|
||||
}
|
||||
|
||||
if (normalized.toLowerCase() === DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_ALIAS) {
|
||||
return CANONICAL_ANTIGRAVITY_SONNET_46_ALIAS;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
@@ -104,23 +104,17 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-6-thinking',
|
||||
name: 'Claude Sonnet 4.6 Thinking',
|
||||
description: 'Latest Sonnet with extended thinking',
|
||||
id: 'claude-sonnet-4-6',
|
||||
name: 'Claude Sonnet 4.6',
|
||||
description: 'Latest Sonnet with thinking budget support',
|
||||
thinking: {
|
||||
type: 'budget',
|
||||
min: 1024,
|
||||
max: 128000,
|
||||
max: 64000,
|
||||
zeroAllowed: true,
|
||||
dynamicAllowed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-6',
|
||||
name: 'Claude Sonnet 4.6',
|
||||
description: 'Latest Sonnet baseline',
|
||||
thinking: { type: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-5-thinking',
|
||||
name: 'Claude Sonnet 4.5 Thinking',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator';
|
||||
import { CLIProxyProvider } from './types';
|
||||
import { initUI, color, bold, dim, ok, info, header } from '../utils/ui';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
import { normalizeModelIdForProvider } from './model-id-normalizer';
|
||||
|
||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
||||
|
||||
@@ -21,9 +22,9 @@ function stripCodexEffortSuffix(model: string, provider: CLIProxyProvider): stri
|
||||
return model.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
|
||||
}
|
||||
|
||||
function normalizeCodexTierModel(provider: CLIProxyProvider, model: string): string {
|
||||
if (provider !== 'codex') return model;
|
||||
return stripCodexEffortSuffix(model, provider);
|
||||
function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string): string {
|
||||
const withoutCodexSuffix = stripCodexEffortSuffix(model, provider);
|
||||
return normalizeModelIdForProvider(withoutCodexSuffix, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +51,8 @@ export function getCurrentModel(
|
||||
|
||||
try {
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
return settings.env?.ANTHROPIC_MODEL;
|
||||
const model = settings.env?.ANTHROPIC_MODEL;
|
||||
return typeof model === 'string' ? canonicalizeModelForProvider(provider, model) : model;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -134,7 +136,7 @@ export async function configureProviderModel(
|
||||
// Find default index - use current model if configured, otherwise catalog default
|
||||
const currentModel = getCurrentModel(provider, customSettingsPath);
|
||||
const targetModel = currentModel
|
||||
? stripCodexEffortSuffix(currentModel, provider)
|
||||
? canonicalizeModelForProvider(provider, currentModel)
|
||||
: catalog.defaultModel;
|
||||
const defaultIdx = catalog.models.findIndex((m) => m.id === targetModel);
|
||||
const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0;
|
||||
@@ -157,10 +159,10 @@ export async function configureProviderModel(
|
||||
|
||||
// Get base env vars for defaults
|
||||
const baseEnv = getClaudeEnvVars(provider);
|
||||
const selectedDefaultModel = normalizeCodexTierModel(provider, selectedModel);
|
||||
const selectedOpusModel = normalizeCodexTierModel(provider, selectedModel);
|
||||
const selectedSonnetModel = normalizeCodexTierModel(provider, selectedModel);
|
||||
const selectedHaikuModel = normalizeCodexTierModel(
|
||||
const selectedDefaultModel = canonicalizeModelForProvider(provider, selectedModel);
|
||||
const selectedOpusModel = canonicalizeModelForProvider(provider, selectedModel);
|
||||
const selectedSonnetModel = canonicalizeModelForProvider(provider, selectedModel);
|
||||
const selectedHaikuModel = canonicalizeModelForProvider(
|
||||
provider,
|
||||
baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || selectedModel
|
||||
);
|
||||
@@ -251,7 +253,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise<voi
|
||||
const currentModel = getCurrentModel(provider);
|
||||
const settingsPath = getProviderSettingsPath(provider);
|
||||
const normalizedCurrentModel = currentModel
|
||||
? stripCodexEffortSuffix(currentModel, provider)
|
||||
? canonicalizeModelForProvider(provider, currentModel)
|
||||
: undefined;
|
||||
|
||||
console.error('');
|
||||
|
||||
@@ -20,6 +20,10 @@ type ProviderLike = CLIProxyProvider | string | null | undefined;
|
||||
const CLAUDE_DOTTED_VERSION_REGEX = /claude-(sonnet|opus|haiku)-(\d+)\.(\d+)(?=(?:$|-|\[|\(|\/))/gi;
|
||||
const CLAUDE_DOTTED_THINKING_REGEX =
|
||||
/claude-(sonnet|opus|haiku)-(\d+)\.(\d+)-thinking(?=(?:$|-|\[|\(|\/))/gi;
|
||||
const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX =
|
||||
/claude-sonnet-4(?:[.-])6-thinking(?=(?:$|-|\[|\(|\/))/gi;
|
||||
const CANONICAL_ANTIGRAVITY_SONNET_46_MODEL = 'claude-sonnet-4-6';
|
||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
||||
|
||||
/**
|
||||
* Extract provider segment from `/api/provider/{provider}` request paths.
|
||||
@@ -41,6 +45,17 @@ export function isAntigravityProvider(provider: ProviderLike): boolean {
|
||||
return normalized === 'agy' || normalized === 'antigravity';
|
||||
}
|
||||
|
||||
/** Whether provider maps to Codex model canonicalization rules. */
|
||||
export function isCodexProvider(provider: ProviderLike): boolean {
|
||||
if (typeof provider !== 'string') return false;
|
||||
return provider.trim().toLowerCase() === 'codex';
|
||||
}
|
||||
|
||||
/** Normalize Codex effort-suffixed IDs to canonical IDs. */
|
||||
export function stripCodexEffortSuffix(model: string): string {
|
||||
return model.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
|
||||
}
|
||||
|
||||
/** Normalize Claude dotted major.minor IDs to hyphenated format. */
|
||||
export function normalizeClaudeDottedMajorMinor(model: string): string {
|
||||
return model.replace(
|
||||
@@ -62,6 +77,17 @@ export function normalizeClaudeDottedThinkingMajorMinor(model: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Antigravity no longer exposes `claude-sonnet-4-6-thinking`.
|
||||
* Canonicalize legacy aliases to `claude-sonnet-4-6` while preserving suffixes.
|
||||
*/
|
||||
export function normalizeDeprecatedAntigravityModelAliases(model: string): string {
|
||||
return model.replace(
|
||||
DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX,
|
||||
CANONICAL_ANTIGRAVITY_SONNET_46_MODEL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize model ID for a specific provider.
|
||||
* Antigravity requires hyphenated Claude major.minor model IDs.
|
||||
@@ -76,7 +102,18 @@ export function normalizeClaudeDottedThinkingMajorMinor(model: string): string {
|
||||
*/
|
||||
export function normalizeModelIdForProvider(model: string, provider: ProviderLike): string {
|
||||
if (!isAntigravityProvider(provider)) return model;
|
||||
return normalizeClaudeDottedMajorMinor(model);
|
||||
const normalizedDottedVersion = normalizeClaudeDottedMajorMinor(model);
|
||||
return normalizeDeprecatedAntigravityModelAliases(normalizedDottedVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize model ID for provider-specific compatibility.
|
||||
* - Codex: strip effort suffixes.
|
||||
* - Antigravity: normalize dotted/historical aliases.
|
||||
*/
|
||||
export function canonicalizeModelIdForProvider(model: string, provider: ProviderLike): string {
|
||||
const withoutCodexSuffix = isCodexProvider(provider) ? stripCodexEffortSuffix(model) : model;
|
||||
return normalizeModelIdForProvider(withoutCodexSuffix, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +124,7 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik
|
||||
*
|
||||
* @example
|
||||
* normalizeModelIdForRouting('claude-sonnet-4.6-thinking', null)
|
||||
* // => 'claude-sonnet-4-6-thinking'
|
||||
* // => 'claude-sonnet-4-6'
|
||||
*
|
||||
* @example
|
||||
* normalizeModelIdForRouting('claude-sonnet-4.6', null)
|
||||
@@ -95,9 +132,14 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik
|
||||
*/
|
||||
export function normalizeModelIdForRouting(model: string, provider: ProviderLike): string {
|
||||
if (isAntigravityProvider(provider)) {
|
||||
return normalizeClaudeDottedMajorMinor(model);
|
||||
return normalizeModelIdForProvider(model, provider);
|
||||
}
|
||||
return normalizeClaudeDottedThinkingMajorMinor(model);
|
||||
// Explicit non-AGY provider routes should pass through unchanged.
|
||||
if (typeof provider === 'string' && provider.trim().length > 0) {
|
||||
return model;
|
||||
}
|
||||
const normalizedThinking = normalizeClaudeDottedThinkingMajorMinor(model);
|
||||
return normalizeDeprecatedAntigravityModelAliases(normalizedThinking);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +151,7 @@ export function normalizeModelIdForRouting(model: string, provider: ProviderLike
|
||||
* { ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking' },
|
||||
* 'agy'
|
||||
* )
|
||||
* // => { ANTHROPIC_MODEL: 'claude-sonnet-4-6-thinking' }
|
||||
* // => { ANTHROPIC_MODEL: 'claude-sonnet-4-6' }
|
||||
*/
|
||||
export function normalizeModelEnvVarsForProvider(
|
||||
envVars: NodeJS.ProcessEnv,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'
|
||||
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector';
|
||||
import { getEffectiveApiKey } from '../auth-token-manager';
|
||||
import { warn } from '../../utils/ui';
|
||||
import { normalizeModelIdForProvider } from '../model-id-normalizer';
|
||||
|
||||
/** Environment settings structure */
|
||||
interface SettingsEnv {
|
||||
@@ -41,8 +42,10 @@ function canonicalizeModelForProvider(
|
||||
provider: CLIProxyProfileName | undefined,
|
||||
model: string
|
||||
): string {
|
||||
if (provider !== 'codex') return model;
|
||||
return model.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
|
||||
const withoutCodexSuffix =
|
||||
provider === 'codex' ? model.replace(CODEX_EFFORT_SUFFIX_REGEX, '') : model;
|
||||
if (!provider) return withoutCodexSuffix;
|
||||
return normalizeModelIdForProvider(withoutCodexSuffix, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,6 +51,10 @@ import {
|
||||
} from '../../cliproxy/platform-detector';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
|
||||
import {
|
||||
MODEL_ENV_VAR_KEYS,
|
||||
canonicalizeModelIdForProvider,
|
||||
} from '../../cliproxy/model-id-normalizer';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -596,16 +600,43 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise<voi
|
||||
}
|
||||
|
||||
// Read and update settings
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
settings.env = settings.env || {};
|
||||
settings.env.ANTHROPIC_MODEL = model;
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
env?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
const canonicalModel = canonicalizeModelIdForProvider(model, provider);
|
||||
const env =
|
||||
settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env)
|
||||
? settings.env
|
||||
: {};
|
||||
|
||||
const previousDefault =
|
||||
typeof env.ANTHROPIC_MODEL === 'string' ? env.ANTHROPIC_MODEL : canonicalModel;
|
||||
const previousCanonicalDefault = canonicalizeModelIdForProvider(previousDefault, provider);
|
||||
|
||||
for (const key of MODEL_ENV_VAR_KEYS) {
|
||||
if (key === 'ANTHROPIC_MODEL') {
|
||||
env[key] = canonicalModel;
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = env[key];
|
||||
if (typeof current !== 'string') {
|
||||
env[key] = canonicalModel;
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalCurrent = canonicalizeModelIdForProvider(current, provider);
|
||||
env[key] = canonicalCurrent === previousCanonicalDefault ? canonicalModel : canonicalCurrent;
|
||||
}
|
||||
settings.env = env;
|
||||
|
||||
// Write atomically
|
||||
const tempPath = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
fs.renameSync(tempPath, settingsPath);
|
||||
|
||||
res.json({ success: true, provider, model });
|
||||
res.json({ success: true, provider, model: canonicalModel });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
mutateUnifiedConfig,
|
||||
} from '../../config/unified-config-loader';
|
||||
import type { Settings } from '../../types/config';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
||||
import { canonicalizeModelIdForProvider } from '../../cliproxy/model-id-normalizer';
|
||||
|
||||
const router = Router();
|
||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
||||
const MODEL_ENV_KEYS = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
@@ -122,18 +124,30 @@ function resolveSettingsPath(profileOrVariant: string): string {
|
||||
return path.join(ccsDir, `${profileOrVariant}.settings.json`);
|
||||
}
|
||||
|
||||
function isCodexProfile(profileOrVariant: string): boolean {
|
||||
if (profileOrVariant.toLowerCase() === 'codex') return true;
|
||||
function resolveProviderForProfile(profileOrVariant: string): CLIProxyProvider | null {
|
||||
const directProvider = mapExternalProviderName(profileOrVariant);
|
||||
if (directProvider) {
|
||||
return directProvider;
|
||||
}
|
||||
|
||||
const variants = listVariants();
|
||||
return variants[profileOrVariant]?.provider === 'codex';
|
||||
const variantProvider = variants[profileOrVariant]?.provider;
|
||||
if (typeof variantProvider === 'string') {
|
||||
return mapExternalProviderName(variantProvider);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripCodexEffortSuffix(modelId: string): string {
|
||||
return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
|
||||
function canonicalizeProfileModelId(profileOrVariant: string, modelId: string): string {
|
||||
const provider = resolveProviderForProfile(profileOrVariant);
|
||||
if (!provider) return modelId;
|
||||
return canonicalizeModelIdForProvider(modelId, provider);
|
||||
}
|
||||
|
||||
function canonicalizeCodexSettings(profileOrVariant: string, settings: Settings): Settings {
|
||||
if (!isCodexProfile(profileOrVariant)) return settings;
|
||||
function canonicalizeProfileSettings(profileOrVariant: string, settings: Settings): Settings {
|
||||
const provider = resolveProviderForProfile(profileOrVariant);
|
||||
if (!provider) return settings;
|
||||
|
||||
let changed = false;
|
||||
const next: Settings = { ...settings };
|
||||
@@ -143,7 +157,7 @@ function canonicalizeCodexSettings(profileOrVariant: string, settings: Settings)
|
||||
for (const key of MODEL_ENV_KEYS) {
|
||||
const value = env[key];
|
||||
if (typeof value !== 'string') continue;
|
||||
const canonical = stripCodexEffortSuffix(value);
|
||||
const canonical = canonicalizeModelIdForProvider(value, provider);
|
||||
if (canonical !== value) {
|
||||
env[key] = canonical;
|
||||
changed = true;
|
||||
@@ -159,7 +173,8 @@ function canonicalizeCodexSettings(profileOrVariant: string, settings: Settings)
|
||||
|
||||
for (const key of PRESET_MODEL_KEYS) {
|
||||
const value = normalizedPreset[key];
|
||||
const canonical = stripCodexEffortSuffix(value);
|
||||
if (typeof value !== 'string') continue;
|
||||
const canonical = canonicalizeModelIdForProvider(value, provider);
|
||||
if (canonical !== value) {
|
||||
normalizedPreset[key] = canonical;
|
||||
presetChanged = true;
|
||||
@@ -206,7 +221,7 @@ router.get('/:profile', (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
const stat = fs.statSync(settingsPath);
|
||||
const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath));
|
||||
const settings = canonicalizeProfileSettings(profile, loadSettings(settingsPath));
|
||||
const masked = maskApiKeys(settings);
|
||||
|
||||
res.json({
|
||||
@@ -234,7 +249,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
const stat = fs.statSync(settingsPath);
|
||||
const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath));
|
||||
const settings = canonicalizeProfileSettings(profile, loadSettings(settingsPath));
|
||||
|
||||
res.json({
|
||||
profile,
|
||||
@@ -270,7 +285,7 @@ router.put('/:profile', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSettings = canonicalizeCodexSettings(profile, settings as Settings);
|
||||
const normalizedSettings = canonicalizeProfileSettings(profile, settings as Settings);
|
||||
|
||||
// Deduplicate CCS hooks to prevent accumulation (fixes #450)
|
||||
// This handles cases where duplicate hooks were added by previous versions
|
||||
@@ -355,7 +370,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath));
|
||||
const settings = canonicalizeProfileSettings(profile, loadSettings(settingsPath));
|
||||
res.json({ presets: settings.presets || [] });
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
@@ -393,7 +408,7 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
const normalizePresetModel = (modelId: string): string =>
|
||||
isCodexProfile(profile) ? stripCodexEffortSuffix(modelId) : modelId;
|
||||
canonicalizeProfileModelId(profile, modelId);
|
||||
|
||||
const normalizedDefaultModel = normalizePresetModel(defaultModel);
|
||||
const normalizedOpusModel = normalizePresetModel(opus || defaultModel);
|
||||
|
||||
@@ -792,8 +792,8 @@ oauth-model-alias:
|
||||
|
||||
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
|
||||
assert(
|
||||
newConfig.includes('alias: claude-sonnet-4-6-thinking'),
|
||||
'Should include normalized upstream Claude alias'
|
||||
newConfig.includes('alias: claude-sonnet-4-6'),
|
||||
'Should migrate deprecated sonnet thinking alias to canonical model ID'
|
||||
);
|
||||
assert(
|
||||
!newConfig.includes('alias: gemini-claude-sonnet-4-6-thinking'),
|
||||
|
||||
@@ -105,10 +105,17 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
});
|
||||
|
||||
const env = getEffectiveEnvVars('agy', 8317, settingsPath);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6-thinking');
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6-thinking');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||
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');
|
||||
});
|
||||
|
||||
it('migrates codex preset model mappings to canonical IDs', () => {
|
||||
@@ -222,6 +229,39 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBeDefined();
|
||||
});
|
||||
|
||||
it('migrates deprecated agy sonnet 4.6 thinking IDs during ensureProviderSettings', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
|
||||
fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
agySettingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
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-thinking(8192)',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-5',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
ensureProviderSettings('agy');
|
||||
|
||||
const repaired = JSON.parse(fs.readFileSync(agySettingsPath, 'utf-8')) as {
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
expect(repaired.env?.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(8192)');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-5');
|
||||
});
|
||||
|
||||
it('recovers malformed provider settings files by writing defaults and backup copy', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
|
||||
|
||||
@@ -85,15 +85,6 @@ describe('Model Catalog', () => {
|
||||
assert.strictEqual(sonnetThinking.name, 'Claude Sonnet 4.5 Thinking');
|
||||
});
|
||||
|
||||
it('includes Claude Sonnet 4.6 Thinking', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const sonnetThinking = MODEL_CATALOG.agy.models.find(
|
||||
(m) => m.id === 'claude-sonnet-4-6-thinking'
|
||||
);
|
||||
assert(sonnetThinking, 'Should include Claude Sonnet 4.6 Thinking');
|
||||
assert.strictEqual(sonnetThinking.name, 'Claude Sonnet 4.6 Thinking');
|
||||
});
|
||||
|
||||
it('includes Claude Sonnet 4.6', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const sonnet = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-sonnet-4-6');
|
||||
@@ -117,9 +108,9 @@ describe('Model Catalog', () => {
|
||||
assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier');
|
||||
});
|
||||
|
||||
it('has 7 models total', () => {
|
||||
it('has 6 models total', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.agy.models.length, 7);
|
||||
assert.strictEqual(MODEL_CATALOG.agy.models.length, 6);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
canonicalizeModelIdForProvider,
|
||||
extractProviderFromPathname,
|
||||
isAntigravityProvider,
|
||||
normalizeClaudeDottedMajorMinor,
|
||||
@@ -44,13 +45,22 @@ describe('model-id-normalizer', () => {
|
||||
|
||||
it('applies provider-aware routing normalization', () => {
|
||||
expect(normalizeModelIdForRouting('claude-sonnet-4.6-thinking', null)).toBe(
|
||||
'claude-sonnet-4-6-thinking'
|
||||
'claude-sonnet-4-6'
|
||||
);
|
||||
expect(normalizeModelIdForRouting('claude-sonnet-4.6', null)).toBe('claude-sonnet-4.6');
|
||||
expect(normalizeModelIdForRouting('claude-sonnet-4.6', 'agy')).toBe('claude-sonnet-4-6');
|
||||
expect(normalizeModelIdForRouting('claude-sonnet-4-6-thinking', 'claude')).toBe(
|
||||
'claude-sonnet-4-6-thinking'
|
||||
);
|
||||
expect(normalizeModelIdForRouting('claude-sonnet-4.6-thinking', 'claude')).toBe(
|
||||
'claude-sonnet-4.6-thinking'
|
||||
);
|
||||
});
|
||||
|
||||
it('applies provider-only normalization for antigravity', () => {
|
||||
expect(normalizeModelIdForProvider('claude-sonnet-4.6-thinking', 'agy')).toBe(
|
||||
'claude-sonnet-4-6'
|
||||
);
|
||||
expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'agy')).toBe(
|
||||
'claude-opus-4-6-thinking'
|
||||
);
|
||||
@@ -58,6 +68,16 @@ describe('model-id-normalizer', () => {
|
||||
'claude-opus-4.6-thinking'
|
||||
);
|
||||
});
|
||||
|
||||
it('applies provider canonicalization for codex and antigravity', () => {
|
||||
expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe('gpt-5.3-codex');
|
||||
expect(canonicalizeModelIdForProvider('claude-sonnet-4.6-thinking', 'agy')).toBe(
|
||||
'claude-sonnet-4-6'
|
||||
);
|
||||
expect(canonicalizeModelIdForProvider('claude-sonnet-4-6-thinking', 'claude')).toBe(
|
||||
'claude-sonnet-4-6-thinking'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('env normalization', () => {
|
||||
@@ -71,7 +91,7 @@ describe('model-id-normalizer', () => {
|
||||
};
|
||||
|
||||
const normalized = normalizeModelEnvVarsForProvider(input, 'agy');
|
||||
expect(normalized.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6-thinking');
|
||||
expect(normalized.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(normalized.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
|
||||
|
||||
@@ -201,9 +201,7 @@ describe('ToolSanitizationProxy Integration', () => {
|
||||
});
|
||||
|
||||
expect(lastRequest).not.toBeNull();
|
||||
expect((lastRequest!.body as Record<string, unknown>).model).toBe(
|
||||
'claude-sonnet-4-6-thinking'
|
||||
);
|
||||
expect((lastRequest!.body as Record<string, unknown>).model).toBe('claude-sonnet-4-6');
|
||||
} finally {
|
||||
proxy.stop();
|
||||
}
|
||||
@@ -255,6 +253,31 @@ describe('ToolSanitizationProxy Integration', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not rewrite AGY legacy aliases on non-AGY provider routes', async () => {
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||
});
|
||||
const port = await proxy.start();
|
||||
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/api/provider/claude/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'claude-sonnet-4-6-thinking',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(lastRequest).not.toBeNull();
|
||||
expect((lastRequest!.body as Record<string, unknown>).model).toBe(
|
||||
'claude-sonnet-4-6-thinking'
|
||||
);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves other tool properties during sanitization', async () => {
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import express from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Server } from 'http';
|
||||
import cliproxyStatsRoutes from '../../../src/web-server/routes/cliproxy-stats-routes';
|
||||
|
||||
function writeSettings(filePath: string, env: Record<string, string>): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify({ env }, null, 2) + '\n');
|
||||
}
|
||||
|
||||
describe('cliproxy-stats-routes model update canonicalization', () => {
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/cliproxy', cliproxyStatsRoutes);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = app.listen(0, '127.0.0.1');
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once('error', onError);
|
||||
server.once('listening', () => {
|
||||
server.off('error', onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-model-route-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('updates linked tiers while preserving intentionally distinct tier models', 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-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6-thinking',
|
||||
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-sonnet-4-6-thinking(8192)' }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as { model: string };
|
||||
expect(body.model).toBe('claude-sonnet-4-6(8192)');
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(8192)');
|
||||
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(8192)');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
|
||||
});
|
||||
|
||||
it('canonicalizes Codex effort suffix and syncs linked core model env vars', async () => {
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'codex.settings.json');
|
||||
writeSettings(settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini',
|
||||
});
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/models/codex`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'gpt-5.3-codex-xhigh' }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as { model: string };
|
||||
expect(body.model).toBe('gpt-5.3-codex');
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import express from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Server } from 'http';
|
||||
import settingsRoutes from '../../../src/web-server/routes/settings-routes';
|
||||
|
||||
function writeSettings(filePath: string, value: Record<string, unknown>): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n');
|
||||
}
|
||||
|
||||
describe('settings-routes model canonicalization', () => {
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = app.listen(0, '127.0.0.1');
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once('error', onError);
|
||||
server.once('listening', () => {
|
||||
server.off('error', onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-settings-routes-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('canonicalizes AGY env and preset model IDs 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-6-thinking(8192)',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5',
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
name: 'legacy',
|
||||
default: 'claude-sonnet-4-6-thinking',
|
||||
opus: 'claude-opus-4.6-thinking',
|
||||
sonnet: 'claude-sonnet-4.6-thinking',
|
||||
haiku: 'claude-haiku-4.5',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
presets: Array<Record<string, string>>;
|
||||
};
|
||||
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(8192)');
|
||||
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-haiku-4-5');
|
||||
|
||||
expect(persisted.presets[0]?.default).toBe('claude-sonnet-4-6');
|
||||
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-haiku-4-5');
|
||||
});
|
||||
|
||||
it('canonicalizes AGY preset values 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: 'new-legacy',
|
||||
default: 'claude-sonnet-4-6-thinking',
|
||||
opus: 'claude-opus-4.6-thinking',
|
||||
sonnet: 'claude-sonnet-4.6-thinking',
|
||||
haiku: 'claude-haiku-4.5',
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const body = (await response.json()) as { preset: Record<string, string> };
|
||||
expect(body.preset.default).toBe('claude-sonnet-4-6');
|
||||
expect(body.preset.opus).toBe('claude-opus-4-6-thinking');
|
||||
expect(body.preset.sonnet).toBe('claude-sonnet-4-6');
|
||||
expect(body.preset.haiku).toBe('claude-haiku-4-5');
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
presets: Array<Record<string, string>>;
|
||||
};
|
||||
expect(persisted.presets[0]?.default).toBe('claude-sonnet-4-6');
|
||||
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-haiku-4-5');
|
||||
});
|
||||
|
||||
it('canonicalizes AGY aliases via provider alias profile names', async () => {
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'antigravity.settings.json');
|
||||
writeSettings(settingsPath, { env: {} });
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/settings/antigravity`, {
|
||||
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.6-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5',
|
||||
},
|
||||
presets: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||
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-haiku-4-5');
|
||||
});
|
||||
|
||||
it('does not crash on malformed non-string preset model values', 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: [
|
||||
{
|
||||
name: 'malformed',
|
||||
default: 123,
|
||||
opus: null,
|
||||
sonnet: 'claude-sonnet-4.6-thinking',
|
||||
haiku: 'claude-haiku-4.5',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/settings/agy/raw`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
settings: { presets: Array<Record<string, unknown>> };
|
||||
};
|
||||
expect(body.settings.presets[0]?.default).toBe(123);
|
||||
expect(body.settings.presets[0]?.sonnet).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
presetMapping: {
|
||||
default: 'claude-opus-4-6-thinking',
|
||||
opus: 'claude-opus-4-6-thinking',
|
||||
sonnet: 'claude-sonnet-4-6-thinking',
|
||||
sonnet: 'claude-sonnet-4-6',
|
||||
haiku: 'claude-sonnet-4-6',
|
||||
},
|
||||
},
|
||||
@@ -33,25 +33,14 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
presetMapping: {
|
||||
default: 'claude-opus-4-5-thinking',
|
||||
opus: 'claude-opus-4-5-thinking',
|
||||
sonnet: 'claude-sonnet-4-6-thinking',
|
||||
haiku: 'claude-sonnet-4-6',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-6-thinking',
|
||||
name: 'Claude Sonnet 4.6 Thinking',
|
||||
description: 'Latest Sonnet with extended thinking',
|
||||
presetMapping: {
|
||||
default: 'claude-sonnet-4-6-thinking',
|
||||
opus: 'claude-opus-4-6-thinking',
|
||||
sonnet: 'claude-sonnet-4-6-thinking',
|
||||
sonnet: 'claude-sonnet-4-6',
|
||||
haiku: 'claude-sonnet-4-6',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-6',
|
||||
name: 'Claude Sonnet 4.6',
|
||||
description: 'Latest Sonnet baseline',
|
||||
description: 'Latest Sonnet with thinking budget support',
|
||||
presetMapping: {
|
||||
default: 'claude-sonnet-4-6',
|
||||
opus: 'claude-opus-4-6-thinking',
|
||||
|
||||
Reference in new issue
Block a user