mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 08:15:17 +00:00
fix(agy): harden preset canonicalization and migration coverage
This commit is contained in:
1 parent
8ddd3596e9
commit
747c706369
9 files changed
+338
-26
No files matched your search
@@ -21,6 +21,7 @@ import {
|
|||||||
} from './port-manager';
|
} from './port-manager';
|
||||||
import { getProviderSettingsPath } from './path-resolver';
|
import { getProviderSettingsPath } from './path-resolver';
|
||||||
import {
|
import {
|
||||||
|
canonicalizeModelIdForProvider,
|
||||||
MODEL_ENV_VAR_KEYS,
|
MODEL_ENV_VAR_KEYS,
|
||||||
normalizeModelEnvVarsForProvider,
|
normalizeModelEnvVarsForProvider,
|
||||||
normalizeModelIdForProvider,
|
normalizeModelIdForProvider,
|
||||||
@@ -563,13 +564,30 @@ 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 = normalizeModelIdForProvider(current, provider);
|
const canonical = canonicalizeModelIdForProvider(current, provider);
|
||||||
if (canonical !== current) {
|
if (canonical !== current) {
|
||||||
mergedEnv[key] = canonical;
|
mergedEnv[key] = canonical;
|
||||||
mutated = true;
|
mutated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const presetsCandidate = parsed.presets;
|
||||||
|
if (Array.isArray(presetsCandidate)) {
|
||||||
|
for (const preset of presetsCandidate) {
|
||||||
|
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 = canonicalizeModelIdForProvider(value, provider);
|
||||||
|
if (canonical !== value) {
|
||||||
|
presetRecord[key] = canonical;
|
||||||
|
mutated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!mutated) {
|
if (!mutated) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ interface OAuthModelAliasEntry {
|
|||||||
const GEMINI_MINOR_COMPAT_RANGE = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
|
const GEMINI_MINOR_COMPAT_RANGE = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
|
||||||
const DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX = 'gemini-claude-';
|
const DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX = 'gemini-claude-';
|
||||||
const UPSTREAM_CLAUDE_ALIAS_PREFIX = 'claude-';
|
const UPSTREAM_CLAUDE_ALIAS_PREFIX = 'claude-';
|
||||||
const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_ALIAS = 'claude-sonnet-4-6-thinking';
|
const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX = /^claude-sonnet-4(?:[.-])6-thinking$/i;
|
||||||
const CANONICAL_ANTIGRAVITY_SONNET_46_ALIAS = 'claude-sonnet-4-6';
|
const CANONICAL_ANTIGRAVITY_SONNET_46_ALIAS = 'claude-sonnet-4-6';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,7 +116,7 @@ function normalizeAntigravityAlias(rawAlias: string): string {
|
|||||||
return normalizeAntigravityAlias(migratedPrefix);
|
return normalizeAntigravityAlias(migratedPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized.toLowerCase() === DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_ALIAS) {
|
if (DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX.test(normalized)) {
|
||||||
return CANONICAL_ANTIGRAVITY_SONNET_46_ALIAS;
|
return CANONICAL_ANTIGRAVITY_SONNET_46_ALIAS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX =
|
|||||||
const CANONICAL_ANTIGRAVITY_SONNET_46_MODEL = 'claude-sonnet-4-6';
|
const CANONICAL_ANTIGRAVITY_SONNET_46_MODEL = 'claude-sonnet-4-6';
|
||||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
||||||
|
|
||||||
|
function trimModelId(model: string): string {
|
||||||
|
return model.trim();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract provider segment from `/api/provider/{provider}` request paths.
|
* Extract provider segment from `/api/provider/{provider}` request paths.
|
||||||
*
|
*
|
||||||
@@ -101,8 +105,9 @@ export function normalizeDeprecatedAntigravityModelAliases(model: string): strin
|
|||||||
* // => 'claude-opus-4.6-thinking'
|
* // => 'claude-opus-4.6-thinking'
|
||||||
*/
|
*/
|
||||||
export function normalizeModelIdForProvider(model: string, provider: ProviderLike): string {
|
export function normalizeModelIdForProvider(model: string, provider: ProviderLike): string {
|
||||||
if (!isAntigravityProvider(provider)) return model;
|
const trimmedModel = trimModelId(model);
|
||||||
const normalizedDottedVersion = normalizeClaudeDottedMajorMinor(model);
|
if (!isAntigravityProvider(provider)) return trimmedModel;
|
||||||
|
const normalizedDottedVersion = normalizeClaudeDottedMajorMinor(trimmedModel);
|
||||||
return normalizeDeprecatedAntigravityModelAliases(normalizedDottedVersion);
|
return normalizeDeprecatedAntigravityModelAliases(normalizedDottedVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +117,10 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik
|
|||||||
* - Antigravity: normalize dotted/historical aliases.
|
* - Antigravity: normalize dotted/historical aliases.
|
||||||
*/
|
*/
|
||||||
export function canonicalizeModelIdForProvider(model: string, provider: ProviderLike): string {
|
export function canonicalizeModelIdForProvider(model: string, provider: ProviderLike): string {
|
||||||
const withoutCodexSuffix = isCodexProvider(provider) ? stripCodexEffortSuffix(model) : model;
|
const trimmedModel = trimModelId(model);
|
||||||
|
const withoutCodexSuffix = isCodexProvider(provider)
|
||||||
|
? stripCodexEffortSuffix(trimmedModel)
|
||||||
|
: trimmedModel;
|
||||||
return normalizeModelIdForProvider(withoutCodexSuffix, provider);
|
return normalizeModelIdForProvider(withoutCodexSuffix, provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,14 +139,15 @@ export function canonicalizeModelIdForProvider(model: string, provider: Provider
|
|||||||
* // => 'claude-sonnet-4.6'
|
* // => 'claude-sonnet-4.6'
|
||||||
*/
|
*/
|
||||||
export function normalizeModelIdForRouting(model: string, provider: ProviderLike): string {
|
export function normalizeModelIdForRouting(model: string, provider: ProviderLike): string {
|
||||||
|
const trimmedModel = trimModelId(model);
|
||||||
if (isAntigravityProvider(provider)) {
|
if (isAntigravityProvider(provider)) {
|
||||||
return normalizeModelIdForProvider(model, provider);
|
return normalizeModelIdForProvider(trimmedModel, provider);
|
||||||
}
|
}
|
||||||
// Explicit non-AGY provider routes should pass through unchanged.
|
// Explicit non-AGY provider routes should pass through unchanged.
|
||||||
if (typeof provider === 'string' && provider.trim().length > 0) {
|
if (typeof provider === 'string' && provider.trim().length > 0) {
|
||||||
return model;
|
return trimmedModel;
|
||||||
}
|
}
|
||||||
const normalizedThinking = normalizeClaudeDottedThinkingMajorMinor(model);
|
const normalizedThinking = normalizeClaudeDottedThinkingMajorMinor(trimmedModel);
|
||||||
return normalizeDeprecatedAntigravityModelAliases(normalizedThinking);
|
return normalizeDeprecatedAntigravityModelAliases(normalizedThinking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,35 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting
|
|||||||
return changed ? next : settings;
|
return changed ? next : settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeSettingsAtomically(settingsPath: string, settings: Settings): void {
|
||||||
|
const tempPath = settingsPath + '.tmp';
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
||||||
|
fs.renameSync(tempPath, settingsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCanonicalProfileSettings(
|
||||||
|
profileOrVariant: string,
|
||||||
|
settingsPath: string,
|
||||||
|
persist = false,
|
||||||
|
strictPersist = false
|
||||||
|
): Settings {
|
||||||
|
const loaded = loadSettings(settingsPath);
|
||||||
|
const canonicalized = canonicalizeProfileSettings(profileOrVariant, loaded);
|
||||||
|
|
||||||
|
if (persist && canonicalized !== loaded) {
|
||||||
|
try {
|
||||||
|
writeSettingsAtomically(settingsPath, canonicalized);
|
||||||
|
} catch (error) {
|
||||||
|
if (strictPersist) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logRouteError(`Failed to persist canonicalized settings for ${profileOrVariant}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return canonicalized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: Mask API keys in settings
|
* Helper: Mask API keys in settings
|
||||||
*/
|
*/
|
||||||
@@ -220,8 +249,8 @@ router.get('/:profile', (req: Request, res: Response): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settings = loadCanonicalProfileSettings(profile, settingsPath, true);
|
||||||
const stat = fs.statSync(settingsPath);
|
const stat = fs.statSync(settingsPath);
|
||||||
const settings = canonicalizeProfileSettings(profile, loadSettings(settingsPath));
|
|
||||||
const masked = maskApiKeys(settings);
|
const masked = maskApiKeys(settings);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -248,8 +277,8 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settings = loadCanonicalProfileSettings(profile, settingsPath, true);
|
||||||
const stat = fs.statSync(settingsPath);
|
const stat = fs.statSync(settingsPath);
|
||||||
const settings = canonicalizeProfileSettings(profile, loadSettings(settingsPath));
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
profile,
|
profile,
|
||||||
@@ -370,7 +399,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = canonicalizeProfileSettings(profile, loadSettings(settingsPath));
|
const settings = loadCanonicalProfileSettings(profile, settingsPath, true);
|
||||||
res.json({ presets: settings.presets || [] });
|
res.json({ presets: settings.presets || [] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
respondInternalError(res, error, 'Internal server error.');
|
respondInternalError(res, error, 'Internal server error.');
|
||||||
@@ -398,7 +427,7 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
|
|||||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
|
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = loadSettings(settingsPath);
|
const settings = loadCanonicalProfileSettings(profile, settingsPath, false);
|
||||||
settings.presets = settings.presets || [];
|
settings.presets = settings.presets || [];
|
||||||
|
|
||||||
// Check for duplicate name
|
// Check for duplicate name
|
||||||
@@ -424,13 +453,12 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
settings.presets.push(preset);
|
settings.presets.push(preset);
|
||||||
|
const canonicalizedSettings = canonicalizeProfileSettings(profile, settings);
|
||||||
|
writeSettingsAtomically(settingsPath, canonicalizedSettings);
|
||||||
|
|
||||||
// Atomic write: temp file + rename
|
const persistedPreset =
|
||||||
const tempPath = settingsPath + '.tmp';
|
canonicalizedSettings.presets?.find((entry) => entry.name === name) || preset;
|
||||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
res.status(201).json({ preset: persistedPreset });
|
||||||
fs.renameSync(tempPath, settingsPath);
|
|
||||||
|
|
||||||
res.status(201).json({ preset });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
respondInternalError(res, error, 'Internal server error.');
|
respondInternalError(res, error, 'Internal server error.');
|
||||||
}
|
}
|
||||||
@@ -449,18 +477,15 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = loadSettings(settingsPath);
|
const settings = loadCanonicalProfileSettings(profile, settingsPath, false);
|
||||||
if (!settings.presets || !settings.presets.some((p) => p.name === name)) {
|
if (!settings.presets || !settings.presets.some((p) => p.name === name)) {
|
||||||
res.status(404).json({ error: 'Preset not found' });
|
res.status(404).json({ error: 'Preset not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.presets = settings.presets.filter((p) => p.name !== name);
|
settings.presets = settings.presets.filter((p) => p.name !== name);
|
||||||
|
const canonicalizedSettings = canonicalizeProfileSettings(profile, settings);
|
||||||
// Atomic write: temp file + rename
|
writeSettingsAtomically(settingsPath, canonicalizedSettings);
|
||||||
const tempPath = settingsPath + '.tmp';
|
|
||||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
|
||||||
fs.renameSync(tempPath, settingsPath);
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -800,5 +800,35 @@ oauth-model-alias:
|
|||||||
'Should remove deprecated gemini-claude alias after regeneration'
|
'Should remove deprecated gemini-claude alias after regeneration'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes dotted deprecated sonnet thinking aliases during regeneration', () => {
|
||||||
|
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||||
|
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||||
|
|
||||||
|
const initialConfig = `# CLIProxyAPI config generated by CCS v11
|
||||||
|
port: 8317
|
||||||
|
api-keys:
|
||||||
|
- "ccs-internal-managed"
|
||||||
|
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
|
||||||
|
oauth-model-alias:
|
||||||
|
antigravity:
|
||||||
|
- name: claude-sonnet-4-6-thinking
|
||||||
|
alias: claude-sonnet-4.6-thinking
|
||||||
|
fork: true
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
|
||||||
|
|
||||||
|
regenerateConfig();
|
||||||
|
|
||||||
|
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
|
||||||
|
assert(
|
||||||
|
newConfig.includes('alias: claude-sonnet-4-6'),
|
||||||
|
'Should migrate dotted deprecated sonnet thinking alias to canonical model ID'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!newConfig.includes('alias: claude-sonnet-4.6-thinking'),
|
||||||
|
'Should remove dotted deprecated sonnet thinking alias after regeneration'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -245,6 +245,15 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
|||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6-thinking',
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6-thinking',
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-5',
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-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-sonnet-4-5',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
@@ -255,11 +264,63 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
|||||||
|
|
||||||
const repaired = JSON.parse(fs.readFileSync(agySettingsPath, 'utf-8')) as {
|
const repaired = JSON.parse(fs.readFileSync(agySettingsPath, 'utf-8')) as {
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
|
presets?: Array<Record<string, string>>;
|
||||||
};
|
};
|
||||||
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-5');
|
||||||
|
expect(repaired.presets?.[0]?.default).toBe('claude-sonnet-4-6');
|
||||||
|
expect(repaired.presets?.[0]?.opus).toBe('claude-opus-4-6-thinking');
|
||||||
|
expect(repaired.presets?.[0]?.sonnet).toBe('claude-sonnet-4-6');
|
||||||
|
expect(repaired.presets?.[0]?.haiku).toBe('claude-sonnet-4-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates codex effort-suffixed preset IDs during ensureProviderSettings', () => {
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
const codexSettingsPath = path.join(tempHome, '.ccs', 'codex.settings.json');
|
||||||
|
fs.mkdirSync(path.dirname(codexSettingsPath), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
codexSettingsPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
|
ANTHROPIC_MODEL: ' gpt-5.3-codex-xhigh ',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
|
||||||
|
},
|
||||||
|
presets: [
|
||||||
|
{
|
||||||
|
name: 'legacy',
|
||||||
|
default: 'gpt-5.3-codex-xhigh',
|
||||||
|
opus: 'gpt-5.3-codex-xhigh',
|
||||||
|
sonnet: 'gpt-5.3-codex-high',
|
||||||
|
haiku: 'gpt-5-mini-medium',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
ensureProviderSettings('codex');
|
||||||
|
|
||||||
|
const repaired = JSON.parse(fs.readFileSync(codexSettingsPath, 'utf-8')) as {
|
||||||
|
env?: Record<string, string>;
|
||||||
|
presets?: Array<Record<string, string>>;
|
||||||
|
};
|
||||||
|
expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||||
|
expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
|
||||||
|
expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
|
||||||
|
expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini');
|
||||||
|
expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex');
|
||||||
|
expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex');
|
||||||
|
expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex');
|
||||||
|
expect(repaired.presets?.[0]?.haiku).toBe('gpt-5-mini');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recovers malformed provider settings files by writing defaults and backup copy', () => {
|
it('recovers malformed provider settings files by writing defaults and backup copy', () => {
|
||||||
|
|||||||
@@ -14,4 +14,12 @@ describe('model-catalog compatibility lookups', () => {
|
|||||||
expect(supportsThinking('agy', 'claude-opus-4.6-thinking')).toBe(true);
|
expect(supportsThinking('agy', 'claude-opus-4.6-thinking')).toBe(true);
|
||||||
expect(supportsThinking('agy', 'claude-sonnet-4.5')).toBe(false);
|
expect(supportsThinking('agy', 'claude-sonnet-4.5')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps legacy sonnet 4.6 thinking aliases to canonical agy model', () => {
|
||||||
|
const dottedLegacy = findModel('agy', 'claude-sonnet-4.6-thinking');
|
||||||
|
const hyphenLegacy = findModel('agy', 'claude-sonnet-4-6-thinking');
|
||||||
|
|
||||||
|
expect(dottedLegacy?.id).toBe('claude-sonnet-4-6');
|
||||||
|
expect(hyphenLegacy?.id).toBe('claude-sonnet-4-6');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -78,6 +78,21 @@ describe('model-id-normalizer', () => {
|
|||||||
'claude-sonnet-4-6-thinking'
|
'claude-sonnet-4-6-thinking'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('trims and canonicalizes provider model IDs with surrounding whitespace', () => {
|
||||||
|
expect(canonicalizeModelIdForProvider(' gpt-5.3-codex-high ', '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'
|
||||||
|
);
|
||||||
|
expect(normalizeModelIdForRouting(' claude-sonnet-4.6-thinking ', null)).toBe(
|
||||||
|
'claude-sonnet-4-6'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('env normalization', () => {
|
describe('env normalization', () => {
|
||||||
|
|||||||
@@ -115,7 +115,15 @@ describe('settings-routes model canonicalization', () => {
|
|||||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
||||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
},
|
},
|
||||||
presets: [],
|
presets: [
|
||||||
|
{
|
||||||
|
name: 'legacy-existing',
|
||||||
|
default: 'claude-sonnet-4-6-thinking',
|
||||||
|
opus: 'claude-opus-4.6-thinking',
|
||||||
|
sonnet: 'claude-sonnet-4.6-thinking',
|
||||||
|
haiku: 'claude-haiku-4.5',
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}/api/settings/agy/presets`, {
|
const response = await fetch(`${baseUrl}/api/settings/agy/presets`, {
|
||||||
@@ -144,6 +152,144 @@ describe('settings-routes model canonicalization', () => {
|
|||||||
expect(persisted.presets[0]?.opus).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]?.sonnet).toBe('claude-sonnet-4-6');
|
||||||
expect(persisted.presets[0]?.haiku).toBe('claude-haiku-4-5');
|
expect(persisted.presets[0]?.haiku).toBe('claude-haiku-4-5');
|
||||||
|
expect(persisted.presets[1]?.default).toBe('claude-sonnet-4-6');
|
||||||
|
expect(persisted.presets[1]?.opus).toBe('claude-opus-4-6-thinking');
|
||||||
|
expect(persisted.presets[1]?.sonnet).toBe('claude-sonnet-4-6');
|
||||||
|
expect(persisted.presets[1]?.haiku).toBe('claude-haiku-4-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonicalizes and persists AGY preset aliases on GET /: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',
|
||||||
|
ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking',
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
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[0]?.default).toBe('claude-sonnet-4-6');
|
||||||
|
expect(body.presets[0]?.opus).toBe('claude-opus-4-6-thinking');
|
||||||
|
expect(body.presets[0]?.sonnet).toBe('claude-sonnet-4-6');
|
||||||
|
expect(body.presets[0]?.haiku).toBe('claude-haiku-4-5');
|
||||||
|
|
||||||
|
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');
|
||||||
|
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 existing settings on DELETE /:profile/presets/:name writes', 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-thinking',
|
||||||
|
},
|
||||||
|
presets: [
|
||||||
|
{
|
||||||
|
name: 'remove-me',
|
||||||
|
default: 'claude-sonnet-4-6-thinking',
|
||||||
|
opus: 'claude-opus-4.6-thinking',
|
||||||
|
sonnet: 'claude-sonnet-4.6-thinking',
|
||||||
|
haiku: 'claude-haiku-4.5',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/settings/agy/presets/remove-me`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
expect(persisted.presets.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate settings on duplicate preset POST failure', 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-thinking',
|
||||||
|
},
|
||||||
|
presets: [
|
||||||
|
{
|
||||||
|
name: 'existing',
|
||||||
|
default: 'claude-sonnet-4-6-thinking',
|
||||||
|
opus: 'claude-opus-4.6-thinking',
|
||||||
|
sonnet: 'claude-sonnet-4.6-thinking',
|
||||||
|
haiku: 'claude-haiku-4.5',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const before = fs.readFileSync(settingsPath, 'utf8');
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/settings/agy/presets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'existing',
|
||||||
|
default: 'claude-sonnet-4-6-thinking',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
|
||||||
|
const after = fs.readFileSync(settingsPath, 'utf8');
|
||||||
|
expect(after).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate settings on missing preset DELETE failure', 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-thinking',
|
||||||
|
},
|
||||||
|
presets: [
|
||||||
|
{
|
||||||
|
name: 'keep-me',
|
||||||
|
default: 'claude-sonnet-4-6-thinking',
|
||||||
|
opus: 'claude-opus-4.6-thinking',
|
||||||
|
sonnet: 'claude-sonnet-4.6-thinking',
|
||||||
|
haiku: 'claude-haiku-4.5',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const before = fs.readFileSync(settingsPath, 'utf8');
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/settings/agy/presets/does-not-exist`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
|
||||||
|
const after = fs.readFileSync(settingsPath, 'utf8');
|
||||||
|
expect(after).toBe(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('canonicalizes AGY aliases via provider alias profile names', async () => {
|
it('canonicalizes AGY aliases via provider alias profile names', async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user