From bebb7582cf4e168628e171b48954bd1d982a129e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 00:12:49 +0700 Subject: [PATCH] fix(cliproxy): prevent agy model reset and add sonnet 4.6 models --- src/cliproxy/config/env-builder.ts | 80 ++++++++++++++++--- src/cliproxy/config/generator.ts | 2 + src/cliproxy/model-catalog.ts | 18 +++++ src/cliproxy/model-config.ts | 5 +- .../image-analyzer-profile-hook-injector.ts | 3 +- src/utils/websearch/profile-hook-injector.ts | 3 +- tests/unit/cliproxy/config-generator.test.js | 30 ++++--- .../cliproxy/env-builder-provider-url.test.ts | 58 +++++++++++++- tests/unit/cliproxy/model-catalog.test.js | 48 ++++++----- ui/src/lib/model-catalogs.ts | 30 ++++++- 10 files changed, 224 insertions(+), 53 deletions(-) diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 92933208..5347962d 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -4,6 +4,7 @@ */ import * as fs from 'fs'; +import * as path from 'path'; import { CLIProxyProvider, ProviderModelMapping } from '../types'; import { getModelMappingFromConfig, getEnvVarsFromConfig } from '../base-config-loader'; import { getGlobalEnvConfig } from '../../config/unified-config-loader'; @@ -37,6 +38,14 @@ const DEPRECATED_MODEL_PREFIX = 'gemini-claude-'; const UPSTREAM_MODEL_PREFIX = 'claude-'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; +const REQUIRED_PROVIDER_ENV_KEYS = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +] as const; function stripCodexEffortSuffix(modelId: string): string { return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); @@ -405,23 +414,72 @@ export function getEffectiveEnvVars( */ export function ensureProviderSettings(provider: CLIProxyProvider): void { const settingsPath = getProviderSettingsPath(provider); + const defaultEnv = getClaudeEnvVars(provider); - // Only create if doesn't exist (preserve user edits) - if (fs.existsSync(settingsPath)) { + const writeSettings = (settings: Record): void => { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { + mode: 0o600, + }); + }; + + // Create initial file when missing. + if (!fs.existsSync(settingsPath)) { + writeSettings({ env: defaultEnv }); return; } - // Generate default settings from PROVIDER_CONFIGS - const envVars = getClaudeEnvVars(provider); - const settings: ProviderSettings = { env: envVars }; + // Existing file: repair missing/invalid core env keys without dropping user data. + let rawContent = ''; + try { + rawContent = fs.readFileSync(settingsPath, 'utf-8'); + } catch { + return; + } - // Ensure directory exists - fs.mkdirSync(require('path').dirname(settingsPath), { recursive: true }); + let parsed: Record; + try { + const value = JSON.parse(rawContent) as unknown; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('settings root must be an object'); + } + parsed = value as Record; + } catch { + // Preserve corrupt payload for manual inspection, then recover with defaults. + const backupPath = `${settingsPath}.corrupt-${Date.now()}`; + try { + fs.writeFileSync(backupPath, rawContent || '', { mode: 0o600 }); + } catch { + // Best effort only. + } + writeSettings({ env: defaultEnv }); + return; + } - // Write with restricted permissions - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { - mode: 0o600, - }); + const envCandidate = parsed.env; + const mergedEnv: Record = + envCandidate && typeof envCandidate === 'object' && !Array.isArray(envCandidate) + ? { ...(envCandidate as Record) } + : {}; + + let mutated = !(envCandidate && typeof envCandidate === 'object' && !Array.isArray(envCandidate)); + for (const key of REQUIRED_PROVIDER_ENV_KEYS) { + const current = mergedEnv[key]; + if (typeof current !== 'string' || current.trim().length === 0) { + mergedEnv[key] = defaultEnv[key] || ''; + mutated = true; + } + } + + if (!mutated) { + return; + } + + const repairedSettings: Record = { + ...parsed, + env: mergedEnv, + }; + writeSettings(repairedSettings); } /** diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 8ea310f7..27e649d4 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -54,6 +54,8 @@ const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [ { name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview' }, { 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: 'gemini-claude-sonnet-4-6', fork: true }, + { name: 'claude-sonnet-4-6-thinking', alias: 'gemini-claude-sonnet-4-6-thinking', fork: true }, { name: 'claude-sonnet-4-5', alias: 'gemini-claude-sonnet-4-5', fork: true }, { name: 'claude-sonnet-4-5-thinking', alias: 'gemini-claude-sonnet-4-5-thinking', fork: true }, { name: 'claude-opus-4-5-thinking', alias: 'gemini-claude-opus-4-5-thinking', fork: true }, diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 13b6c45c..eb6a87f5 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -103,6 +103,24 @@ export const MODEL_CATALOG: Partial> = dynamicAllowed: true, }, }, + { + id: 'claude-sonnet-4-6-thinking', + name: 'Claude Sonnet 4.6 Thinking', + description: 'Latest Sonnet with extended thinking', + thinking: { + type: 'budget', + min: 1024, + max: 128000, + 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', diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index a9ab8a84..cb1ddb34 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -116,8 +116,9 @@ export async function configureProviderModel( ? customSettingsPath.replace(/^~/, os.homedir()) : getProviderSettingsPath(provider); - // Skip if already configured (unless --config flag) - if (!force && fs.existsSync(settingsPath)) { + // Skip if already configured with a model (unless --config flag). + // A settings file can exist without model env keys (e.g., hook-only writes). + if (!force && getCurrentModel(provider, customSettingsPath)?.trim()) { return false; } diff --git a/src/utils/hooks/image-analyzer-profile-hook-injector.ts b/src/utils/hooks/image-analyzer-profile-hook-injector.ts index 94d414da..6c3c8316 100644 --- a/src/utils/hooks/image-analyzer-profile-hook-injector.ts +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -133,7 +133,8 @@ export function ensureProfileHooks(profileName: string): boolean { warn(`Malformed ${profileName}.settings.json: ${(parseError as Error).message}`) ); } - // Continue with empty settings, will add hooks + // Never overwrite malformed settings files; avoid destructive data loss. + return false; } } diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index 8b2916de..698f00f6 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -114,7 +114,8 @@ export function ensureProfileHooks(profileName: string): boolean { warn(`Malformed ${profileName}.settings.json: ${(parseError as Error).message}`) ); } - // Continue with empty settings, will add hooks + // Never overwrite malformed settings files; avoid destructive data loss. + return false; } } diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index 2b744bed..772d7a9b 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -59,10 +59,7 @@ describe('Config Generator', () => { assert(!configLine.includes('\\'), 'Should not contain backslashes'); // Verify it's valid YAML format - assert.strictEqual( - configLine, - 'auth-dir: "C:/Users/TestUser/.ccs/cliproxy/auth"' - ); + assert.strictEqual(configLine, 'auth-dir: "C:/Users/TestUser/.ccs/cliproxy/auth"'); }); it('handles multiple consecutive backslashes', () => { @@ -79,7 +76,10 @@ describe('Config Generator', () => { const complexPath = 'D:\\Projects\\ccs-2024\\test\\.ccs\\auth-tokens\\provider.json'; const normalizedPath = complexPath.replace(/\\/g, '/'); - assert.strictEqual(normalizedPath, 'D:/Projects/ccs-2024/test/.ccs/auth-tokens/provider.json'); + assert.strictEqual( + normalizedPath, + 'D:/Projects/ccs-2024/test/.ccs/auth-tokens/provider.json' + ); assert(normalizedPath.includes('Projects'), 'Should preserve directory names'); assert(normalizedPath.includes('auth-tokens'), 'Should preserve hyphens'); assert(normalizedPath.includes('provider.json'), 'Should preserve filenames'); @@ -348,7 +348,8 @@ auth-dir: "/test" }); it('handles tabs in indentation', () => { - const configContent = 'api-keys:\n\t- "ccs-internal-managed"\n\t- "user-key-with-tab"\n\nauth-dir: "/test"'; + const configContent = + 'api-keys:\n\t- "ccs-internal-managed"\n\t- "user-key-with-tab"\n\nauth-dir: "/test"'; const userKeys = parseUserApiKeys(configContent); @@ -544,11 +545,7 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" testPaths.forEach(({ input, expected }) => { const normalized = input.replace(/\\/g, '/'); - assert.strictEqual( - normalized, - expected, - `Should normalize "${input}" to "${expected}"` - ); + assert.strictEqual(normalized, expected, `Should normalize "${input}" to "${expected}"`); }); }); }); @@ -588,6 +585,7 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" // Claude aliases should have fork: true assert(config.includes('claude-sonnet-4-5'), 'Should include Claude sonnet model'); + assert(config.includes('claude-sonnet-4-6'), 'Should include Claude Sonnet 4.6 model'); assert(config.includes('fork: true'), 'Should include fork: true for Claude aliases'); // Verify fork: true appears after each Claude alias entry @@ -650,8 +648,14 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); - assert(config.includes('alias: gemini-3.1-pro-preview'), 'Should include dotted version alias'); - assert(config.includes('alias: gemini-3-1-pro-preview'), 'Should include hyphen version alias'); + assert( + config.includes('alias: gemini-3.1-pro-preview'), + 'Should include dotted version alias' + ); + assert( + config.includes('alias: gemini-3-1-pro-preview'), + 'Should include hyphen version alias' + ); assert( config.includes('alias: gemini-3-1-pro-preview-customtools'), 'Should include hyphen customtools alias' diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index 38370495..63efe2c7 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -2,7 +2,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import { getEffectiveEnvVars } from '../../../src/cliproxy/config/env-builder'; +import { + ensureProviderSettings, + getEffectiveEnvVars, +} from '../../../src/cliproxy/config/env-builder'; interface EnvSettings { ANTHROPIC_BASE_URL: string; @@ -24,13 +27,16 @@ function writeSettings( describe('getEffectiveEnvVars local provider URL normalization', () => { let tempHome: string; let settingsPath: string; + let originalCcsHome: string | undefined; beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-env-url-')); settingsPath = path.join(tempHome, 'codex.settings.json'); }); afterEach(() => { + process.env.CCS_HOME = originalCcsHome; fs.rmSync(tempHome, { recursive: true, force: true }); }); @@ -139,4 +145,54 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex'); expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini'); }); + + it('repairs existing provider settings files that are missing env keys', () => { + 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( + { + hooks: { + PreToolUse: [{ matcher: 'WebSearch', hooks: [] }], + }, + }, + null, + 2 + ) + ); + + ensureProviderSettings('agy'); + + const repaired = JSON.parse(fs.readFileSync(agySettingsPath, 'utf-8')) as { + env?: Record; + hooks?: Record; + }; + expect(repaired.hooks?.PreToolUse).toBeDefined(); + expect(repaired.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/agy'); + expect(repaired.env?.ANTHROPIC_MODEL).toBeDefined(); + expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeDefined(); + expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBeDefined(); + expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBeDefined(); + }); + + 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'); + fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true }); + fs.writeFileSync(agySettingsPath, '{"env": {"ANTHROPIC_MODEL": "claude-sonnet-4-6-thinking",}'); + + ensureProviderSettings('agy'); + + const repaired = JSON.parse(fs.readFileSync(agySettingsPath, 'utf-8')) as { + env?: Record; + }; + expect(repaired.env?.ANTHROPIC_MODEL).toBeDefined(); + + const backups = fs + .readdirSync(path.dirname(agySettingsPath)) + .filter((file) => file.startsWith('agy.settings.json.corrupt-')); + expect(backups.length).toBe(1); + }); }); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index b3fcfa68..3cd6d7e8 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -71,9 +71,7 @@ describe('Model Catalog', () => { it('includes Claude Opus 4.5 Thinking', () => { const { MODEL_CATALOG } = modelCatalog; - const opus = MODEL_CATALOG.agy.models.find( - (m) => m.id === 'claude-opus-4-5-thinking' - ); + const opus = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-opus-4-5-thinking'); assert(opus, 'Should include Claude Opus 4.5 Thinking'); assert.strictEqual(opus.name, 'Claude Opus 4.5 Thinking'); }); @@ -87,6 +85,22 @@ 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'); + assert(sonnet, 'Should include Claude Sonnet 4.6'); + assert.strictEqual(sonnet.name, 'Claude Sonnet 4.6'); + }); + it('includes Claude Sonnet 4.5', () => { const { MODEL_CATALOG } = modelCatalog; const sonnet = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-sonnet-4-5'); @@ -103,9 +117,9 @@ describe('Model Catalog', () => { assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier'); }); - it('has 5 models total', () => { + it('has 7 models total', () => { const { MODEL_CATALOG } = modelCatalog; - assert.strictEqual(MODEL_CATALOG.agy.models.length, 5); + assert.strictEqual(MODEL_CATALOG.agy.models.length, 7); }); }); @@ -266,9 +280,7 @@ describe('Model Catalog', () => { describe('Thinking models ordering', () => { it('Claude Opus 4.5 Thinking is not deprecated', () => { const { MODEL_CATALOG } = modelCatalog; - const opus = MODEL_CATALOG.agy.models.find( - (m) => m.id === 'claude-opus-4-5-thinking' - ); + const opus = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-opus-4-5-thinking'); assert(opus, 'Should include Claude Opus 4.5 Thinking'); assert.strictEqual(opus.deprecated, undefined, 'Should not be marked as deprecated'); }); @@ -279,7 +291,11 @@ describe('Model Catalog', () => { (m) => m.id === 'claude-sonnet-4-5-thinking' ); assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking'); - assert.strictEqual(sonnetThinking.deprecated, undefined, 'Should not be marked as deprecated'); + assert.strictEqual( + sonnetThinking.deprecated, + undefined, + 'Should not be marked as deprecated' + ); }); it('thinking models are at the top of the list', () => { @@ -288,9 +304,7 @@ describe('Model Catalog', () => { // Find indices of thinking models const opusIdx = models.findIndex((m) => m.id === 'claude-opus-4-5-thinking'); - const sonnetThinkingIdx = models.findIndex( - (m) => m.id === 'claude-sonnet-4-5-thinking' - ); + const sonnetThinkingIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-5-thinking'); // Find indices of non-thinking models const sonnetIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-5'); @@ -299,14 +313,8 @@ describe('Model Catalog', () => { // Thinking models should come before non-thinking models assert(opusIdx < sonnetIdx, 'Opus Thinking should be above non-thinking Sonnet'); assert(opusIdx < geminiIdx, 'Opus Thinking should be above non-thinking Gemini'); - assert( - sonnetThinkingIdx < sonnetIdx, - 'Sonnet Thinking should be above non-thinking Sonnet' - ); - assert( - sonnetThinkingIdx < geminiIdx, - 'Sonnet Thinking should be above non-thinking Gemini' - ); + assert(sonnetThinkingIdx < sonnetIdx, 'Sonnet Thinking should be above non-thinking Sonnet'); + assert(sonnetThinkingIdx < geminiIdx, 'Sonnet Thinking should be above non-thinking Gemini'); }); }); diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 244f570a..3e398d05 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -22,8 +22,8 @@ export const MODEL_CATALOGS: Record = { presetMapping: { default: 'claude-opus-4-6-thinking', opus: 'claude-opus-4-6-thinking', - sonnet: 'claude-sonnet-4-5-thinking', - haiku: 'claude-sonnet-4-5', + sonnet: 'claude-sonnet-4-6-thinking', + haiku: 'claude-sonnet-4-6', }, }, { @@ -33,8 +33,30 @@ export const MODEL_CATALOGS: Record = { presetMapping: { default: 'claude-opus-4-5-thinking', opus: 'claude-opus-4-5-thinking', - sonnet: 'claude-sonnet-4-5-thinking', - haiku: 'claude-sonnet-4-5', + 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', + haiku: 'claude-sonnet-4-6', + }, + }, + { + id: 'claude-sonnet-4-6', + name: 'Claude Sonnet 4.6', + description: 'Latest Sonnet baseline', + presetMapping: { + default: 'claude-sonnet-4-6', + opus: 'claude-opus-4-6-thinking', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-sonnet-4-6', }, }, {