feat(image-analysis): resolve backend status per profile

This commit is contained in:
Tam Nhu Tran committed 2026-04-01 15:32:37 -04:00
1 parent 8e4f56d8f9
commit ae459fc3d7
17 files changed
+932 -48

No files matched your search

+29
View File
@@ -64,6 +64,35 @@ export interface CliproxyBridgeMetadata {
usesCurrentAuthToken: boolean;
}
export interface ImageAnalysisProfileStatus {
enabled: boolean;
supported: boolean;
status: 'active' | 'mapped' | 'attention' | 'disabled' | 'skipped' | 'hook-missing';
backendId: string | null;
backendDisplayName: string | null;
model: string | null;
resolutionSource:
| 'cliproxy-provider'
| 'cliproxy-variant'
| 'cliproxy-composite'
| 'copilot-alias'
| 'cliproxy-bridge'
| 'profile-backend'
| 'fallback-backend'
| 'disabled'
| 'unsupported-profile'
| 'unresolved'
| 'missing-model';
reason: string | null;
shouldPersistHook: boolean;
persistencePath: string | null;
runtimePath: string | null;
usesCurrentTarget: boolean | null;
usesCurrentAuthToken: boolean | null;
hookInstalled: boolean | null;
sharedHookInstalled: boolean | null;
}
export interface ResolvedCliproxyBridgeProfile {
name: string;
provider: CLIProxyProvider;
+28 -8
View File
@@ -33,7 +33,7 @@ import {
} from './utils/websearch-manager';
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
import { getImageAnalysisHookEnv } from './utils/hooks';
import { getImageAnalysisHookEnv, installImageAnalyzerHook } from './utils/hooks';
import { fail, info, warn } from './utils/ui';
import { isCopilotSubcommandToken } from './copilot/constants';
import {
@@ -682,10 +682,15 @@ async function main(): Promise<void> {
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
}
// Inject Image Analyzer hook into profile settings before launch
ensureImageAnalyzerHooks(profileInfo.name);
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
// Inject Image Analyzer hook into profile settings before launch
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
cliproxyProvider: provider,
isComposite: profileInfo.isComposite,
settingsPath: profileInfo.settingsPath ? expandPath(profileInfo.settingsPath) : undefined,
});
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
const variantPort = profileInfo.port; // variant-specific port for isolation
const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT;
@@ -839,8 +844,12 @@ async function main(): Promise<void> {
} else if (profileInfo.type === 'copilot') {
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
ensureWebSearchMcpOrThrow();
installImageAnalyzerHook();
// Inject Image Analyzer hook into profile settings before launch
ensureImageAnalyzerHooks(profileInfo.name);
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
});
const { executeCopilotProfile } = await import('./copilot');
const copilotConfig = profileInfo.copilotConfig;
@@ -871,9 +880,8 @@ async function main(): Promise<void> {
// Settings-based profiles (glm, glmt) are third-party providers
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
installImageAnalyzerHook();
}
// Inject Image Analyzer hook into profile settings before launch
ensureImageAnalyzerHooks(profileInfo.name);
// Display WebSearch status (single line, equilibrium UX)
displayWebSearchStatus();
@@ -902,6 +910,13 @@ async function main(): Promise<void> {
: getSettingsPath(profileInfo.name));
const settings = resolvedSettings ?? loadSettings(expandedSettingsPath);
const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings);
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
settingsPath: expandedSettingsPath,
settings,
cliproxyBridge,
});
if (resolvedTarget !== 'claude') {
const compatibility = evaluateTargetRuntimeCompatibility({
target: resolvedTarget,
@@ -998,7 +1013,12 @@ async function main(): Promise<void> {
}
const webSearchEnv = getWebSearchHookEnv();
const imageAnalysisEnv = getImageAnalysisHookEnv(profileInfo.name);
const imageAnalysisEnv = getImageAnalysisHookEnv({
profileName: profileInfo.name,
profileType: profileInfo.type,
settings,
cliproxyBridge,
});
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
const globalEnvConfig = getGlobalEnvConfig();
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
+19 -4
View File
@@ -161,7 +161,12 @@ export function createSettingsFile(
}
// Inject Image Analyzer hooks into variant settings
ensureImageAnalyzerHooks(`${provider}-${name}`);
ensureImageAnalyzerHooks({
profileName: `${provider}-${name}`,
profileType: 'cliproxy',
cliproxyProvider: provider,
settingsPath,
});
return settingsPath;
}
@@ -195,7 +200,12 @@ export function createSettingsFileUnified(
}
// Inject Image Analyzer hooks into variant settings
ensureImageAnalyzerHooks(`${provider}-${name}`);
ensureImageAnalyzerHooks({
profileName: `${provider}-${name}`,
profileType: 'cliproxy',
cliproxyProvider: provider,
settingsPath,
});
return settingsPath;
}
@@ -284,7 +294,6 @@ export function createCompositeSettingsFile(
ensureDir(settingsDir);
writeSettings(settingsPath, settings);
// Hook injectors target ~/.ccs/<profile>.settings.json; only run for default path.
if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) {
try {
ensureWebSearchMcpOrThrow();
@@ -292,7 +301,13 @@ export function createCompositeSettingsFile(
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
}
ensureImageAnalyzerHooks(`composite-${name}`);
ensureImageAnalyzerHooks({
profileName: `composite-${name}`,
profileType: 'cliproxy',
cliproxyProvider: tiers[defaultTier].provider,
isComposite: true,
settingsPath,
});
}
return settingsPath;
@@ -18,13 +18,19 @@ import {
mapExternalProviderName,
} from '../cliproxy/provider-capabilities';
import { extractOption, hasAnyFlag } from './arg-extractor';
import { normalizeImageAnalysisBackendId } from '../utils/hooks';
interface ImageAnalysisCommandOptions {
enable?: boolean;
disable?: boolean;
timeout?: number;
setModel?: { provider: string; model: string };
setFallback?: string;
setProfileBackend?: { profile: string; backend: string };
clearProfileBackend?: string;
setModelError?: string;
setFallbackError?: string;
setProfileBackendError?: string;
help?: boolean;
}
@@ -62,6 +68,36 @@ function parseArgs(args: string[]): ImageAnalysisCommandOptions {
}
}
const setFallbackIdx = args.indexOf('--set-fallback');
if (setFallbackIdx !== -1) {
const backend = args[setFallbackIdx + 1];
if (backend && !backend.startsWith('-')) {
options.setFallback = backend;
} else {
options.setFallbackError = '--set-fallback requires <backend>';
}
}
const setProfileBackendIdx = args.indexOf('--set-profile-backend');
if (setProfileBackendIdx !== -1) {
const profile = args[setProfileBackendIdx + 1];
const backend = args[setProfileBackendIdx + 2];
if (profile && backend && !profile.startsWith('-') && !backend.startsWith('-')) {
options.setProfileBackend = { profile, backend };
} else {
options.setProfileBackendError = '--set-profile-backend requires <profile> <backend>';
}
}
const clearProfileBackend = extractOption(args, ['--clear-profile-backend']);
if (clearProfileBackend.found) {
if (clearProfileBackend.value && !clearProfileBackend.value.startsWith('-')) {
options.clearProfileBackend = clearProfileBackend.value;
} else {
options.setProfileBackendError = '--clear-profile-backend requires <profile>';
}
}
return options;
}
@@ -82,6 +118,13 @@ function showHelp(): void {
console.log(` ${color('--disable', 'command')} Disable image analysis`);
console.log(` ${color('--timeout <seconds>', 'command')} Set analysis timeout (10-600)`);
console.log(` ${color('--set-model <p> <m>', 'command')} Set model for provider`);
console.log(` ${color('--set-fallback <backend>', 'command')} Set fallback backend`);
console.log(
` ${color('--set-profile-backend <p> <b>', 'command')} Map a profile alias to a backend`
);
console.log(
` ${color('--clear-profile-backend <p>', 'command')} Remove a saved profile mapping`
);
console.log(` ${color('--help, -h', 'command')} Show this help`);
console.log('');
@@ -157,6 +200,15 @@ function showStatus(): void {
console.log(subheader('Configuration:'));
console.log(` File: ${color('~/.ccs/config.yaml', 'path')}`);
console.log(` Section: ${dim('image_analysis')}`);
console.log(` Fallback backend: ${color(config.fallback_backend || 'none', 'command')}`);
const profileBackends = Object.entries(config.profile_backends ?? {});
if (profileBackends.length > 0) {
console.log('');
console.log(subheader('Profile Backends:'));
for (const [profile, backend] of profileBackends) {
console.log(` ${color(profile.padEnd(16), 'command')} ${backend}`);
}
}
console.log('');
// Troubleshooting hint if disabled
@@ -181,6 +233,16 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise<
process.exit(1);
}
if (options.setFallbackError) {
console.error(fail(options.setFallbackError));
process.exit(1);
}
if (options.setProfileBackendError) {
console.error(fail(options.setProfileBackendError));
process.exit(1);
}
// Validate conflicting flags (Edge case #2: --enable + --disable conflict)
if (options.enable && options.disable) {
console.error(fail('Cannot use --enable and --disable together'));
@@ -229,6 +291,51 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise<
hasChanges = true;
}
if (options.setFallback) {
const normalizedBackend = normalizeImageAnalysisBackendId(
options.setFallback,
Object.keys(imageConfig.provider_models)
);
if (!normalizedBackend) {
console.error(fail(`Invalid fallback backend: ${options.setFallback}`));
process.exit(1);
}
imageConfig.fallback_backend = normalizedBackend;
hasChanges = true;
}
if (options.setProfileBackend) {
const profileName = options.setProfileBackend.profile.trim();
const normalizedBackend = normalizeImageAnalysisBackendId(
options.setProfileBackend.backend,
Object.keys(imageConfig.provider_models)
);
if (!profileName) {
console.error(fail('Profile name cannot be empty'));
process.exit(1);
}
if (!normalizedBackend) {
console.error(fail(`Invalid backend: ${options.setProfileBackend.backend}`));
process.exit(1);
}
imageConfig.profile_backends = {
...(imageConfig.profile_backends ?? {}),
[profileName]: normalizedBackend,
};
hasChanges = true;
}
if (options.clearProfileBackend) {
const profileName = options.clearProfileBackend.trim().toLowerCase();
const nextProfileBackends = Object.fromEntries(
Object.entries(imageConfig.profile_backends ?? {}).filter(
([name]) => name.trim().toLowerCase() !== profileName
)
);
imageConfig.profile_backends = nextProfileBackends;
hasChanges = true;
}
if (hasChanges) {
updateUnifiedConfig({ image_analysis: imageConfig });
console.log(ok('Configuration updated'));
+13 -4
View File
@@ -44,6 +44,7 @@ import {
normalizeOfficialChannelIds,
resolveLegacyDiscordSelection,
} from '../channels/official-channels-runtime';
import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver';
const CONFIG_YAML = 'config.yaml';
const CONFIG_JSON = 'config.json';
@@ -556,12 +557,16 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours,
},
// Image analysis config - enabled by default for CLIProxy providers
image_analysis: {
image_analysis: canonicalizeImageAnalysisConfig({
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
provider_models:
partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
},
fallback_backend:
partial.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend,
profile_backends:
partial.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends,
}),
};
}
@@ -1267,12 +1272,16 @@ export function getDashboardAuthConfig(): DashboardAuthConfig {
export function getImageAnalysisConfig(): ImageAnalysisConfig {
const config = loadOrCreateUnifiedConfig();
return {
return canonicalizeImageAnalysisConfig({
enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
provider_models:
config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
};
fallback_backend:
config.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend,
profile_backends:
config.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends,
});
}
/**
+6
View File
@@ -759,6 +759,10 @@ export interface ImageAnalysisConfig {
timeout: number;
/** Provider-to-model mapping for vision analysis */
provider_models: Record<string, string>;
/** Fallback backend used when a profile does not resolve to a provider-specific backend */
fallback_backend?: string;
/** Explicit profile-name-to-backend overrides for settings/custom aliases */
profile_backends?: Record<string, string>;
}
/**
@@ -780,6 +784,8 @@ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = {
iflow: 'qwen3-vl-plus',
kimi: 'vision-model',
},
fallback_backend: 'gemini',
profile_backends: {},
};
/**
+4 -1
View File
@@ -165,7 +165,10 @@ export async function executeCopilotProfile(
// Merge with current environment (global env first, copilot overrides, then hook env vars)
const webSearchEnv = getWebSearchHookEnv();
const imageAnalysisEnv = getImageAnalysisHookEnv('copilot');
const imageAnalysisEnv = getImageAnalysisHookEnv({
profileName: 'copilot',
profileType: 'copilot',
});
const env = stripClaudeCodeEnv({
...process.env,
...globalEnv,
+21 -7
View File
@@ -8,6 +8,11 @@
*/
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import {
resolveImageAnalysisStatus,
type ImageAnalysisResolutionContext,
} from './image-analysis-backend-resolver';
/**
* Serialize provider_models map to env var format: provider:model,provider:model
@@ -22,21 +27,30 @@ function serializeProviderModels(providerModels: Record<string, string>): string
* Get image analysis hook environment variables.
* These env vars control the hook's behavior via Claude Code hook system.
*
* @param provider - Current CLIProxy provider (e.g., 'agy', 'gemini', 'codex')
* @param input - Current runtime context
* @returns Environment variables for image analysis hook
*/
export function getImageAnalysisHookEnv(provider?: string): Record<string, string> {
export function getImageAnalysisHookEnv(
input?: string | ImageAnalysisResolutionContext
): Record<string, string> {
const config = getImageAnalysisConfig();
// Check if current provider has a vision model configured
const hasVisionModel = provider && config.provider_models[provider];
const skipImageAnalysis = !config.enabled || !hasVisionModel;
const context =
typeof input === 'string'
? {
profileName: input,
cliproxyProvider: mapExternalProviderName(input) ?? undefined,
}
: input;
const status = context
? resolveImageAnalysisStatus(context, config)
: resolveImageAnalysisStatus({ profileName: '' }, config);
const skipImageAnalysis = !status.supported;
return {
CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0',
CCS_IMAGE_ANALYSIS_TIMEOUT: String(Number(config.timeout) || 60),
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models),
CCS_CURRENT_PROVIDER: provider || '',
CCS_CURRENT_PROVIDER: status.backendId || '',
CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0',
};
}
@@ -0,0 +1,379 @@
import {
DEFAULT_IMAGE_ANALYSIS_CONFIG,
type ImageAnalysisConfig,
} from '../../config/unified-config-types';
import {
getProviderDisplayName,
isCLIProxyProvider,
mapExternalProviderName,
} from '../../cliproxy/provider-capabilities';
import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer';
import type { CliproxyBridgeMetadata } from '../../api/services/profile-types';
import type { Settings } from '../../types/config';
import type { ProfileType } from '../../types/profile';
export type ImageAnalysisResolutionSource =
| 'cliproxy-provider'
| 'cliproxy-variant'
| 'cliproxy-composite'
| 'copilot-alias'
| 'cliproxy-bridge'
| 'profile-backend'
| 'fallback-backend'
| 'disabled'
| 'unsupported-profile'
| 'unresolved'
| 'missing-model';
export type ImageAnalysisStatusCode =
| 'active'
| 'mapped'
| 'attention'
| 'disabled'
| 'skipped'
| 'hook-missing';
export interface ImageAnalysisResolutionContext {
profileName: string;
profileType?: ProfileType;
settingsPath?: string | null;
cliproxyProvider?: string | null;
isComposite?: boolean;
settings?: Pick<Settings, 'env'> | null;
cliproxyBridge?: CliproxyBridgeMetadata | null;
hookInstalled?: boolean;
sharedHookInstalled?: boolean;
}
export interface ImageAnalysisStatus {
enabled: boolean;
supported: boolean;
status: ImageAnalysisStatusCode;
backendId: string | null;
backendDisplayName: string | null;
model: string | null;
resolutionSource: ImageAnalysisResolutionSource;
reason: string | null;
shouldPersistHook: boolean;
persistencePath: string | null;
runtimePath: string | null;
usesCurrentTarget: boolean | null;
usesCurrentAuthToken: boolean | null;
hookInstalled: boolean | null;
sharedHookInstalled: boolean | null;
}
function resolveProviderFromBaseUrl(baseUrl: unknown): string | 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 findCaseInsensitiveKey(
entries: Record<string, string> | undefined,
requestedKey: string
): string | null {
if (!entries) {
return null;
}
const normalizedRequestedKey = requestedKey.trim().toLowerCase();
for (const key of Object.keys(entries)) {
if (key.trim().toLowerCase() === normalizedRequestedKey) {
return key;
}
}
return null;
}
export function normalizeImageAnalysisBackendId(
value: string | null | undefined,
knownBackends: Iterable<string> = []
): string | null {
if (!value || value.trim().length === 0) {
return null;
}
const trimmed = value.trim();
const canonicalProvider = mapExternalProviderName(trimmed.toLowerCase());
if (canonicalProvider) {
return canonicalProvider;
}
const knownBackendList = Array.from(knownBackends);
const exactKey = knownBackendList.find((backend) => backend === trimmed);
if (exactKey) {
return exactKey;
}
const caseInsensitiveKey = knownBackendList.find(
(backend) => backend.trim().toLowerCase() === trimmed.toLowerCase()
);
if (caseInsensitiveKey) {
return caseInsensitiveKey;
}
return trimmed.toLowerCase();
}
export function canonicalizeImageAnalysisConfig(config: ImageAnalysisConfig): ImageAnalysisConfig {
const normalizedProviderModels = Object.entries(config.provider_models ?? {}).reduce(
(acc, [backend, model]) => {
const normalizedBackend = normalizeImageAnalysisBackendId(
backend,
Object.keys(DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models)
);
if (!normalizedBackend || typeof model !== 'string' || model.trim().length === 0) {
return acc;
}
acc[normalizedBackend] = model.trim();
return acc;
},
{} as Record<string, string>
);
const normalizedFallbackBackend =
normalizeImageAnalysisBackendId(
config.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend,
Object.keys(normalizedProviderModels)
) ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend;
const normalizedProfileBackends = Object.entries(config.profile_backends ?? {}).reduce(
(acc, [profileName, backend]) => {
const trimmedProfileName = profileName.trim();
const normalizedBackend = normalizeImageAnalysisBackendId(
backend,
Object.keys(normalizedProviderModels)
);
if (!trimmedProfileName || !normalizedBackend) {
return acc;
}
acc[trimmedProfileName] = normalizedBackend;
return acc;
},
{} as Record<string, string>
);
return {
enabled: config.enabled,
timeout: config.timeout,
provider_models: normalizedProviderModels,
fallback_backend: normalizedFallbackBackend,
profile_backends: normalizedProfileBackends,
};
}
function resolveConfiguredProfileBackend(
profileName: string,
config: ImageAnalysisConfig
): string | null {
if (!config.profile_backends) {
return null;
}
const exactKey = config.profile_backends[profileName];
if (exactKey) {
return normalizeImageAnalysisBackendId(exactKey, Object.keys(config.provider_models));
}
const matchedKey = findCaseInsensitiveKey(config.profile_backends, profileName);
if (!matchedKey) {
return null;
}
return normalizeImageAnalysisBackendId(
config.profile_backends[matchedKey],
Object.keys(config.provider_models)
);
}
function getBackendDisplayName(backendId: string | null): string | null {
if (!backendId) {
return null;
}
return isCLIProxyProvider(backendId) ? getProviderDisplayName(backendId) : backendId;
}
function getRuntimePath(backendId: string | null): string | null {
if (!backendId) {
return null;
}
return `/api/provider/${backendId}`;
}
function resolveBackend(
context: ImageAnalysisResolutionContext,
config: ImageAnalysisConfig
): Pick<ImageAnalysisStatus, 'backendId' | 'backendDisplayName' | 'resolutionSource' | 'reason'> {
const { profileName, profileType, cliproxyProvider, isComposite, cliproxyBridge, settings } =
context;
if (!config.enabled) {
return {
backendId: null,
backendDisplayName: null,
resolutionSource: 'disabled',
reason: 'Disabled globally.',
};
}
if (profileType === 'default' || profileType === 'account') {
return {
backendId: null,
backendDisplayName: null,
resolutionSource: 'unsupported-profile',
reason: 'This profile type is not currently covered by image-analysis runtime.',
};
}
if (profileType === 'copilot' || profileName === 'copilot') {
const backendId = normalizeImageAnalysisBackendId('ghcp', Object.keys(config.provider_models));
return {
backendId,
backendDisplayName: getBackendDisplayName(backendId),
resolutionSource: 'copilot-alias',
reason: null,
};
}
const normalizedCliproxyProvider = normalizeImageAnalysisBackendId(
cliproxyProvider,
Object.keys(config.provider_models)
);
if (normalizedCliproxyProvider) {
return {
backendId: normalizedCliproxyProvider,
backendDisplayName: getBackendDisplayName(normalizedCliproxyProvider),
resolutionSource:
isComposite || profileName.startsWith('composite-')
? 'cliproxy-composite'
: profileName === normalizedCliproxyProvider
? 'cliproxy-provider'
: 'cliproxy-variant',
reason: null,
};
}
const bridgeBackend = normalizeImageAnalysisBackendId(
cliproxyBridge?.provider ??
resolveProviderFromBaseUrl(settings?.env?.ANTHROPIC_BASE_URL ?? undefined),
Object.keys(config.provider_models)
);
if (bridgeBackend) {
return {
backendId: bridgeBackend,
backendDisplayName: getBackendDisplayName(bridgeBackend),
resolutionSource: 'cliproxy-bridge',
reason: null,
};
}
const mappedBackend = resolveConfiguredProfileBackend(profileName, config);
if (mappedBackend) {
return {
backendId: mappedBackend,
backendDisplayName: getBackendDisplayName(mappedBackend),
resolutionSource: 'profile-backend',
reason: null,
};
}
const fallbackBackend = normalizeImageAnalysisBackendId(
config.fallback_backend,
Object.keys(config.provider_models)
);
if (fallbackBackend) {
return {
backendId: fallbackBackend,
backendDisplayName: getBackendDisplayName(fallbackBackend),
resolutionSource: 'fallback-backend',
reason: null,
};
}
return {
backendId: null,
backendDisplayName: null,
resolutionSource: 'unresolved',
reason: 'No supported backend could be resolved.',
};
}
export function resolveImageAnalysisStatus(
context: ImageAnalysisResolutionContext,
rawConfig: ImageAnalysisConfig = DEFAULT_IMAGE_ANALYSIS_CONFIG
): ImageAnalysisStatus {
const config = canonicalizeImageAnalysisConfig(rawConfig);
const resolution = resolveBackend(context, config);
const model = resolution.backendId
? (config.provider_models[resolution.backendId] ?? null)
: null;
const shouldPersistHook =
config.enabled && context.profileType !== 'default' && context.profileType !== 'account';
let status: ImageAnalysisStatusCode = 'active';
let reason = resolution.reason;
if (!config.enabled) {
status = 'disabled';
reason ??=
'This profile falls back to native Read because image analysis is turned off in CCS config.';
} else if (!resolution.backendId) {
status = 'skipped';
reason ??= 'No supported backend could be resolved.';
} else if (!model) {
status = 'skipped';
reason = 'Resolved backend has no image-analysis model configured.';
} else if (
shouldPersistHook &&
(context.hookInstalled === false || context.sharedHookInstalled === false)
) {
status = 'hook-missing';
reason =
context.sharedHookInstalled === false
? 'Shared image-analysis hook is not installed.'
: 'Profile hook is missing from the persisted settings file.';
} else if (
resolution.resolutionSource === 'cliproxy-bridge' &&
context.cliproxyBridge &&
(!context.cliproxyBridge.usesCurrentTarget || !context.cliproxyBridge.usesCurrentAuthToken)
) {
status = 'attention';
reason =
'Active, but runtime uses the current CLIProxy target instead of the stale route saved in this profile.';
} else if (resolution.resolutionSource === 'profile-backend') {
status = 'mapped';
}
return {
enabled: config.enabled,
supported: Boolean(config.enabled && resolution.backendId && model),
status,
backendId: resolution.backendId,
backendDisplayName: resolution.backendDisplayName,
model,
resolutionSource: resolution.resolutionSource,
reason,
shouldPersistHook,
persistencePath: shouldPersistHook ? `${context.profileName}.settings.json` : null,
runtimePath: getRuntimePath(resolution.backendId),
usesCurrentTarget: context.cliproxyBridge?.usesCurrentTarget ?? null,
usesCurrentAuthToken: context.cliproxyBridge?.usesCurrentAuthToken ?? null,
hookInstalled: context.hookInstalled ?? null,
sharedHookInstalled: context.sharedHookInstalled ?? null,
};
}
@@ -4,7 +4,7 @@
* Injects image analyzer hooks into per-profile settings files.
* This replaces the global ~/.claude/settings.json approach.
*
* Injects for profiles configured in image_analysis.provider_models.
* Injects for profiles that resolve to a supported image-analysis backend.
*
* @module utils/hooks/image-analyzer-profile-injector
*/
@@ -18,9 +18,13 @@ import {
} from './image-analyzer-hook-configuration';
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
import { getCcsDir } from '../config-manager';
import {
resolveImageAnalysisStatus,
type ImageAnalysisResolutionContext,
} from './image-analysis-backend-resolver';
// Valid profile name pattern (alphanumeric, dash, underscore only)
const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/;
// Valid profile name pattern (alphanumeric, dot, dash, underscore only)
const VALID_PROFILE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
/**
* Get migration marker path (respects CCS_HOME for test isolation)
@@ -51,6 +55,39 @@ function hasCcsHook(settings: Record<string, unknown>): boolean {
});
}
export function getImageAnalysisProfileSettingsPath(
profileName: string,
settingsPath?: string | null
): string {
if (typeof settingsPath === 'string' && settingsPath.trim().length > 0) {
return settingsPath;
}
return path.join(getCcsDir(), `${profileName}.settings.json`);
}
export function hasImageAnalysisProfileHook(
profileName: string,
settingsPath?: string | null
): boolean {
if (!VALID_PROFILE_NAME.test(profileName)) {
return false;
}
const resolvedSettingsPath = getImageAnalysisProfileSettingsPath(profileName, settingsPath);
if (!fs.existsSync(resolvedSettingsPath)) {
return false;
}
try {
const content = fs.readFileSync(resolvedSettingsPath, 'utf8');
const settings = JSON.parse(content) as Record<string, unknown>;
return hasCcsHook(settings);
} catch {
return false;
}
}
/**
* One-time migration marker management
*/
@@ -79,13 +116,14 @@ function migrateGlobalHook(): void {
/**
* Ensure image analyzer hook is configured in profile's settings file
*
* Only injects for CLIProxy profiles with vision support (agy, gemini).
*
* @param profileName - Name of the profile (e.g., 'agy', 'gemini')
* @param input - Profile name or pre-resolved runtime context
* @returns true if hook is configured (existing or newly added)
*/
export function ensureProfileHooks(profileName: string): boolean {
export function ensureProfileHooks(input: string | ImageAnalysisResolutionContext): boolean {
try {
const context = typeof input === 'string' ? { profileName: input } : input;
const profileName = context.profileName;
// Validate profile name to prevent path traversal
if (!VALID_PROFILE_NAME.test(profileName)) {
if (process.env.CCS_DEBUG) {
@@ -95,16 +133,8 @@ export function ensureProfileHooks(profileName: string): boolean {
}
const imageConfig = getImageAnalysisConfig();
// Only inject for profiles that have a model mapping in provider_models
// This allows dynamic extension without hardcoding profile names
const configuredProviders = Object.keys(imageConfig.provider_models);
if (!configuredProviders.includes(profileName)) {
return false;
}
// Skip if image analysis is disabled
if (!imageConfig.enabled) {
const status = resolveImageAnalysisStatus(context, imageConfig);
if (!status.supported || !status.shouldPersistHook) {
return false;
}
@@ -119,7 +149,7 @@ export function ensureProfileHooks(profileName: string): boolean {
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
}
const settingsPath = path.join(ccsDir, `${profileName}.settings.json`);
const settingsPath = getImageAnalysisProfileSettingsPath(profileName, context.settingsPath);
// Read existing settings or create empty
let settings: Record<string, unknown> = {};
+7
View File
@@ -7,6 +7,13 @@
*/
export { getImageAnalysisHookEnv } from './get-image-analysis-hook-env';
export {
canonicalizeImageAnalysisConfig,
resolveImageAnalysisStatus,
normalizeImageAnalysisBackendId,
type ImageAnalysisResolutionContext,
type ImageAnalysisStatus,
} from './image-analysis-backend-resolver';
export {
getImageAnalyzerHookPath,
getImageAnalyzerHookConfig,
+59 -5
View File
@@ -4,10 +4,9 @@
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { getCcsDir, loadSettings } from '../../utils/config-manager';
import { getCcsDir, loadConfigSafe, loadSettings } from '../../utils/config-manager';
import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys';
import { listVariants } from '../../cliproxy/services/variant-service';
import {
@@ -21,17 +20,28 @@ import {
import { regenerateConfig } from '../../cliproxy/config-generator';
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
import { resolveCliproxyBridgeMetadata } from '../../api/services';
import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
import {
getImageAnalysisConfig,
loadOrCreateUnifiedConfig,
mutateUnifiedConfig,
} from '../../config/unified-config-loader';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
import type { Settings } from '../../types/config';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import { expandPath } from '../../utils/helpers';
import {
canonicalizeModelIdForProvider,
extractProviderFromPathname,
getDeniedModelIdReasonForProvider,
} from '../../cliproxy/model-id-normalizer';
import { createRouteErrorHelpers } from './route-helpers';
import {
getImageAnalysisProfileSettingsPath,
hasImageAnalysisProfileHook,
} from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
import { resolveImageAnalysisStatus } from '../../utils/hooks';
const router = Router();
const MODEL_ENV_KEYS = [
@@ -94,8 +104,16 @@ function resolveSettingsPath(profileOrVariant: string): string {
const variants = listVariants();
const variant = variants[profileOrVariant];
if (variant?.settings) {
// Variant settings path (e.g., ~/.ccs/agy-g3.settings.json)
return resolvePathWithin(resolvedCcsDir, variant.settings.replace(/^~/, os.homedir()));
return path.resolve(expandPath(variant.settings));
}
try {
const configuredSettingsPath = loadConfigSafe().profiles[profileOrVariant];
if (typeof configuredSettingsPath === 'string' && configuredSettingsPath.trim().length > 0) {
return path.resolve(expandPath(configuredSettingsPath));
}
} catch {
// Fall back to the conventional ~/.ccs/<profile>.settings.json path below.
}
// Regular profile settings
@@ -251,6 +269,40 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting
return changed ? next : settings;
}
function resolveImageAnalysisStatusForProfile(
profileOrVariant: string,
settings: Settings,
settingsPath: string
) {
const variants = listVariants();
const variant = variants[profileOrVariant];
const cliproxyProvider = resolveProviderForProfile(profileOrVariant);
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
const status = resolveImageAnalysisStatus(
{
profileName: profileOrVariant,
profileType: cliproxyProvider ? 'cliproxy' : 'settings',
cliproxyProvider,
isComposite: Boolean(
variant && 'type' in variant && (variant as { type?: string }).type === 'composite'
),
settingsPath,
settings,
cliproxyBridge,
hookInstalled: hasImageAnalysisProfileHook(profileOrVariant, settingsPath),
sharedHookInstalled: hasImageAnalyzerHook(),
},
getImageAnalysisConfig()
);
return {
...status,
persistencePath: status.shouldPersistHook
? getImageAnalysisProfileSettingsPath(profileOrVariant, settingsPath)
: null,
};
}
function writeSettingsAtomically(settingsPath: string, settings: Settings): void {
const tempPath = `${settingsPath}.tmp.${process.pid}`;
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
@@ -338,6 +390,7 @@ router.get('/:profile', (req: Request, res: Response): void => {
mtime: stat.mtime.getTime(),
path: settingsPath,
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
imageAnalysisStatus: resolveImageAnalysisStatusForProfile(profile, settings, settingsPath),
});
} catch (error) {
respondInternalError(res, error, 'Internal server error.');
@@ -368,6 +421,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
mtime: stat.mtime.getTime(),
path: settingsPath,
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
imageAnalysisStatus: resolveImageAnalysisStatusForProfile(profile, settings, settingsPath),
});
} catch (error) {
respondInternalError(res, error, 'Internal server error.');