diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index daa10782..bea62c96 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -6,12 +6,13 @@ * Returns detailed text descriptions instead of allowing direct visual access. * * Environment Variables (set by CCS): - * CCS_IMAGE_ANALYSIS_SKIP=1 - Skip this hook entirely - * CCS_IMAGE_ANALYSIS_ENABLED=1 - Enable image analysis (default: 1) - * CCS_IMAGE_ANALYSIS_MODEL - Model to use (default: gemini-2.5-flash) - * CCS_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60) - * CCS_PROFILE_TYPE - Profile type (account/default skip) - * CCS_DEBUG=1 - Enable debug output + * CCS_IMAGE_ANALYSIS_SKIP=1 - Skip this hook entirely + * CCS_IMAGE_ANALYSIS_ENABLED=1 - Enable image analysis (default: 1) + * CCS_IMAGE_ANALYSIS_PROVIDER_MODELS - Provider:model mapping (e.g., agy:gemini-2.5-flash,gemini:gemini-2.5-flash) + * CCS_CURRENT_PROVIDER - Current CLIProxy provider (e.g., agy, gemini, codex) + * CCS_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60) + * CCS_PROFILE_TYPE - Profile type (account/default skip) + * CCS_DEBUG=1 - Enable debug output * * Exit codes: * 0 - Allow tool (pass-through to native Read) @@ -65,6 +66,31 @@ Be comprehensive - this description replaces direct visual access.`; // HELPER FUNCTIONS // ============================================================================ +/** + * Parse provider_models env var to object + * Format: provider:model,provider:model + */ +function parseProviderModels(envValue) { + if (!envValue) return {}; + const result = {}; + envValue.split(',').forEach((pair) => { + const [provider, model] = pair.split(':'); + if (provider && model) { + result[provider.trim()] = model.trim(); + } + }); + return result; +} + +/** + * Get model for current provider from provider_models mapping + */ +function getModelForProvider() { + const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + return providerModels[currentProvider] || DEFAULT_MODEL; +} + /** * Check if file is an analyzable image or PDF */ @@ -295,6 +321,11 @@ function shouldSkipHook() { const profileType = process.env.CCS_PROFILE_TYPE; if (profileType === 'account' || profileType === 'default') return true; + // Check if current provider has a vision model configured + const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + if (!providerModels[currentProvider]) return true; + return false; } @@ -368,7 +399,7 @@ async function processHook() { process.exit(0); } - const model = process.env.CCS_IMAGE_ANALYSIS_MODEL || DEFAULT_MODEL; + const model = getModelForProvider(); const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); const timeoutMs = timeout * 1000; diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index ed98d8d8..626e4519 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -38,6 +38,7 @@ import { configureProviderModel, getCurrentModel } from './model-config'; import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; import { getImageReadBlockHookEnv } from '../utils/hooks/image-read-block-hook-env'; +import { getImageAnalysisHookEnv } from '../utils/hooks/get-image-analysis-hook-env'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; import { CodexReasoningProxy } from './codex-reasoning-proxy'; import { ToolSanitizationProxy } from './tool-sanitization-proxy'; @@ -946,11 +947,13 @@ export async function execClaudeWithCLIProxy( }; const webSearchEnv = getWebSearchHookEnv(); const imageReadBlockEnv = getImageReadBlockHookEnv(); + const imageAnalysisEnv = getImageAnalysisHookEnv(provider); const env = { ...process.env, ...effectiveEnvVars, ...webSearchEnv, ...imageReadBlockEnv, + ...imageAnalysisEnv, CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider }; diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 1ad14459..86b3cdc0 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -298,9 +298,9 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { // Image analysis config - enabled by default for CLIProxy providers image_analysis: { enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, - model: partial.image_analysis?.model ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.model, timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout, - providers: partial.image_analysis?.providers ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.providers, + provider_models: + partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, }, }; } @@ -740,8 +740,8 @@ export function getImageAnalysisConfig(): ImageAnalysisConfig { return { enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, - model: config.image_analysis?.model ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.model, timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout, - providers: config.image_analysis?.providers ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.providers, + provider_models: + config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, }; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 1eab0270..c654d9ec 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -523,14 +523,12 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = { * Routes image/PDF files through CLIProxy for vision analysis. */ export interface ImageAnalysisConfig { - /** Enable image analysis via CLIProxy (default: true for agy/gemini) */ + /** Enable image analysis via CLIProxy (default: true) */ enabled: boolean; - /** Model to use for analysis (default: gemini-2.5-flash) */ - model: string; /** Timeout in seconds (default: 60) */ timeout: number; - /** Providers to enable for (default: ['agy', 'gemini']) */ - providers: string[]; + /** Provider-to-model mapping for vision analysis */ + provider_models: Record; } /** @@ -539,9 +537,15 @@ export interface ImageAnalysisConfig { */ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { enabled: true, - model: 'gemini-2.5-flash', timeout: 60, - providers: ['agy', 'gemini'], + provider_models: { + agy: 'gemini-2.5-flash', + gemini: 'gemini-2.5-flash', + codex: 'gpt-5.1-codex-mini', + kiro: 'kiro-claude-haiku-4-5', + ghcp: 'claude-haiku-4.5', + claude: 'claude-haiku-4-5-20251001', + }, }; /** diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index fc0e2102..0dd9aa14 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -9,25 +9,34 @@ import { getImageAnalysisConfig } from '../../config/unified-config-loader'; +/** + * Serialize provider_models map to env var format: provider:model,provider:model + */ +function serializeProviderModels(providerModels: Record): string { + return Object.entries(providerModels) + .map(([provider, model]) => `${provider}:${model}`) + .join(','); +} + /** * Get image analysis hook environment variables. * These env vars control the hook's behavior via Claude Code hook system. * - * @param profileName - Current profile name (to determine if native Claude) + * @param provider - Current CLIProxy provider (e.g., 'agy', 'gemini', 'codex') * @returns Environment variables for image analysis hook */ -export function getImageAnalysisHookEnv(profileName?: string): Record { +export function getImageAnalysisHookEnv(provider?: string): Record { const config = getImageAnalysisConfig(); - // Native Claude profiles (no CLIProxy) should skip image analysis - const isNativeProfile = !profileName || ['claude', 'anthropic'].includes(profileName); - const skipImageAnalysis = isNativeProfile || !config.enabled; + // Check if current provider has a vision model configured + const hasVisionModel = provider && config.provider_models[provider]; + const skipImageAnalysis = !config.enabled || !hasVisionModel; return { CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0', - CCS_IMAGE_ANALYSIS_MODEL: config.model || 'gemini-2.5-flash', CCS_IMAGE_ANALYSIS_TIMEOUT: String(Number(config.timeout) || 60), - CCS_IMAGE_ANALYSIS_PROVIDERS: config.providers.join(','), + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models), + CCS_CURRENT_PROVIDER: provider || '', CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0', }; } diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts index 630f7ae4..b3da61dd 100644 --- a/tests/e2e/image-analyzer-hook.e2e.test.ts +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -31,6 +31,10 @@ const TEST_DIR = '/tmp/ccs-hook-tests'; const MOCK_PORT = 59876; // Use a unique port for mock server const CLIPROXY_API_KEY = 'test-api-key-12345'; +// Default provider models for testing (matches DEFAULT_IMAGE_ANALYSIS_CONFIG) +const DEFAULT_PROVIDER_MODELS = 'agy:gemini-2.5-flash,gemini:gemini-2.5-flash,codex:gpt-5.1-codex-mini,kiro:kiro-claude-haiku-4-5,ghcp:claude-haiku-4.5,claude:claude-haiku-4-5-20251001'; +const DEFAULT_PROVIDER = 'agy'; // Default test provider + // ============================================================================ // MOCK SERVER // ============================================================================ @@ -143,6 +147,9 @@ function invokeHook( ...process.env, CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, CCS_CLIPROXY_PORT: String(MOCK_PORT), + // Default provider config for tests (can be overridden) + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS, + CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER, ...env, }, timeout: 10000, // 10 second timeout per test @@ -409,7 +416,13 @@ describe('Image Analyzer Hook', () => { input: 'not valid json', encoding: 'utf8', timeout: 5000, - env: { ...process.env, CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY }, + env: { + ...process.env, + CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, + CCS_CLIPROXY_PORT: String(MOCK_PORT), + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS, + CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER, + }, }); // Should exit with error (code 2) @@ -546,7 +559,8 @@ describe('Image Analyzer Hook', () => { { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy', - CCS_IMAGE_ANALYSIS_MODEL: 'gemini-2.5-flash', + CCS_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash', } ); @@ -646,7 +660,7 @@ describe('Image Analyzer Hook', () => { expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error'); }); - it('should use default model when CCS_IMAGE_ANALYSIS_MODEL is not set', () => { + it('should use model from provider_models mapping', () => { resetMockState(); invokeHook( @@ -654,11 +668,33 @@ describe('Image Analyzer Hook', () => { tool_name: 'Read', tool_input: { file_path: testPngPath }, }, - { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + { + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_PROFILE_TYPE: 'cliproxy', + CCS_CURRENT_PROVIDER: 'codex', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'codex:gpt-5.1-codex-mini,agy:gemini-2.5-flash', + } ); const body = lastRequest?.body as { model: string }; - expect(body.model).toBe('gemini-2.5-flash'); // Default model + expect(body.model).toBe('gpt-5.1-codex-mini'); // Model from provider_models + }); + + it('should skip when provider is not in provider_models', () => { + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_PROFILE_TYPE: 'cliproxy', + CCS_CURRENT_PROVIDER: 'unknown-provider', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash', + } + ); + + expect(result.code).toBe(0); // Skip - provider not in map }); });