feat(profiles): add cliproxy api profile bridge

- add backend and CLI flows for creating routed API profiles from CLIProxy providers

- add dashboard bridge CTAs, metadata, and guided create dialog mode

- cover bridge routes and dialog behavior with focused tests

Refs #649
This commit is contained in:
Tam Nhu Tran committed 2026-03-19 10:24:28 -04:00
1 parent 1f284132ce
commit 287691fa04
26 files changed
+1804 -465

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, loadConfigSafe } from '../../utils/config-manager';
import { buildProxyUrl, getProxyTarget } from '../../cliproxy/proxy-target-resolver';
import { getEffectiveApiKey } from '../../cliproxy/auth-token-manager';
import { getModelMappingFromConfig } from '../../cliproxy/base-config-loader';
import {
CLIPROXY_PROVIDER_IDS,
getProviderDescription,
getProviderDisplayName,
mapExternalProviderName,
} from '../../cliproxy/provider-capabilities';
import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer';
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import type { TargetType } from '../../targets/target-adapter';
import type { Settings } from '../../types/config';
import type { CLIProxyProvider } from '../../cliproxy/types';
import type {
CliproxyBridgeMetadata,
CliproxyBridgeProviderInfo,
ModelMapping,
ResolvedCliproxyBridgeProfile,
} from './profile-types';
const DEFAULT_PROFILE_SUFFIX = '-api';
function normalizeBridgeUrl(value: string): string {
try {
const parsed = new URL(value);
const hostname = parsed.hostname === 'localhost' ? '127.0.0.1' : parsed.hostname;
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
return `${parsed.protocol}//${hostname}:${parsed.port}${pathname}`;
} catch {
return value.trim().replace(/\/+$/, '');
}
}
function resolveProviderFromBaseUrl(baseUrl: unknown): CLIProxyProvider | null {
if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) {
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 hasConfiguredProfile(name: string): boolean {
if (isUnifiedMode()) {
const config = loadOrCreateUnifiedConfig();
return name in config.profiles;
}
const config = loadConfigSafe();
return name in config.profiles;
}
function hasSettingsFile(name: string): boolean {
return fs.existsSync(path.join(getCcsDir(), `${name}.settings.json`));
}
export function getDefaultCliproxyBridgeName(provider: CLIProxyProvider): string {
return `${provider}${DEFAULT_PROFILE_SUFFIX}`;
}
export function suggestCliproxyBridgeName(provider: CLIProxyProvider): string {
const baseName = getDefaultCliproxyBridgeName(provider);
if (!hasConfiguredProfile(baseName) && !hasSettingsFile(baseName)) {
return baseName;
}
for (let index = 2; index < 1000; index += 1) {
const candidate = `${baseName}-${index}`;
if (!hasConfiguredProfile(candidate) && !hasSettingsFile(candidate)) {
return candidate;
}
}
return `${baseName}-${Date.now()}`;
}
function resolveBridgeModelMapping(provider: CLIProxyProvider): ModelMapping {
const mapping = getModelMappingFromConfig(provider);
return {
default: mapping.defaultModel,
opus: mapping.opusModel || mapping.defaultModel,
sonnet: mapping.sonnetModel || mapping.defaultModel,
haiku: mapping.haikuModel || mapping.defaultModel,
};
}
export function listCliproxyBridgeProviders(): CliproxyBridgeProviderInfo[] {
return CLIPROXY_PROVIDER_IDS.map((provider) => ({
provider,
displayName: getProviderDisplayName(provider),
description: getProviderDescription(provider),
defaultProfileName: getDefaultCliproxyBridgeName(provider),
routePath: `/api/provider/${provider}`,
}));
}
export function resolveCliproxyBridgeProfile(
provider: CLIProxyProvider,
options: {
name?: string;
target?: TargetType;
} = {}
): ResolvedCliproxyBridgeProfile {
const target = getProxyTarget();
const profileName = options.name?.trim() || suggestCliproxyBridgeName(provider);
const baseUrl = buildProxyUrl(target, `/api/provider/${provider}`);
const apiKey = target.authToken ?? getEffectiveApiKey();
return {
name: profileName,
provider,
providerDisplayName: getProviderDisplayName(provider),
baseUrl,
apiKey,
models: resolveBridgeModelMapping(provider),
target: options.target || 'claude',
routePath: `/api/provider/${provider}`,
source: target.isRemote ? 'remote' : 'local',
};
}
export function resolveCliproxyBridgeMetadata(
settings: Pick<Settings, 'env'> | null | undefined
): CliproxyBridgeMetadata | null {
const provider = resolveProviderFromBaseUrl(settings?.env?.ANTHROPIC_BASE_URL);
if (!provider) {
return null;
}
const resolved = resolveCliproxyBridgeProfile(provider);
const actualBaseUrl = settings?.env?.ANTHROPIC_BASE_URL?.trim() || '';
const actualAuthToken = settings?.env?.ANTHROPIC_AUTH_TOKEN?.trim() || '';
return {
provider,
providerDisplayName: resolved.providerDisplayName,
routePath: resolved.routePath,
currentBaseUrl: resolved.baseUrl,
source: resolved.source,
usesCurrentTarget: normalizeBridgeUrl(actualBaseUrl) === normalizeBridgeUrl(resolved.baseUrl),
usesCurrentAuthToken: actualAuthToken.length > 0 && actualAuthToken === resolved.apiKey,
};
}
+12
View File
@@ -14,8 +14,12 @@ export {
type CliproxyVariantInfo,
type ApiListResult,
type CreateApiProfileResult,
type CreateCliproxyBridgeProfileResult,
type RemoveApiProfileResult,
type UpdateApiProfileTargetResult,
type CliproxyBridgeProviderInfo,
type CliproxyBridgeMetadata,
type ResolvedCliproxyBridgeProfile,
type ProfileValidationIssue,
type ProfileValidationSummary,
type ApiProfileOrphanCandidate,
@@ -38,6 +42,14 @@ export {
// Profile write operations
export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer';
export { createCliproxyBridgeProfile } from './profile-writer';
export {
getDefaultCliproxyBridgeName,
listCliproxyBridgeProviders,
resolveCliproxyBridgeMetadata,
resolveCliproxyBridgeProfile,
suggestCliproxyBridgeName,
} from './cliproxy-profile-bridge';
// Lifecycle validation and operations
export { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
+45 -18
View File
@@ -6,11 +6,13 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, loadConfigSafe } from '../../utils/config-manager';
import { loadConfigSafe } from '../../utils/config-manager';
import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
import { expandPath } from '../../utils/helpers';
import type { TargetType } from '../../targets/target-adapter';
import type { Settings } from '../../types/config';
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
import { resolveCliproxyBridgeMetadata } from './cliproxy-profile-bridge';
const VALID_TARGETS: ReadonlySet<TargetType> = new Set<TargetType>(['claude', 'droid']);
@@ -37,23 +39,43 @@ export function apiProfileExists(name: string): boolean {
}
}
/**
* Load settings file from a config reference such as ~/.ccs/name.settings.json.
*/
function loadProfileSettings(settingsReference: string | undefined): Settings | null {
if (!settingsReference || settingsReference === 'config.yaml') {
return null;
}
try {
const settingsPath = expandPath(settingsReference);
if (!fs.existsSync(settingsPath)) return null;
return JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as Settings;
} catch {
return null;
}
}
function isConfiguredFromSettings(settings: Settings | null): boolean {
const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || settings?.env?.ANTHROPIC_API_KEY || '';
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
}
function resolveProfileSettingsReference(name: string): string | undefined {
if (isUnifiedMode()) {
const config = loadOrCreateUnifiedConfig();
return config.profiles[name]?.settings;
}
const config = loadConfigSafe();
return config.profiles[name];
}
/**
* Check if API profile has real API key (not placeholder)
*/
export function isApiProfileConfigured(apiName: string): boolean {
try {
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${apiName}.settings.json`);
// Check settings.json file for API key
if (!fs.existsSync(settingsPath)) return false;
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || settings?.env?.ANTHROPIC_API_KEY || '';
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
} catch {
return false;
}
return isConfiguredFromSettings(loadProfileSettings(resolveProfileSettingsReference(apiName)));
}
/**
@@ -73,12 +95,15 @@ export function listApiProfiles(): ApiListResult {
if (name === 'default' && profile.settings?.includes('.claude/settings.json')) {
continue;
}
const settingsPath = profile.settings || 'config.yaml';
const settings = loadProfileSettings(settingsPath);
profiles.push({
name,
settingsPath: profile.settings || 'config.yaml',
isConfigured: isApiProfileConfigured(name),
settingsPath,
isConfigured: isConfiguredFromSettings(settings),
configSource: 'unified',
target: sanitizeTarget(profile.target),
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
});
}
// CLIProxy variants
@@ -103,12 +128,14 @@ export function listApiProfiles(): ApiListResult {
if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) {
continue;
}
const settings = loadProfileSettings(settingsPath as string);
profiles.push({
name,
settingsPath: settingsPath as string,
isConfigured: isApiProfileConfigured(name),
isConfigured: isConfiguredFromSettings(settings),
configSource: 'legacy',
target: sanitizeTarget(legacyTargetMap?.[name]),
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
});
}
// CLIProxy variants
+39
View File
@@ -5,6 +5,7 @@
*/
import type { TargetType } from '../../targets/target-adapter';
import type { CLIProxyProvider } from '../../cliproxy/types';
/** Model mapping for API profiles */
export interface ModelMapping {
@@ -21,6 +22,7 @@ export interface ApiProfileInfo {
isConfigured: boolean;
configSource: 'unified' | 'legacy';
target: TargetType;
cliproxyBridge?: CliproxyBridgeMetadata | null;
}
/** CLIProxy variant info */
@@ -44,6 +46,43 @@ export interface CreateApiProfileResult {
error?: string;
}
export interface CliproxyBridgeProviderInfo {
provider: CLIProxyProvider;
displayName: string;
description: string;
defaultProfileName: string;
routePath: string;
}
export interface CliproxyBridgeMetadata {
provider: CLIProxyProvider;
providerDisplayName: string;
routePath: string;
currentBaseUrl: string;
source: 'local' | 'remote';
usesCurrentTarget: boolean;
usesCurrentAuthToken: boolean;
}
export interface ResolvedCliproxyBridgeProfile {
name: string;
provider: CLIProxyProvider;
providerDisplayName: string;
baseUrl: string;
apiKey: string;
models: ModelMapping;
target: TargetType;
routePath: string;
source: 'local' | 'remote';
}
export interface CreateCliproxyBridgeProfileResult extends CreateApiProfileResult {
name?: string;
provider?: CLIProxyProvider;
target?: TargetType;
cliproxyBridge?: CliproxyBridgeMetadata | null;
}
/** Result from remove operation */
export interface RemoveApiProfileResult {
success: boolean;
+65
View File
@@ -6,6 +6,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import { validateApiName } from './validation-service';
import {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
@@ -14,6 +15,7 @@ import {
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import type { TargetType } from '../../targets/target-adapter';
import { resolveDroidProvider } from '../../targets/droid-provider';
import { isReservedName } from '../../config/reserved-names';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import {
extractProviderFromPathname,
@@ -23,9 +25,15 @@ import type { CLIProxyProvider } from '../../cliproxy/types';
import type {
ModelMapping,
CreateApiProfileResult,
CreateCliproxyBridgeProfileResult,
RemoveApiProfileResult,
UpdateApiProfileTargetResult,
} from './profile-types';
import { apiProfileExists } from './profile-reader';
import {
resolveCliproxyBridgeMetadata,
resolveCliproxyBridgeProfile,
} from './cliproxy-profile-bridge';
/** Check if URL is an OpenRouter endpoint */
function isOpenRouterUrl(baseUrl: string): boolean {
@@ -233,6 +241,63 @@ export function createApiProfile(
}
}
export function createCliproxyBridgeProfile(
provider: CLIProxyProvider,
options: {
name?: string;
force?: boolean;
target?: TargetType;
} = {}
): CreateCliproxyBridgeProfileResult {
const providedName = options.name?.trim();
if (providedName) {
const nameError = validateApiName(providedName);
if (nameError) {
return { success: false, settingsFile: '', error: nameError };
}
if (isReservedName(providedName)) {
return {
success: false,
settingsFile: '',
error: `Profile name '${providedName}' is reserved`,
};
}
}
const resolved = resolveCliproxyBridgeProfile(provider, options);
const settingsPath = path.join(getCcsDir(), `${resolved.name}.settings.json`);
if (!options.force && (apiProfileExists(resolved.name) || fs.existsSync(settingsPath))) {
return {
success: false,
settingsFile: '',
error: `Profile already exists: ${resolved.name}`,
};
}
const result = createApiProfile(
resolved.name,
resolved.baseUrl,
resolved.apiKey,
resolved.models,
resolved.target,
provider
);
return {
...result,
name: resolved.name,
provider,
target: resolved.target,
cliproxyBridge:
resolveCliproxyBridgeMetadata({
env: {
ANTHROPIC_BASE_URL: resolved.baseUrl,
ANTHROPIC_AUTH_TOKEN: resolved.apiKey,
},
}) ?? null,
};
}
/**
* Update API profile target (claude/droid).
* Persists to config.yaml in unified mode and config.json profile_targets in legacy mode.