diff --git a/src/api/services/profile-types.ts b/src/api/services/profile-types.ts index 2f8bd40a..2cb096c0 100644 --- a/src/api/services/profile-types.ts +++ b/src/api/services/profile-types.ts @@ -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; diff --git a/src/ccs.ts b/src/ccs.ts index d427d234..cd0b6ca1 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -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 { 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 { } 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 { // 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 { : 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 { } 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 : {}; diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 6e982528..924aa95d 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -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/.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; diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts index 41ec5df5..0301cfc6 100644 --- a/src/commands/config-image-analysis-command.ts +++ b/src/commands/config-image-analysis-command.ts @@ -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 '; + } + } + + 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 '; + } + } + + 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 '; + } + } + return options; } @@ -82,6 +118,13 @@ function showHelp(): void { console.log(` ${color('--disable', 'command')} Disable image analysis`); console.log(` ${color('--timeout ', 'command')} Set analysis timeout (10-600)`); console.log(` ${color('--set-model

', 'command')} Set model for provider`); + console.log(` ${color('--set-fallback ', 'command')} Set fallback backend`); + console.log( + ` ${color('--set-profile-backend

', 'command')} Map a profile alias to a backend` + ); + console.log( + ` ${color('--clear-profile-backend

', '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')); diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 64567307..878dfbd1 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -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 { 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, + }); } /** diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index cdfcd639..5651d82d 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -759,6 +759,10 @@ export interface ImageAnalysisConfig { timeout: number; /** Provider-to-model mapping for vision analysis */ provider_models: Record; + /** 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; } /** @@ -780,6 +784,8 @@ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { iflow: 'qwen3-vl-plus', kimi: 'vision-model', }, + fallback_backend: 'gemini', + profile_backends: {}, }; /** diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index d1487cbf..53c40263 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -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, diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index 0dd9aa14..40835023 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -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 * 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 { +export function getImageAnalysisHookEnv( + input?: string | ImageAnalysisResolutionContext +): Record { 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', }; } diff --git a/src/utils/hooks/image-analysis-backend-resolver.ts b/src/utils/hooks/image-analysis-backend-resolver.ts new file mode 100644 index 00000000..ca19d5f5 --- /dev/null +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -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 | 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 | 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 | 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 + ); + + 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 + ); + + 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 { + 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, + }; +} diff --git a/src/utils/hooks/image-analyzer-profile-hook-injector.ts b/src/utils/hooks/image-analyzer-profile-hook-injector.ts index 6c3c8316..a2b28978 100644 --- a/src/utils/hooks/image-analyzer-profile-hook-injector.ts +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -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): 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; + 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 = {}; diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 7e05c9d5..0c44e572 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -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, diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 193080b4..ce3803c3 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -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/.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.'); diff --git a/ui/src/components/profiles/editor/image-analysis-status-section.tsx b/ui/src/components/profiles/editor/image-analysis-status-section.tsx new file mode 100644 index 00000000..e807a251 --- /dev/null +++ b/ui/src/components/profiles/editor/image-analysis-status-section.tsx @@ -0,0 +1,173 @@ +import { AlertTriangle, Image as ImageIcon, Route } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import type { ImageAnalysisStatus } from '@/lib/api-client'; + +interface ImageAnalysisStatusSectionProps { + status?: ImageAnalysisStatus | null; +} + +function getBadge(status: ImageAnalysisStatus | null | undefined): { + label: string; + variant: 'default' | 'secondary' | 'destructive' | 'outline'; +} { + switch (status?.status) { + case 'active': + return { label: 'Ready', variant: 'default' }; + case 'mapped': + return { label: 'Saved mapping', variant: 'secondary' }; + case 'attention': + return { label: 'Needs review', variant: 'outline' }; + case 'disabled': + return { label: 'Disabled', variant: 'outline' }; + case 'hook-missing': + return { label: 'Setup needed', variant: 'destructive' }; + case 'skipped': + return { label: 'Not available', variant: 'outline' }; + default: + return { label: 'Checking', variant: 'outline' }; + } +} + +function getSummary(status: ImageAnalysisStatus): string { + const backendName = status.backendDisplayName || status.backendId || 'this backend'; + + switch (status.status) { + case 'disabled': + return "Disabled globally. This profile uses Claude's built-in file reading because CCS image analysis is turned off."; + case 'mapped': + return `Ready via saved ${backendName} mapping. CCS could not infer the backend from this alias, so it uses the mapping saved in CCS config.`; + case 'attention': + return `Ready via ${backendName}, but runtime is using the current CLIProxy route instead of the route saved in this profile.`; + case 'hook-missing': + return `Configured for ${backendName}, but the image-analysis hook is not fully installed yet.`; + case 'skipped': + return status.reason || 'Skipped for this profile.'; + case 'active': + default: + if (status.resolutionSource === 'cliproxy-bridge') { + return `Ready via ${backendName}. Images and PDFs are routed through CLIProxy before Claude sees text.`; + } + + if (status.resolutionSource === 'fallback-backend') { + return `Ready via ${backendName} fallback. Images and PDFs are routed through CLIProxy before Claude sees text.`; + } + + return `Ready via ${backendName}. Images and PDFs are routed through CLIProxy before Claude sees text.`; + } +} + +function getRuntimeLine(status: ImageAnalysisStatus): string { + if (!status.runtimePath) { + return 'Read -> native file access'; + } + + return `Read -> image-analysis hook -> ${status.runtimePath}`; +} + +function getPersistenceLine(status: ImageAnalysisStatus): string { + if (!status.shouldPersistHook || !status.persistencePath) { + return 'Not persisted for this profile type'; + } + + if (status.hookInstalled) { + return `${status.persistencePath} hook`; + } + + return `${status.persistencePath} hook missing`; +} + +export function ImageAnalysisStatusSection({ status }: ImageAnalysisStatusSectionProps) { + if (!status) { + return ( +

