diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index d8bb71e1..1369b690 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -12,6 +12,7 @@ * 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) + * ANTHROPIC_MODEL - Fallback model if provider not in mapping * CCS_DEBUG=1 - Enable debug output * * Exit codes: @@ -107,14 +108,14 @@ function getDebugContext(filePath, stats) { const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); const model = providerModels[currentProvider] || DEFAULT_MODEL; const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); - const isDefaultModel = !providerModels[currentProvider]; + const modelsToTry = getModelsToTry(); return { file: path.basename(filePath), size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown', provider: currentProvider, model: model, - config: isDefaultModel ? 'default' : 'user-configured', + modelsToTry: modelsToTry.length > 1 ? modelsToTry.join(' -> ') : model, timeout: `${timeout}s`, endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}${CLIPROXY_PATH}`, }; @@ -148,6 +149,7 @@ function parseProviderModels(envValue) { /** * Get model for current provider from provider_models mapping + * Returns primary model only (for display/logging) */ function getModelForProvider() { const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; @@ -155,6 +157,85 @@ function getModelForProvider() { return providerModels[currentProvider] || DEFAULT_MODEL; } +/** + * Get list of models to try in order: + * 1. provider_models[current_provider] (if exists) + * 2. DEFAULT_MODEL + * 3. ANTHROPIC_MODEL from profile (if different and exists) + */ +function getModelsToTry() { + const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + const anthropicModel = process.env.ANTHROPIC_MODEL; + + const models = []; + const seen = new Set(); + + // 1. Provider-specific model + if (providerModels[currentProvider]) { + models.push(providerModels[currentProvider]); + seen.add(providerModels[currentProvider]); + } + + // 2. Default model + if (!seen.has(DEFAULT_MODEL)) { + models.push(DEFAULT_MODEL); + seen.add(DEFAULT_MODEL); + } + + // 3. ANTHROPIC_MODEL fallback + if (anthropicModel && !seen.has(anthropicModel)) { + models.push(anthropicModel); + } + + return models; +} + +/** + * Analyze with retry logic - tries models in order until one succeeds + */ +async function analyzeWithRetry(base64Data, mediaType, timeoutMs) { + const models = getModelsToTry(); + let lastError = null; + + for (let i = 0; i < models.length; i++) { + const model = models[i]; + try { + debugLog(`Trying model ${i + 1}/${models.length}`, { model }); + const result = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs); + if (i > 0) { + debugLog('Retry succeeded', { model, attempt: i + 1 }); + } + return { description: result, model }; + } catch (err) { + lastError = err; + const isLastModel = i === models.length - 1; + + // Don't retry on certain errors (auth, rate limit, timeout, file access) + const errMsg = err.message || ''; + const noRetryPatterns = ['AUTH_ERROR', 'RATE_LIMIT', 'TIMEOUT', 'EACCES', 'EPERM', 'ECONNREFUSED']; + const shouldNotRetry = noRetryPatterns.some(p => errMsg.includes(p)); + + if (shouldNotRetry || isLastModel) { + debugLog('Analysis failed, no more retries', { + model, + error: errMsg, + reason: shouldNotRetry ? 'non-retryable error' : 'last model' + }); + throw err; + } + + debugLog('Model failed, trying next', { + model, + error: errMsg.substring(0, 100), + nextModel: models[i + 1] + }); + } + } + + throw lastError || new Error('No models available'); +} + /** * Check if file is an analyzable image or PDF */ @@ -707,15 +788,16 @@ async function processHook() { base64Length: `${(base64Data.length / 1024).toFixed(1)}KB`, }); - // Analyze via CLIProxy - const description = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs); + // Analyze via CLIProxy with retry logic + const { description, model: usedModel } = await analyzeWithRetry(base64Data, mediaType, timeoutMs); debugLog('Analysis complete', { responseLength: `${description.length} chars`, + model: usedModel, }); // Output success - outputSuccess(filePath, description, model, stats.size); + outputSuccess(filePath, description, usedModel, stats.size); } catch (err) { if (process.env.CCS_DEBUG) { console.error('[CCS Hook] Error:', err.message);