fix(cliproxy): preserve legacy openai-compat connectors on restart

This commit is contained in:
Tam Nhu Tran
2026-04-18 20:05:05 -04:00
parent b71e5845d5
commit 16f81fc8a5
5 changed files with 158 additions and 15 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap # CCS Project Roadmap
Last Updated: 2026-04-14 Last Updated: 2026-04-18
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes ### Recent Fixes
- **2026-04-18**: **#1038** Legacy OpenAI-compatible provider writes no longer self-destruct on the next `ccs cliproxy restart`. CCS now preserves AI-provider-managed top-level sections such as `openai-compatibility` during CLIProxy config regeneration, and the legacy `openai-compat` manager now rewrites only its own YAML section instead of dumping the whole file and stripping the generated version header. Regression coverage now proves the legacy helper keeps the generated header intact and that OpenAI-compatible connectors survive regeneration.
- **2026-04-16**: **#1030** Browser automation is now a first-class CCS surface instead of an env-only/runtime-only feature. CCS adds `ccs help browser`, `ccs browser status`, and `ccs browser doctor`; a dedicated `Settings -> Browser` dashboard tab for Claude Browser Attach and Codex Browser Tools; a new `browser` section in `~/.ccs/config.yaml`; explicit readiness/next-step messaging for attach-mode Chrome sessions; and Codex UI guidance that marks the managed `ccs_browser` entry as CCS-owned and redirects browser setup away from the generic MCP editor. - **2026-04-16**: **#1030** Browser automation is now a first-class CCS surface instead of an env-only/runtime-only feature. CCS adds `ccs help browser`, `ccs browser status`, and `ccs browser doctor`; a dedicated `Settings -> Browser` dashboard tab for Claude Browser Attach and Codex Browser Tools; a new `browser` section in `~/.ccs/config.yaml`; explicit readiness/next-step messaging for attach-mode Chrome sessions; and Codex UI guidance that marks the managed `ccs_browser` entry as CCS-owned and redirects browser setup away from the generic MCP editor.
- **2026-04-15**: **#969** Local CLIProxy bootstrap no longer depends on live GitHub reachability during normal dashboard and runtime startup. CCS now skips hidden auto-update lookups on standard CLIProxy bootstrap paths, fails fast with explicit `ccs cliproxy install` guidance when a service start needs a binary that is not installed locally, and keeps `ccs config` able to open the dashboard in limited mode instead of stalling behind blocked release downloads. - **2026-04-15**: **#969** Local CLIProxy bootstrap no longer depends on live GitHub reachability during normal dashboard and runtime startup. CCS now skips hidden auto-update lookups on standard CLIProxy bootstrap paths, fails fast with explicit `ccs cliproxy install` guidance when a service start needs a binary that is not installed locally, and keeps `ccs config` able to open the dashboard in limited mode instead of stalling behind blocked release downloads.
- **2026-04-15**: **#1010** Remote dashboard auth guidance now explains the Docker boundary explicitly. The readonly banner, remote login/setup card, and dashboard-auth docs now tell users that integrated Docker deployments keep config inside the running `ccs-cliproxy` container volume, so `ccs config auth setup` must run there rather than in the outer host shell. - **2026-04-15**: **#1010** Remote dashboard auth guidance now explains the Docker boundary explicitly. The readonly banner, remote login/setup card, and dashboard-auth docs now tell users that integrated Docker deployments keep config inside the running `ccs-cliproxy` container volume, so `ccs config auth setup` must run there rather than in the outer host shell.
+20 -6
View File
@@ -8,6 +8,7 @@ import * as path from 'path';
import type { CLIProxyProvider, ProviderConfig } from '../types'; import type { CLIProxyProvider, ProviderConfig } from '../types';
import { getProviderDisplayName } from '../provider-capabilities'; import { getProviderDisplayName } from '../provider-capabilities';
import { getModelMappingFromConfig } from '../base-config-loader'; import { getModelMappingFromConfig } from '../base-config-loader';
import { AI_PROVIDER_FAMILY_IDS } from '../ai-providers/types';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager'; import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager';
import { getDeniedModelIdReasonForProvider } from '../model-id-normalizer'; import { getDeniedModelIdReasonForProvider } from '../model-id-normalizer';
@@ -47,6 +48,11 @@ interface RegenerateConfigOptions {
authDir?: string; authDir?: string;
} }
interface PreservedYamlSection {
key: string;
body: string;
}
interface OAuthModelAliasEntry { interface OAuthModelAliasEntry {
name: string; name: string;
alias: string; alias: string;
@@ -754,8 +760,8 @@ export function regenerateConfig(
// Preserve user settings from existing config // Preserve user settings from existing config
let effectivePort = port; let effectivePort = port;
let userApiKeys: string[] = []; let userApiKeys: string[] = [];
let claudeApiKeySection = '';
let existingAliases = ''; let existingAliases = '';
const preservedSections: PreservedYamlSection[] = [];
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
try { try {
@@ -770,8 +776,16 @@ export function regenerateConfig(
// Preserve user-added API keys (fix for issue #200) // Preserve user-added API keys (fix for issue #200)
userApiKeys = parseUserApiKeys(content); userApiKeys = parseUserApiKeys(content);
// Preserve claude-api-key section (managed via dashboard/API) // Preserve AI provider sections managed outside the generated defaults.
claudeApiKeySection = extractYamlSection(content, 'claude-api-key'); for (const familyId of AI_PROVIDER_FAMILY_IDS) {
const sectionBody = extractYamlSection(content, familyId);
if (sectionBody) {
preservedSections.push({
key: familyId,
body: sectionBody,
});
}
}
// Preserve user customizations while pruning legacy generated Gemini preview noise. // Preserve user customizations while pruning legacy generated Gemini preview noise.
const existingConfigVersion = getConfigVersionFromContent(content); const existingConfigVersion = getConfigVersionFromContent(content);
@@ -801,9 +815,9 @@ export function regenerateConfig(
// Generate fresh config with preserved user API keys and aliases // Generate fresh config with preserved user API keys and aliases
let configContent = generateUnifiedConfigContent(effectivePort, userApiKeys, existingAliases); let configContent = generateUnifiedConfigContent(effectivePort, userApiKeys, existingAliases);
// Re-append claude-api-key section if it existed // Re-append managed top-level sections that are not part of the generated defaults.
if (claudeApiKeySection) { for (const section of preservedSections) {
configContent += `claude-api-key:\n${claudeApiKeySection}\n`; configContent += `${section.key}:\n${section.body}\n`;
} }
fs.writeFileSync(configPath, configContent, { mode: 0o600 }); fs.writeFileSync(configPath, configContent, { mode: 0o600 });
+27 -8
View File
@@ -7,7 +7,8 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import { getCliproxyConfigPath } from './config-generator'; import { configExists, getCliproxyConfigPath, regenerateConfig } from './config-generator';
import { rewriteTopLevelYamlSection } from './ai-providers/config-yaml-sections';
/** Model alias configuration */ /** Model alias configuration */
export interface OpenAICompatModel { export interface OpenAICompatModel {
@@ -62,17 +63,35 @@ function loadConfig(): ConfigYaml {
} }
/** /**
* Save config.yaml with proper formatting * Persist only the openai-compatibility section so the generated config header
* and unrelated user-managed sections survive legacy writes.
*/ */
function saveConfig(config: ConfigYaml): void { function saveConfig(config: ConfigYaml): void {
const configPath = getCliproxyConfigPath(); const configPath = getCliproxyConfigPath();
const content = yaml.dump(config, { if (!configExists()) {
lineWidth: -1, // Disable line wrapping regenerateConfig();
quotingType: '"', }
forceQuotes: false,
});
fs.writeFileSync(configPath, content, { mode: 0o600 }); const currentContent = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf-8') : '';
const sectionContent =
config['openai-compatibility'] && config['openai-compatibility'].length > 0
? yaml.dump(
{ 'openai-compatibility': config['openai-compatibility'] },
{
lineWidth: -1,
quotingType: '"',
forceQuotes: false,
}
)
: null;
const nextContent = rewriteTopLevelYamlSection(
currentContent,
'openai-compatibility',
sectionContent
);
const tempPath = `${configPath}.tmp`;
fs.writeFileSync(tempPath, nextContent, { mode: 0o600 });
fs.renameSync(tempPath, configPath);
} }
/** /**
@@ -445,6 +445,38 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
assert(newConfig.includes('port: 9999'), 'Should preserve custom port'); assert(newConfig.includes('port: 9999'), 'Should preserve custom port');
}); });
it('preserves openai-compatibility connectors during regeneration', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
const initialConfig = `# CLIProxyAPI config generated by CCS v17
port: 8317
api-keys:
- "ccs-internal-managed"
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
openai-compatibility:
- name: mimo
base-url: https://api.xiaomimimo.com/v1
api-key-entries:
- api-key: sk-test
models:
- name: mimo-v2-flash
alias: mimo-v2-flash
`;
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
regenerateConfig();
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
assert(newConfig.includes('openai-compatibility:'), 'Should preserve openai-compatibility');
assert(newConfig.includes('name: mimo'), 'Should preserve connector name');
assert(newConfig.includes('base-url: https://api.xiaomimimo.com/v1'), 'Should preserve base URL');
assert(newConfig.includes('alias: mimo-v2-flash'), 'Should preserve model aliases');
});
it('creates fresh config when none exists', () => { it('creates fresh config when none exists', () => {
// Ensure clean state // Ensure clean state
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
@@ -0,0 +1,77 @@
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
describe('openai-compat manager', () => {
let testDir;
let originalCcsHome;
let originalCcsDir;
let configGenerator;
let openAICompatManager;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-openai-compat-'));
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
process.env.CCS_HOME = testDir;
process.env.CCS_DIR = path.join(testDir, '.ccs');
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
delete require.cache[require.resolve('../../../dist/cliproxy/openai-compat-manager')];
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
configGenerator = require('../../../dist/cliproxy/config-generator');
openAICompatManager = require('../../../dist/cliproxy/openai-compat-manager');
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (originalCcsDir !== undefined) {
process.env.CCS_DIR = originalCcsDir;
} else {
delete process.env.CCS_DIR;
}
if (testDir && fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
it('preserves the generated header and connector entries across regeneration', () => {
configGenerator.regenerateConfig();
const configPath = configGenerator.getCliproxyConfigPath();
const initialHeader = fs.readFileSync(configPath, 'utf8').split('\n')[0];
openAICompatManager.addOpenAICompatProvider({
name: 'mimo',
baseUrl: 'https://api.xiaomimimo.com/v1',
apiKey: 'sk-test',
models: [{ name: 'mimo-v2-flash', alias: 'mimo-v2-flash' }],
});
const afterWrite = fs.readFileSync(configPath, 'utf8');
assert.strictEqual(afterWrite.split('\n')[0], initialHeader, 'Should preserve the generated header');
assert(afterWrite.includes('openai-compatibility:'), 'Should write the openai-compatibility section');
assert.strictEqual(
configGenerator.configNeedsRegeneration(),
false,
'Legacy openai-compat writes should not force regeneration'
);
configGenerator.regenerateConfig();
const afterRegen = fs.readFileSync(configPath, 'utf8');
assert(afterRegen.includes('openai-compatibility:'), 'Connector section should survive regeneration');
assert(afterRegen.includes('name: mimo'), 'Connector name should survive regeneration');
assert(
afterRegen.includes('base-url: https://api.xiaomimimo.com/v1'),
'Connector base URL should survive regeneration'
);
});
});