mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 00:14:15 +00:00
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:
1 parent
1f284132ce
commit
287691fa04
26 files changed
+1804
-465
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user