+
+
+

Checking backend status...

+
+ ); + } + + const badge = getBadge(status); + const detailLabel = status.supported ? 'Model' : 'Reason'; + const detailValue = status.supported ? status.model : status.reason || 'Unavailable'; + + return ( +
+
+
+
+ +

Image-analysis backend

+
+

+ Derived runtime status. This section is not written into the JSON editor above. +

+
+ + {badge.label} + +
+ +

+ {getSummary(status)} +

+ +
+
+
+ Backend +
+
{status.backendDisplayName || 'Unresolved'}
+
+ +
+
+ Runtime +
+
+ {getRuntimeLine(status)} +
+
+ +
+
+ Persistence +
+
+ {getPersistenceLine(status)} +
+
+ +
+
+ {detailLabel} +
+
+ {detailValue} +
+
+
+ + {(status.status === 'attention' || status.status === 'hook-missing') && ( +
+ + {status.reason} +
+ )} + +
+ + + WebSearch stays managed separately and is not controlled by this backend status. + +
+
+ ); +} diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx index f9709845..470de66b 100644 --- a/ui/src/components/profiles/editor/index.tsx +++ b/ui/src/components/profiles/editor/index.tsx @@ -254,6 +254,7 @@ export function ProfileEditor({ isRawJsonValid={computedIsRawJsonValid} rawJsonEdits={rawJsonEdits} settings={settings} + imageAnalysisStatus={data?.imageAnalysisStatus} onChange={handleRawJsonChange} missingRequiredFields={missingRequiredFields} /> diff --git a/ui/src/components/profiles/editor/raw-editor-section.tsx b/ui/src/components/profiles/editor/raw-editor-section.tsx index 686fb2e7..c077ccee 100644 --- a/ui/src/components/profiles/editor/raw-editor-section.tsx +++ b/ui/src/components/profiles/editor/raw-editor-section.tsx @@ -6,7 +6,9 @@ import { Suspense, lazy } from 'react'; import { Loader2, X, AlertTriangle } from 'lucide-react'; import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator'; +import { ImageAnalysisStatusSection } from './image-analysis-status-section'; import type { Settings } from './types'; +import type { ImageAnalysisStatus } from '@/lib/api-client'; // Lazy load CodeEditor const CodeEditor = lazy(() => @@ -18,6 +20,7 @@ interface RawEditorSectionProps { isRawJsonValid: boolean; rawJsonEdits: string | null; settings: Settings | undefined; + imageAnalysisStatus?: ImageAnalysisStatus | null; onChange: (value: string) => void; missingRequiredFields?: string[]; } @@ -27,6 +30,7 @@ export function RawEditorSection({ isRawJsonValid, rawJsonEdits, settings, + imageAnalysisStatus, onChange, missingRequiredFields = [], }: RawEditorSectionProps) { @@ -75,6 +79,9 @@ export function RawEditorSection({ />
+
+ +
{/* Global Env Indicator */}
diff --git a/ui/src/components/profiles/editor/types.ts b/ui/src/components/profiles/editor/types.ts index 9e2365da..e38baa84 100644 --- a/ui/src/components/profiles/editor/types.ts +++ b/ui/src/components/profiles/editor/types.ts @@ -2,7 +2,7 @@ * Types for Profile Editor */ -import type { CliTarget, CliproxyBridgeMetadata } from '@/lib/api-client'; +import type { CliTarget, CliproxyBridgeMetadata, ImageAnalysisStatus } from '@/lib/api-client'; export interface Settings { env?: Record; @@ -14,6 +14,7 @@ export interface SettingsResponse { mtime: number; path: string; cliproxyBridge?: CliproxyBridgeMetadata | null; + imageAnalysisStatus?: ImageAnalysisStatus | null; } export interface ProfileEditorProps { diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 749c63f3..7fef8193 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -111,6 +111,35 @@ export interface CliproxyBridgeMetadata { usesCurrentAuthToken: boolean; } +export interface ImageAnalysisStatus { + 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 Profile { name: string; settingsPath: string;