Merge remote-tracking branch 'origin/dev' into pr-689-anthropic-direct-api

# Conflicts:
#	ui/src/components/profiles/profile-dialog.tsx
This commit is contained in:
Tam Nhu Tran committed 2026-03-07 15:59:44 +07:00
commit 5bcad30762
89 files changed
+6407 -726

No files matched your search

+19
View File
@@ -16,6 +16,15 @@ export {
type CreateApiProfileResult,
type RemoveApiProfileResult,
type UpdateApiProfileTargetResult,
type ProfileValidationIssue,
type ProfileValidationSummary,
type ApiProfileOrphanCandidate,
type DiscoverApiProfileOrphansResult,
type RegisterApiProfileOrphansResult,
type CopyApiProfileResult,
type ApiProfileExportBundle,
type ExportApiProfileResult,
type ImportApiProfileResult,
} from './profile-types';
// Profile read operations
@@ -30,6 +39,16 @@ export {
// Profile write operations
export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer';
// Lifecycle validation and operations
export { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
export {
discoverApiProfileOrphans,
registerApiProfileOrphans,
copyApiProfile,
exportApiProfile,
importApiProfileBundle,
} from './profile-lifecycle-service';
// OpenRouter catalog and picker
export { isOpenRouterUrl, fetchOpenRouterModels, type OpenRouterModel } from './openrouter-catalog';
export { pickOpenRouterModel, type OpenRouterSelection } from './openrouter-picker';
@@ -0,0 +1,409 @@
/**
* API profile lifecycle service.
*
* Discovery, registration, copy, export, and import for API profiles.
*/
import * as fs from 'fs';
import * as path from 'path';
import type { Config, Settings } from '../../types';
import type { TargetType } from '../../targets/target-adapter';
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import { isSensitiveKey } from '../../utils/sensitive-keys';
import { isReservedName } from '../../config/reserved-names';
import {
isUnifiedMode,
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
} from '../../config/unified-config-loader';
import { validateApiName } from './validation-service';
import { listApiProfiles } from './profile-reader';
import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
import type {
ApiProfileExportBundle,
CopyApiProfileResult,
DiscoverApiProfileOrphansResult,
ExportApiProfileResult,
ImportApiProfileResult,
RegisterApiProfileOrphansResult,
} from './profile-types';
const SETTINGS_FILE_SUFFIX = '.settings.json';
const REDACTED_TOKEN_SENTINEL = '__CCS_REDACTED__';
function parseTargetValue(value: unknown): TargetType | null {
if (value === 'claude' || value === 'droid') {
return value;
}
return null;
}
function validateProfileNameForPath(name: string, label: string): string | null {
const validationError = validateApiName(name);
if (validationError) {
return `Invalid ${label} profile name "${name}": ${validationError}`;
}
return null;
}
function getProfileSettingsPath(name: string): string {
return path.join(getCcsDir(), `${name}${SETTINGS_FILE_SUFFIX}`);
}
function writeJsonObjectAtomically(filePath: string, value: unknown): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const tempPath = `${filePath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + '\n', 'utf8');
fs.renameSync(tempPath, filePath);
}
function registerApiProfileInConfig(name: string, target: TargetType, force = false): void {
if (isUnifiedMode()) {
const config = loadOrCreateUnifiedConfig();
if (config.profiles[name] && !force) {
throw new Error(`API profile already exists: ${name}`);
}
config.profiles[name] = {
type: 'api',
settings: `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`,
...(target !== 'claude' && { target }),
};
saveUnifiedConfig(config);
return;
}
const configPath = getConfigPath();
const config = loadConfigSafe() as Config;
if (config.profiles[name] && !force) {
throw new Error(`API profile already exists: ${name}`);
}
config.profiles[name] = `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`;
config.profile_targets = config.profile_targets || {};
if (target === 'claude') {
delete config.profile_targets[name];
} else {
config.profile_targets[name] = target;
}
writeJsonObjectAtomically(configPath, config);
}
function getRegisteredSettingsFileNames(): Set<string> {
const { profiles, variants } = listApiProfiles();
const names = new Set<string>();
for (const profile of profiles) {
names.add(`${profile.name}${SETTINGS_FILE_SUFFIX}`);
}
for (const variant of variants) {
if (!variant.settings || variant.settings === '-') continue;
names.add(path.basename(variant.settings.replace(/^~\/\.ccs\//, '')));
}
return names;
}
function getProfileTarget(name: string): TargetType {
const { profiles } = listApiProfiles();
return profiles.find((profile) => profile.name === name)?.target || 'claude';
}
function readJsonObject(filePath: string): Record<string, unknown> {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Settings file must contain a JSON object.');
}
return parsed as Record<string, unknown>;
}
function rollbackSettingsFile(
filePath: string,
previousContent: string | null,
existedBefore: boolean
): void {
if (existedBefore && previousContent !== null) {
fs.writeFileSync(filePath, previousContent, 'utf8');
return;
}
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
export function discoverApiProfileOrphans(): DiscoverApiProfileOrphansResult {
const ccsDir = getCcsDir();
if (!fs.existsSync(ccsDir)) {
return { orphans: [] };
}
const registeredSettings = getRegisteredSettingsFileNames();
const files = fs.readdirSync(ccsDir).filter((file) => file.endsWith(SETTINGS_FILE_SUFFIX));
const ignoredNames = new Set(['cursor.settings.json']);
const orphans = files
.filter((file) => !registeredSettings.has(file))
.filter((file) => !file.startsWith('base-'))
.filter((file) => !ignoredNames.has(file))
.filter((file) => !isReservedName(file.slice(0, -SETTINGS_FILE_SUFFIX.length)))
.map((file) => {
const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length);
const settingsPath = path.join(ccsDir, file);
try {
const settings = readJsonObject(settingsPath);
return {
name,
settingsPath,
validation: validateApiProfileSettingsPayload(settings),
};
} catch (error) {
return {
name,
settingsPath,
validation: {
valid: false,
issues: [
{
level: 'error' as const,
code: 'invalid_json',
message: (error as Error).message,
field: 'settings',
hint: 'Fix JSON syntax before registration.',
},
],
},
};
}
});
return { orphans };
}
export function registerApiProfileOrphans(options?: {
names?: string[];
target?: TargetType;
force?: boolean;
}): RegisterApiProfileOrphansResult {
const discovered = discoverApiProfileOrphans();
const selected =
options?.names === undefined
? discovered.orphans
: discovered.orphans.filter((orphan) => options.names?.includes(orphan.name));
const result: RegisterApiProfileOrphansResult = { registered: [], skipped: [] };
for (const orphan of selected) {
const nameError = validateApiName(orphan.name);
if (nameError) {
result.skipped.push({
name: orphan.name,
reason: `Invalid profile name: ${nameError}`,
});
continue;
}
if (!options?.force && !orphan.validation.valid) {
result.skipped.push({
name: orphan.name,
reason: 'Validation failed. Use --force to register.',
});
continue;
}
try {
registerApiProfileInConfig(orphan.name, options?.target || 'claude', options?.force || false);
ensureProfileHooks(orphan.name);
result.registered.push(orphan.name);
} catch (error) {
result.skipped.push({ name: orphan.name, reason: (error as Error).message });
}
}
return result;
}
export function copyApiProfile(
source: string,
destination: string,
options?: { target?: TargetType; force?: boolean }
): CopyApiProfileResult {
const sourceError = validateProfileNameForPath(source, 'source');
if (sourceError) return { success: false, error: sourceError };
const destinationError = validateApiName(destination);
if (destinationError) return { success: false, error: destinationError };
const sourceSettingsPath = getProfileSettingsPath(source);
if (!fs.existsSync(sourceSettingsPath)) {
return { success: false, error: `Source profile settings not found: ${source}` };
}
const destinationSettingsPath = getProfileSettingsPath(destination);
if (fs.existsSync(destinationSettingsPath) && !options?.force) {
return { success: false, error: `Destination settings already exist: ${destination}` };
}
try {
const sourceSettings = readJsonObject(sourceSettingsPath) as Settings;
const validation = validateApiProfileSettingsPayload(sourceSettings);
if (!validation.valid && !options?.force) {
return {
success: false,
error: 'Source profile has validation errors. Use --force to copy.',
};
}
const destinationExisted = fs.existsSync(destinationSettingsPath);
const previousDestinationContent = destinationExisted
? fs.readFileSync(destinationSettingsPath, 'utf8')
: null;
writeJsonObjectAtomically(destinationSettingsPath, sourceSettings);
ensureProfileHooks(destination);
try {
registerApiProfileInConfig(
destination,
options?.target || getProfileTarget(source),
options?.force
);
} catch (registrationError) {
rollbackSettingsFile(destinationSettingsPath, previousDestinationContent, destinationExisted);
throw registrationError;
}
return {
success: true,
name: destination,
settingsPath: destinationSettingsPath,
warnings: validation.issues
.filter((issue) => issue.level === 'warning')
.map((issue) => issue.message),
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
export function exportApiProfile(name: string, includeSecrets = false): ExportApiProfileResult {
const nameError = validateProfileNameForPath(name, 'profile');
if (nameError) return { success: false, error: nameError };
const settingsPath = getProfileSettingsPath(name);
if (!fs.existsSync(settingsPath)) {
return { success: false, error: `Profile settings not found: ${name}` };
}
try {
const settings = readJsonObject(settingsPath);
let redacted = false;
if (!includeSecrets) {
const env = settings.env;
if (typeof env === 'object' && env !== null) {
for (const [key, value] of Object.entries(env as Record<string, unknown>)) {
if (!isSensitiveKey(key) || typeof value !== 'string') continue;
(env as Record<string, unknown>)[key] = REDACTED_TOKEN_SENTINEL;
redacted = true;
}
}
}
const bundle: ApiProfileExportBundle = {
schemaVersion: 1,
exportedAt: new Date().toISOString(),
profile: {
name,
target: getProfileTarget(name),
},
settings,
};
return { success: true, bundle, redacted };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
export function importApiProfileBundle(
bundle: unknown,
options?: { name?: string; target?: TargetType; force?: boolean }
): ImportApiProfileResult {
if (typeof bundle !== 'object' || bundle === null || Array.isArray(bundle)) {
return { success: false, error: 'Import bundle must be a JSON object.' };
}
const input = bundle as Partial<ApiProfileExportBundle>;
if (input.schemaVersion !== 1 || !input.profile || !input.settings) {
return {
success: false,
error: 'Invalid bundle schema. Expected schemaVersion=1 with profile and settings.',
};
}
const name = options?.name || input.profile.name;
const nameError = validateApiName(name);
if (nameError) return { success: false, error: nameError };
const bundleTarget = parseTargetValue(input.profile.target);
if (input.profile.target !== undefined && bundleTarget === null) {
return {
success: false,
error: 'Invalid bundle profile target. Expected: claude or droid.',
};
}
const settings = JSON.parse(JSON.stringify(input.settings)) as Record<string, unknown>;
const env = settings.env as Record<string, unknown> | undefined;
const warnings: string[] = [];
if (env) {
const redactedKeys = Object.entries(env)
.filter(([key, value]) => isSensitiveKey(key) && value === REDACTED_TOKEN_SENTINEL)
.map(([key]) => key);
if (redactedKeys.length > 0) {
for (const key of redactedKeys) {
env[key] = '';
}
warnings.push(
`Imported bundle had redacted values for ${redactedKeys.join(', ')}. Set secrets before use.`
);
}
}
const validation = validateApiProfileSettingsPayload(settings);
if (!validation.valid && !options?.force) {
return { success: false, error: 'Import validation failed.', validation };
}
const settingsPath = getProfileSettingsPath(name);
try {
const settingsExisted = fs.existsSync(settingsPath);
const previousSettingsContent = settingsExisted ? fs.readFileSync(settingsPath, 'utf8') : null;
writeJsonObjectAtomically(settingsPath, settings);
ensureProfileHooks(name);
try {
registerApiProfileInConfig(name, options?.target || bundleTarget || 'claude', options?.force);
} catch (registrationError) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw registrationError;
}
warnings.push(
...validation.issues
.filter((issue) => issue.level === 'warning')
.map((issue) => issue.message)
);
return { success: true, name, warnings, validation };
} catch (error) {
return { success: false, error: (error as Error).message, validation };
}
}
@@ -0,0 +1,150 @@
/**
* Profile lifecycle validation helpers.
*
* Shared by orphan discovery, copy, export/import, and dashboard routes.
*/
import {
extractProviderFromPathname,
getDeniedModelIdReasonForProvider,
} from '../../cliproxy/model-id-normalizer';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import type { CLIProxyProvider } from '../../cliproxy/types';
import type { ProfileValidationIssue, ProfileValidationSummary } from './profile-types';
const MODEL_ENV_KEYS = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
const ALLOWED_ANTHROPIC_ENV_KEYS = new Set<string>([
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_API_KEY',
...MODEL_ENV_KEYS,
]);
function resolveProviderFromBaseUrl(baseUrl: string): CLIProxyProvider | null {
if (!baseUrl.trim()) return null;
try {
const parsed = new URL(baseUrl);
const extracted = extractProviderFromPathname(parsed.pathname);
return extracted ? mapExternalProviderName(extracted) : null;
} catch {
const extracted = extractProviderFromPathname(baseUrl);
return extracted ? mapExternalProviderName(extracted) : null;
}
}
function pushIssue(
issues: ProfileValidationIssue[],
level: ProfileValidationIssue['level'],
code: string,
message: string,
field?: string,
hint?: string
): void {
issues.push({ level, code, message, field, hint });
}
function asObject(value: unknown): Record<string, unknown> | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
/**
* Validate an API profile settings payload and return actionable diagnostics.
*/
export function validateApiProfileSettingsPayload(settings: unknown): ProfileValidationSummary {
const issues: ProfileValidationIssue[] = [];
const settingsObj = asObject(settings);
if (!settingsObj) {
pushIssue(
issues,
'error',
'invalid_settings_type',
'Settings payload must be a JSON object.',
'settings',
'Expected object like { "env": { ... } }'
);
return { valid: false, issues };
}
const envObj = asObject(settingsObj.env);
if (!envObj) {
pushIssue(
issues,
'error',
'missing_env_object',
'settings.env must be a JSON object.',
'settings.env',
'Add env keys such as ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN.'
);
return { valid: false, issues };
}
const baseUrl =
typeof envObj.ANTHROPIC_BASE_URL === 'string' ? envObj.ANTHROPIC_BASE_URL.trim() : '';
if (!baseUrl) {
pushIssue(
issues,
'error',
'missing_base_url',
'ANTHROPIC_BASE_URL is required.',
'env.ANTHROPIC_BASE_URL',
'Example: https://api.openai.com/v1 or provider-specific endpoint.'
);
}
const authToken =
typeof envObj.ANTHROPIC_AUTH_TOKEN === 'string' ? envObj.ANTHROPIC_AUTH_TOKEN.trim() : '';
if (!authToken) {
pushIssue(
issues,
'warning',
'missing_auth_token',
'ANTHROPIC_AUTH_TOKEN is empty; profile may not run until token is configured.',
'env.ANTHROPIC_AUTH_TOKEN',
'Set token after import if exported in redacted mode.'
);
}
const provider = resolveProviderFromBaseUrl(baseUrl);
for (const modelKey of MODEL_ENV_KEYS) {
const value = envObj[modelKey];
if (typeof value !== 'string' || value.trim().length === 0) continue;
const denyReason = getDeniedModelIdReasonForProvider(value, provider);
if (denyReason) {
pushIssue(
issues,
'error',
'model_denylisted',
`${modelKey}: ${denyReason}`,
`env.${modelKey}`,
'Choose a supported model for the provider endpoint.'
);
}
}
for (const key of Object.keys(envObj)) {
if (key.startsWith('ANTHROPIC_') && !ALLOWED_ANTHROPIC_ENV_KEYS.has(key)) {
pushIssue(
issues,
'warning',
'unknown_anthropic_env_key',
`Unknown ANTHROPIC env key: ${key}`,
`env.${key}`,
'Check for typos or provider-unsupported settings.'
);
}
}
const hasErrors = issues.some((issue) => issue.level === 'error');
return { valid: !hasErrors, issues };
}
+73
View File
@@ -56,3 +56,76 @@ export interface UpdateApiProfileTargetResult {
target?: TargetType;
error?: string;
}
/** Validation severity for profile lifecycle checks */
export type ProfileValidationLevel = 'error' | 'warning';
/** Field-level validation issue emitted by lifecycle operations */
export interface ProfileValidationIssue {
level: ProfileValidationLevel;
code: string;
message: string;
field?: string;
hint?: string;
}
/** Validation summary for settings payload */
export interface ProfileValidationSummary {
valid: boolean;
issues: ProfileValidationIssue[];
}
/** Orphan settings file candidate discovered on disk */
export interface ApiProfileOrphanCandidate {
name: string;
settingsPath: string;
validation: ProfileValidationSummary;
}
/** Discovery result for orphan settings files */
export interface DiscoverApiProfileOrphansResult {
orphans: ApiProfileOrphanCandidate[];
}
/** Registration result for orphan settings files */
export interface RegisterApiProfileOrphansResult {
registered: string[];
skipped: Array<{ name: string; reason: string }>;
}
/** Copy result for API profile duplication */
export interface CopyApiProfileResult {
success: boolean;
name?: string;
settingsPath?: string;
warnings?: string[];
error?: string;
}
/** Portable export bundle schema */
export interface ApiProfileExportBundle {
schemaVersion: 1;
exportedAt: string;
profile: {
name: string;
target: TargetType;
};
settings: Record<string, unknown>;
}
/** Export operation result */
export interface ExportApiProfileResult {
success: boolean;
bundle?: ApiProfileExportBundle;
redacted?: boolean;
error?: string;
}
/** Import operation result */
export interface ImportApiProfileResult {
success: boolean;
name?: string;
warnings?: string[];
validation?: ProfileValidationSummary;
error?: string;
}