mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 00:17:47 +00:00
refactor(hooks): use provider_models mapping for image analysis
Changes ImageAnalysisConfig from providers array to provider_models mapping for granular vision model control per CLIProxy provider. Breaking change: config.yaml image_analysis section now uses provider_models instead of providers/model fields. Provider-to-model mappings: - 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 Hook checks CCS_CURRENT_PROVIDER against provider_models and skips if no vision model configured for that provider.
This commit is contained in:
@@ -6,12 +6,13 @@
|
|||||||
* Returns detailed text descriptions instead of allowing direct visual access.
|
* Returns detailed text descriptions instead of allowing direct visual access.
|
||||||
*
|
*
|
||||||
* Environment Variables (set by CCS):
|
* Environment Variables (set by CCS):
|
||||||
* CCS_IMAGE_ANALYSIS_SKIP=1 - Skip this hook entirely
|
* CCS_IMAGE_ANALYSIS_SKIP=1 - Skip this hook entirely
|
||||||
* CCS_IMAGE_ANALYSIS_ENABLED=1 - Enable image analysis (default: 1)
|
* 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_PROVIDER_MODELS - Provider:model mapping (e.g., agy:gemini-2.5-flash,gemini:gemini-2.5-flash)
|
||||||
* CCS_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60)
|
* CCS_CURRENT_PROVIDER - Current CLIProxy provider (e.g., agy, gemini, codex)
|
||||||
* CCS_PROFILE_TYPE - Profile type (account/default skip)
|
* CCS_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60)
|
||||||
* CCS_DEBUG=1 - Enable debug output
|
* CCS_PROFILE_TYPE - Profile type (account/default skip)
|
||||||
|
* CCS_DEBUG=1 - Enable debug output
|
||||||
*
|
*
|
||||||
* Exit codes:
|
* Exit codes:
|
||||||
* 0 - Allow tool (pass-through to native Read)
|
* 0 - Allow tool (pass-through to native Read)
|
||||||
@@ -65,6 +66,31 @@ Be comprehensive - this description replaces direct visual access.`;
|
|||||||
// HELPER FUNCTIONS
|
// 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
|
* Check if file is an analyzable image or PDF
|
||||||
*/
|
*/
|
||||||
@@ -295,6 +321,11 @@ function shouldSkipHook() {
|
|||||||
const profileType = process.env.CCS_PROFILE_TYPE;
|
const profileType = process.env.CCS_PROFILE_TYPE;
|
||||||
if (profileType === 'account' || profileType === 'default') return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +399,7 @@ async function processHook() {
|
|||||||
process.exit(0);
|
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 timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
||||||
const timeoutMs = timeout * 1000;
|
const timeoutMs = timeout * 1000;
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { configureProviderModel, getCurrentModel } from './model-config';
|
|||||||
import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver';
|
import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver';
|
||||||
import { getWebSearchHookEnv } from '../utils/websearch-manager';
|
import { getWebSearchHookEnv } from '../utils/websearch-manager';
|
||||||
import { getImageReadBlockHookEnv } from '../utils/hooks/image-read-block-hook-env';
|
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 { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog';
|
||||||
import { CodexReasoningProxy } from './codex-reasoning-proxy';
|
import { CodexReasoningProxy } from './codex-reasoning-proxy';
|
||||||
import { ToolSanitizationProxy } from './tool-sanitization-proxy';
|
import { ToolSanitizationProxy } from './tool-sanitization-proxy';
|
||||||
@@ -946,11 +947,13 @@ export async function execClaudeWithCLIProxy(
|
|||||||
};
|
};
|
||||||
const webSearchEnv = getWebSearchHookEnv();
|
const webSearchEnv = getWebSearchHookEnv();
|
||||||
const imageReadBlockEnv = getImageReadBlockHookEnv();
|
const imageReadBlockEnv = getImageReadBlockHookEnv();
|
||||||
|
const imageAnalysisEnv = getImageAnalysisHookEnv(provider);
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
...effectiveEnvVars,
|
...effectiveEnvVars,
|
||||||
...webSearchEnv,
|
...webSearchEnv,
|
||||||
...imageReadBlockEnv,
|
...imageReadBlockEnv,
|
||||||
|
...imageAnalysisEnv,
|
||||||
CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider
|
CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -298,9 +298,9 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
|||||||
// Image analysis config - enabled by default for CLIProxy providers
|
// Image analysis config - enabled by default for CLIProxy providers
|
||||||
image_analysis: {
|
image_analysis: {
|
||||||
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
|
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,
|
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 {
|
return {
|
||||||
enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
|
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,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -523,14 +523,12 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = {
|
|||||||
* Routes image/PDF files through CLIProxy for vision analysis.
|
* Routes image/PDF files through CLIProxy for vision analysis.
|
||||||
*/
|
*/
|
||||||
export interface ImageAnalysisConfig {
|
export interface ImageAnalysisConfig {
|
||||||
/** Enable image analysis via CLIProxy (default: true for agy/gemini) */
|
/** Enable image analysis via CLIProxy (default: true) */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** Model to use for analysis (default: gemini-2.5-flash) */
|
|
||||||
model: string;
|
|
||||||
/** Timeout in seconds (default: 60) */
|
/** Timeout in seconds (default: 60) */
|
||||||
timeout: number;
|
timeout: number;
|
||||||
/** Providers to enable for (default: ['agy', 'gemini']) */
|
/** Provider-to-model mapping for vision analysis */
|
||||||
providers: string[];
|
provider_models: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -539,9 +537,15 @@ export interface ImageAnalysisConfig {
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = {
|
export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
model: 'gemini-2.5-flash',
|
|
||||||
timeout: 60,
|
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',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,25 +9,34 @@
|
|||||||
|
|
||||||
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize provider_models map to env var format: provider:model,provider:model
|
||||||
|
*/
|
||||||
|
function serializeProviderModels(providerModels: Record<string, string>): string {
|
||||||
|
return Object.entries(providerModels)
|
||||||
|
.map(([provider, model]) => `${provider}:${model}`)
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get image analysis hook environment variables.
|
* Get image analysis hook environment variables.
|
||||||
* These env vars control the hook's behavior via Claude Code hook system.
|
* 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
|
* @returns Environment variables for image analysis hook
|
||||||
*/
|
*/
|
||||||
export function getImageAnalysisHookEnv(profileName?: string): Record<string, string> {
|
export function getImageAnalysisHookEnv(provider?: string): Record<string, string> {
|
||||||
const config = getImageAnalysisConfig();
|
const config = getImageAnalysisConfig();
|
||||||
|
|
||||||
// Native Claude profiles (no CLIProxy) should skip image analysis
|
// Check if current provider has a vision model configured
|
||||||
const isNativeProfile = !profileName || ['claude', 'anthropic'].includes(profileName);
|
const hasVisionModel = provider && config.provider_models[provider];
|
||||||
const skipImageAnalysis = isNativeProfile || !config.enabled;
|
const skipImageAnalysis = !config.enabled || !hasVisionModel;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0',
|
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_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',
|
CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ const TEST_DIR = '/tmp/ccs-hook-tests';
|
|||||||
const MOCK_PORT = 59876; // Use a unique port for mock server
|
const MOCK_PORT = 59876; // Use a unique port for mock server
|
||||||
const CLIPROXY_API_KEY = 'test-api-key-12345';
|
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
|
// MOCK SERVER
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -143,6 +147,9 @@ function invokeHook(
|
|||||||
...process.env,
|
...process.env,
|
||||||
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
|
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
|
||||||
CCS_CLIPROXY_PORT: String(MOCK_PORT),
|
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,
|
...env,
|
||||||
},
|
},
|
||||||
timeout: 10000, // 10 second timeout per test
|
timeout: 10000, // 10 second timeout per test
|
||||||
@@ -409,7 +416,13 @@ describe('Image Analyzer Hook', () => {
|
|||||||
input: 'not valid json',
|
input: 'not valid json',
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
timeout: 5000,
|
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)
|
// Should exit with error (code 2)
|
||||||
@@ -546,7 +559,8 @@ describe('Image Analyzer Hook', () => {
|
|||||||
{
|
{
|
||||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||||
CCS_PROFILE_TYPE: 'cliproxy',
|
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');
|
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();
|
resetMockState();
|
||||||
|
|
||||||
invokeHook(
|
invokeHook(
|
||||||
@@ -654,11 +668,33 @@ describe('Image Analyzer Hook', () => {
|
|||||||
tool_name: 'Read',
|
tool_name: 'Read',
|
||||||
tool_input: { file_path: testPngPath },
|
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 };
|
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
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user