mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
fix(cliproxy): preserve legacy openai-compat connectors on restart
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
|
||||
|
||||
### 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-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.
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as path from 'path';
|
||||
import type { CLIProxyProvider, ProviderConfig } from '../types';
|
||||
import { getProviderDisplayName } from '../provider-capabilities';
|
||||
import { getModelMappingFromConfig } from '../base-config-loader';
|
||||
import { AI_PROVIDER_FAMILY_IDS } from '../ai-providers/types';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager';
|
||||
import { getDeniedModelIdReasonForProvider } from '../model-id-normalizer';
|
||||
@@ -47,6 +48,11 @@ interface RegenerateConfigOptions {
|
||||
authDir?: string;
|
||||
}
|
||||
|
||||
interface PreservedYamlSection {
|
||||
key: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface OAuthModelAliasEntry {
|
||||
name: string;
|
||||
alias: string;
|
||||
@@ -754,8 +760,8 @@ export function regenerateConfig(
|
||||
// Preserve user settings from existing config
|
||||
let effectivePort = port;
|
||||
let userApiKeys: string[] = [];
|
||||
let claudeApiKeySection = '';
|
||||
let existingAliases = '';
|
||||
const preservedSections: PreservedYamlSection[] = [];
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
@@ -770,8 +776,16 @@ export function regenerateConfig(
|
||||
// Preserve user-added API keys (fix for issue #200)
|
||||
userApiKeys = parseUserApiKeys(content);
|
||||
|
||||
// Preserve claude-api-key section (managed via dashboard/API)
|
||||
claudeApiKeySection = extractYamlSection(content, 'claude-api-key');
|
||||
// Preserve AI provider sections managed outside the generated defaults.
|
||||
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.
|
||||
const existingConfigVersion = getConfigVersionFromContent(content);
|
||||
@@ -801,9 +815,9 @@ export function regenerateConfig(
|
||||
// Generate fresh config with preserved user API keys and aliases
|
||||
let configContent = generateUnifiedConfigContent(effectivePort, userApiKeys, existingAliases);
|
||||
|
||||
// Re-append claude-api-key section if it existed
|
||||
if (claudeApiKeySection) {
|
||||
configContent += `claude-api-key:\n${claudeApiKeySection}\n`;
|
||||
// Re-append managed top-level sections that are not part of the generated defaults.
|
||||
for (const section of preservedSections) {
|
||||
configContent += `${section.key}:\n${section.body}\n`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, configContent, { mode: 0o600 });
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
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 */
|
||||
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 {
|
||||
const configPath = getCliproxyConfigPath();
|
||||
const content = yaml.dump(config, {
|
||||
lineWidth: -1, // Disable line wrapping
|
||||
quotingType: '"',
|
||||
forceQuotes: false,
|
||||
});
|
||||
if (!configExists()) {
|
||||
regenerateConfig();
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
// Ensure clean state
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user