diff --git a/README.md b/README.md index a75d3d6f..c10672d9 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,8 @@ The dashboard provides visual management for all account types: > **Third-party WebSearch steering:** Claude-backed third-party launches keep Anthropic's native `WebSearch` disabled, provision `ccs-websearch.WebSearch` when the managed runtime is available, and append a short system hint so Claude prefers that managed tool over ad hoc Bash or `curl` lookups whenever current web information is needed. > Setting `websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party backends because those providers cannot execute it correctly. +> **Image backend visibility:** `ccs config image-analysis --set-fallback ` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend ` and `--clear-profile-backend ` for explicit per-profile mappings. In the dashboard, the global `Settings -> Image` section now shows the shared backend routing state, while each profile editor keeps a compact `Image` status card that links back to those global controls. + > **Copilot config behavior:** Opening the dashboard or other read-only Copilot endpoints does not rewrite `~/.ccs/copilot.settings.json`. If CCS detects deprecated Copilot model IDs such as `raptor-mini`, it shows warnings immediately and only persists replacements when you explicitly save the Copilot configuration. **llama.cpp Integration**: Run a local llama.cpp OpenAI-compatible server and create a profile with `ccs api create --preset llamacpp`. CCS defaults to `http://127.0.0.1:8080`, matching the standard llama.cpp server port. diff --git a/package.json b/package.json index e92eab14..4cd95f30 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "test:native": "bash tests/native/unix/edge-cases.sh", "test:e2e": "bun test tests/e2e/ --bail --timeout 60000", "report:hardening": "node scripts/hardening-inventory.js", - "dev": "bun run build:server && bun dist/ccs.js config --dev", + "dev": "bun run build:server && node dist/ccs.js config --dev", "dev:symlink": "bash scripts/dev-symlink.sh", "dev:unlink": "bash scripts/dev-symlink.sh --restore", "ui:build": "cd ui && bun run build", 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..aafaee74 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -33,7 +33,11 @@ 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, + resolveImageAnalysisRuntimeStatus, +} from './utils/hooks'; import { fail, info, warn } from './utils/ui'; import { isCopilotSubcommandToken } from './copilot/constants'; import { @@ -682,10 +686,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 +848,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 +884,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 +914,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 +1017,61 @@ async function main(): Promise { } const webSearchEnv = getWebSearchHookEnv(); - const imageAnalysisEnv = getImageAnalysisHookEnv(profileInfo.name); + const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settings, + cliproxyBridge, + }); + let imageAnalysisEnv = getImageAnalysisHookEnv({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settings, + cliproxyBridge, + }); + + const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; + if ( + resolvedTarget === 'claude' && + imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' && + imageAnalysisProvider + ) { + const verboseProxyLaunch = + remainingArgs.includes('--verbose') || + remainingArgs.includes('-v') || + targetRemainingArgs.includes('--verbose') || + targetRemainingArgs.includes('-v'); + + if (imageAnalysisStatus.effectiveRuntimeMode === 'native-read') { + console.error( + info( + `${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This session will use native Read.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } else if (imageAnalysisStatus.proxyReadiness === 'stopped') { + const ensureServiceResult = await ensureCliproxyService( + CLIPROXY_DEFAULT_PORT, + verboseProxyLaunch + ); + if (!ensureServiceResult.started) { + console.error( + warn( + `Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } + } + } // 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/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index 043ed4c3..83a0294b 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -9,6 +9,7 @@ * - WebSearch and ImageAnalysis hook integration */ +import * as fs from 'fs'; import { getEffectiveEnvVars, getRemoteEnvVars, @@ -20,11 +21,16 @@ import { CLIProxyProvider } from '../types'; import { CompositeTierConfig } from '../../config/unified-config-types'; import { getWebSearchHookEnv } from '../../utils/websearch-manager'; import { getImageAnalysisHookEnv } from '../../utils/hooks/get-image-analysis-hook-env'; +import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status'; +import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; import { stripClaudeCodeEnv } from '../../utils/shell-executor'; import { CodexReasoningProxy } from '../codex-reasoning-proxy'; import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; import { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer'; +import type { ProxyTarget } from '../proxy-target-resolver'; +import { isSettings, type Settings } from '../../types/config'; export interface RemoteProxyConfig { host: string; @@ -61,8 +67,38 @@ export interface ProxyChainConfig { compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku'; /** Optional inherited continuity directory from mapped account profile */ claudeConfigDir?: string; + /** Execution-aware image analysis env prepared by the caller */ + imageAnalysisEnv?: Record; } +interface CliproxyImageAnalysisDeps { + getImageAnalysisHookEnv: typeof getImageAnalysisHookEnv; + hasImageAnalysisProfileHook: typeof hasImageAnalysisProfileHook; + hasImageAnalyzerHook: typeof hasImageAnalyzerHook; + resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus; +} + +interface ResolveCliproxyImageAnalysisEnvOptions { + profileName: string; + provider: CLIProxyProvider; + profileSettingsPath?: string; + isComposite?: boolean; + proxyTarget: ProxyTarget; + proxyReachable: boolean; +} + +export interface CliproxyImageAnalysisResolution { + env: Record; + warning: string | null; +} + +const defaultCliproxyImageAnalysisDeps: CliproxyImageAnalysisDeps = { + getImageAnalysisHookEnv, + hasImageAnalysisProfileHook, + hasImageAnalyzerHook, + resolveImageAnalysisRuntimeStatus, +}; + const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i; const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; @@ -95,6 +131,68 @@ function normalizeCodexEnvForDirectUpstream(envVars: NodeJS.ProcessEnv): NodeJS. return nextEnv ?? envVars; } +function loadImageAnalysisSettings(settingsPath?: string): Settings | undefined { + if (!settingsPath) { + return undefined; + } + + try { + if (!fs.existsSync(settingsPath)) { + return undefined; + } + + const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as unknown; + return isSettings(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +export async function resolveCliproxyImageAnalysisEnv( + options: ResolveCliproxyImageAnalysisEnvOptions, + deps: Partial = {} +): Promise { + const resolvedDeps = { ...defaultCliproxyImageAnalysisDeps, ...deps }; + const settings = loadImageAnalysisSettings(options.profileSettingsPath); + const context = { + profileName: options.profileName, + profileType: 'cliproxy' as const, + cliproxyProvider: options.provider, + isComposite: options.isComposite, + settingsPath: options.profileSettingsPath, + settings, + hookInstalled: resolvedDeps.hasImageAnalysisProfileHook( + options.profileName, + options.profileSettingsPath + ), + sharedHookInstalled: resolvedDeps.hasImageAnalyzerHook(), + }; + + const env = resolvedDeps.getImageAnalysisHookEnv(context); + const currentProvider = env['CCS_CURRENT_PROVIDER']; + if (env['CCS_IMAGE_ANALYSIS_SKIP'] === '1' || !currentProvider) { + return { env, warning: null }; + } + + const status = await resolvedDeps.resolveImageAnalysisRuntimeStatus(context, undefined, { + getProxyTarget: () => options.proxyTarget, + isCliproxyRunning: async () => options.proxyReachable, + }); + + if (status.effectiveRuntimeMode === 'native-read') { + return { + env: { + ...env, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }, + warning: `${status.effectiveRuntimeReason || `Image analysis via ${currentProvider} is unavailable.`} This session will use native Read.`, + }; + } + + return { env, warning: null }; +} + /** * Build final environment variables for Claude CLI execution * Handles proxy chain ordering and integration with hooks @@ -116,6 +214,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record> = id: 'claude-opus-4-6-thinking', name: 'Claude Opus 4.6 Thinking', description: 'Latest flagship, extended thinking', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -101,6 +104,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', description: 'Latest Sonnet with thinking budget support', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -113,6 +117,15 @@ export const MODEL_CATALOG: Partial> = id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', description: 'Google latest Gemini Pro model via Antigravity', + nativeImageInput: true, + thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, + extendedContext: true, + }, + { + id: 'gemini-3-1-flash-preview', + name: 'Gemini Flash', + description: 'Latest Gemini Flash model via Antigravity', + nativeImageInput: true, thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, extendedContext: true, }, @@ -128,6 +141,16 @@ export const MODEL_CATALOG: Partial> = name: 'Gemini 3.1 Pro', tier: 'pro', description: 'Latest Gemini Pro model, requires paid Google account', + nativeImageInput: true, + thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, + extendedContext: true, + }, + { + id: 'gemini-3-flash-preview', + name: 'Gemini Flash', + tier: 'pro', + description: 'Latest Gemini Flash model, requires paid Google account', + nativeImageInput: true, thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, extendedContext: true, }, @@ -135,6 +158,7 @@ export const MODEL_CATALOG: Partial> = id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Stable, works with free Google account', + nativeImageInput: true, thinking: { type: 'budget', min: 128, @@ -264,6 +288,7 @@ export const MODEL_CATALOG: Partial> = id: 'kimi-k2.5', name: 'Kimi K2.5', description: 'Latest multimodal model (262K context)', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -300,6 +325,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-opus-4-6', name: 'Claude Opus 4.6', description: 'Latest flagship model', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -313,6 +339,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', description: 'Balanced performance and speed', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -326,6 +353,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5', description: 'Most capable Claude model', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -339,6 +367,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', description: 'Balanced performance and speed', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -352,6 +381,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', description: 'Previous generation Sonnet', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -365,6 +395,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', description: 'Fast and efficient', + nativeImageInput: true, thinking: { type: 'none' }, }, ], @@ -514,6 +545,14 @@ export function supportsExtendedContext(provider: CLIProxyProvider, modelId: str return model?.extendedContext === true; } +/** + * Check if a model can read image inputs natively. + */ +export function supportsNativeImageInput(provider: CLIProxyProvider, modelId: string): boolean { + const model = findModel(provider, modelId); + return model?.nativeImageInput === true; +} + /** * Check if model is a native Gemini model (not Claude via Antigravity). * Native Gemini models get extended context auto-enabled. diff --git a/src/cliproxy/proxy-config-resolver.ts b/src/cliproxy/proxy-config-resolver.ts index c4e382f9..9f36232a 100644 --- a/src/cliproxy/proxy-config-resolver.ts +++ b/src/cliproxy/proxy-config-resolver.ts @@ -7,7 +7,7 @@ * Supports both local (spawn CLIProxyAPI) and remote (connect to external) modes. */ -import { ResolvedProxyConfig } from './types'; +import type { ResolvedProxyConfig } from './types'; import { CLIPROXY_DEFAULT_PORT, validatePort } from './config-generator'; /** CLI flags for proxy configuration */ @@ -233,6 +233,7 @@ export function resolveProxyConfig( port?: number; protocol?: 'http' | 'https'; auth_token?: string; + management_key?: string; timeout?: number; fallback_enabled?: boolean; }; @@ -290,6 +291,7 @@ export function resolveProxyConfig( // Merge auth token: CLI > ENV > config.yaml resolved.authToken = cliFlags.authToken ?? envConfig.authToken ?? yamlConfig.remote?.auth_token; + resolved.managementKey = yamlConfig.remote?.management_key; // Merge timeout: CLI > ENV > config.yaml > default (2000ms in executor) resolved.timeout = cliFlags.timeout ?? envConfig.timeout ?? yamlConfig.remote?.timeout; diff --git a/src/cliproxy/proxy-target-resolver.ts b/src/cliproxy/proxy-target-resolver.ts index a9bde5d2..c6cfa966 100644 --- a/src/cliproxy/proxy-target-resolver.ts +++ b/src/cliproxy/proxy-target-resolver.ts @@ -13,6 +13,7 @@ import { normalizeProtocol, validateRemotePort, } from './config-generator'; +import { getProxyEnvVars } from './proxy-config-resolver'; import { getEffectiveManagementSecret } from './auth-token-manager'; /** Resolved proxy target for making requests */ @@ -27,6 +28,8 @@ export interface ProxyTarget { authToken?: string; /** Optional management key for management API endpoints (/v0/management/*) */ managementKey?: string; + /** Whether HTTPS requests should allow self-signed certificates */ + allowSelfSigned?: boolean; /** True if targeting remote server, false if local */ isRemote: boolean; } @@ -46,6 +49,7 @@ function loadCliproxyServerConfig(): CliproxyServerConfig | undefined { */ export function getProxyTarget(): ProxyTarget { const config = loadCliproxyServerConfig(); + const envConfig = getProxyEnvVars(); if (config?.remote?.enabled && config.remote?.host) { // Normalize protocol (handles case sensitivity and invalid values) @@ -60,6 +64,7 @@ export function getProxyTarget(): ProxyTarget { protocol, authToken: config.remote.auth_token || undefined, // Empty string -> undefined managementKey: config.remote.management_key || undefined, // Empty string -> undefined + allowSelfSigned: envConfig.allowSelfSigned, isRemote: true, }; } diff --git a/src/cliproxy/remote-auth-fetcher.ts b/src/cliproxy/remote-auth-fetcher.ts index 8259582f..b1e81e55 100644 --- a/src/cliproxy/remote-auth-fetcher.ts +++ b/src/cliproxy/remote-auth-fetcher.ts @@ -3,6 +3,7 @@ * Fetches and transforms auth data from remote CLIProxyAPI. */ +import * as https from 'https'; import { getProxyTarget, buildProxyUrl, @@ -15,6 +16,87 @@ import type { CLIProxyProvider } from './types'; /** Timeout for remote fetch requests (ms) */ const REMOTE_FETCH_TIMEOUT_MS = 5000; +async function fetchRemoteAuthResponse( + url: string, + headers: Record, + target: ProxyTarget +): Promise { + if (target.protocol !== 'https' || !target.allowSelfSigned) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REMOTE_FETCH_TIMEOUT_MS); + + try { + return await fetch(url, { + signal: controller.signal, + headers, + }); + } finally { + clearTimeout(timeoutId); + } + } + + return new Promise((resolve, reject) => { + const agent = new https.Agent({ rejectUnauthorized: false }); + let settled = false; + + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + callback(); + }; + + const timeoutId = setTimeout(() => { + const timeoutError = new Error('Request timeout'); + req.destroy(timeoutError); + settle(() => reject(timeoutError)); + }, REMOTE_FETCH_TIMEOUT_MS); + + const req = https.request( + url, + { + method: 'GET', + headers, + agent, + timeout: REMOTE_FETCH_TIMEOUT_MS, + }, + (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + settle(() => + resolve( + new Response(body, { + status: res.statusCode || 500, + statusText: res.statusMessage ?? '', + headers: + typeof res.headers['content-type'] === 'string' + ? { 'Content-Type': res.headers['content-type'] } + : undefined, + }) + ) + ); + }); + } + ); + + req.on('error', (error) => { + settle(() => reject(error)); + }); + + req.on('timeout', () => { + const timeoutError = new Error('Request timeout'); + req.destroy(timeoutError); + settle(() => reject(timeoutError)); + }); + + req.end(); + }); +} + /** Remote auth file from CLIProxyAPI /v0/management/auth-files */ interface RemoteAuthFile { id: string; @@ -60,16 +142,8 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise controller.abort(), REMOTE_FETCH_TIMEOUT_MS); - try { - const response = await fetch(url, { - signal: controller.signal, - headers, - }); - - clearTimeout(timeoutId); + const response = await fetchRemoteAuthResponse(url, headers, proxyTarget); if (!response.ok) { if (response.status === 401 || response.status === 403) { @@ -87,11 +161,12 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise.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/cliproxy/types.ts b/src/cliproxy/types.ts index 2cb7b292..7f122cad 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -268,6 +268,8 @@ export interface ResolvedProxyConfig { protocol: 'http' | 'https'; /** Auth token for remote proxy authentication */ authToken?: string; + /** Management key for remote management endpoints */ + managementKey?: string; /** Enable fallback to local when remote unreachable (default: true) */ fallbackEnabled: boolean; /** Auto-start local proxy if not running (default: true) */ diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts index 41ec5df5..b9ed3f6a 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; } @@ -34,6 +40,13 @@ const IMAGE_ANALYSIS_PROVIDER_ALIASES = Object.freeze( ) ); +function isConfiguredImageAnalysisBackend( + backend: string | null, + providerModels: Record +): backend is string { + return Boolean(backend && Object.prototype.hasOwnProperty.call(providerModels, backend)); +} + function parseArgs(args: string[]): ImageAnalysisCommandOptions { const options: ImageAnalysisCommandOptions = { enable: hasAnyFlag(args, ['--enable']), @@ -62,6 +75,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 +125,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(''); @@ -90,7 +140,9 @@ function showHelp(): void { if (IMAGE_ANALYSIS_PROVIDER_ALIASES.length > 0) { console.log(` ${dim(`Aliases accepted: ${IMAGE_ANALYSIS_PROVIDER_ALIASES.join(', ')}`)}`); } - console.log(` ${dim('Default model: gemini-2.5-flash (most providers)')}`); + console.log( + ` ${dim('Defaults: agy -> gemini-3-1-flash-preview, gemini -> gemini-3-flash-preview')}` + ); console.log(''); console.log(subheader('Examples:')); @@ -157,6 +209,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 +242,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 +300,51 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< hasChanges = true; } + if (options.setFallback) { + const normalizedBackend = normalizeImageAnalysisBackendId( + options.setFallback, + Object.keys(imageConfig.provider_models) + ); + if (!isConfiguredImageAnalysisBackend(normalizedBackend, imageConfig.provider_models)) { + 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 (!isConfiguredImageAnalysisBackend(normalizedBackend, imageConfig.provider_models)) { + 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..dd72d114 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; } /** @@ -769,8 +773,8 @@ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { enabled: true, timeout: 60, provider_models: { - agy: 'gemini-2.5-flash', - gemini: 'gemini-2.5-flash', + agy: 'gemini-3-1-flash-preview', + gemini: 'gemini-3-flash-preview', codex: 'gpt-5.1-codex-mini', kiro: 'kiro-claude-haiku-4-5', ghcp: 'claude-haiku-4.5', @@ -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..e727fbb4 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -8,6 +8,8 @@ import { spawn } from 'child_process'; import { CopilotConfig } from '../config/unified-config-types'; import { getGlobalEnvConfig } from '../config/unified-config-loader'; +import { ensureCliproxyService } from '../cliproxy'; +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth'; import { isDaemonRunning, startDaemon } from './copilot-daemon'; import { ensureCopilotApi } from './copilot-package-manager'; @@ -20,9 +22,20 @@ import { createWebSearchTraceContext, syncWebSearchMcpToConfigDir, } from '../utils/websearch-manager'; -import { getImageAnalysisHookEnv } from '../utils/hooks'; +import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../utils/hooks'; import { stripClaudeCodeEnv } from '../utils/shell-executor'; +interface CopilotImageAnalysisDeps { + ensureCliproxyService: typeof ensureCliproxyService; + getImageAnalysisHookEnv: typeof getImageAnalysisHookEnv; + resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus; +} + +interface CopilotImageAnalysisResolution { + env: Record; + warning: string | null; +} + /** * Get full copilot status (auth + daemon). */ @@ -75,6 +88,62 @@ export function generateCopilotEnv( }; } +export async function resolveCopilotImageAnalysisEnv( + verbose = false, + deps: Partial = {} +): Promise { + const resolvedDeps: CopilotImageAnalysisDeps = { + ensureCliproxyService, + getImageAnalysisHookEnv, + resolveImageAnalysisRuntimeStatus, + ...deps, + }; + + const env = resolvedDeps.getImageAnalysisHookEnv({ + profileName: 'copilot', + profileType: 'copilot', + }); + const provider = env['CCS_CURRENT_PROVIDER']; + if (env['CCS_IMAGE_ANALYSIS_SKIP'] === '1' || !provider) { + return { env, warning: null }; + } + + const status = await resolvedDeps.resolveImageAnalysisRuntimeStatus({ + profileName: 'copilot', + profileType: 'copilot', + }); + + if (status.effectiveRuntimeMode === 'native-read') { + return { + env: { + ...env, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }, + warning: `${status.effectiveRuntimeReason || `Image analysis via ${provider} is unavailable.`} This session will use native Read.`, + }; + } + + if (status.proxyReadiness === 'stopped') { + const ensureServiceResult = await resolvedDeps.ensureCliproxyService( + CLIPROXY_DEFAULT_PORT, + verbose + ); + if (!ensureServiceResult.started) { + return { + env: { + ...env, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }, + warning: `Image analysis via ${provider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.`, + }; + } + } + + return { env, warning: null }; +} + /** * Execute Claude Code with copilot-api proxy. * @@ -165,7 +234,8 @@ 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 { env: imageAnalysisEnv, warning: imageAnalysisWarning } = + await resolveCopilotImageAnalysisEnv(); const env = stripClaudeCodeEnv({ ...process.env, ...globalEnv, @@ -176,6 +246,9 @@ export async function executeCopilotProfile( }); console.log(info(`Using GitHub Copilot proxy (model: ${normalizedConfig.model})`)); + if (imageAnalysisWarning) { + console.log(info(imageAnalysisWarning)); + } console.log(''); syncWebSearchMcpToConfigDir(claudeConfigDir); diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index 39a0f7c5..34d35394 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -42,7 +42,7 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise results.errors.push({ name: 'Image Analysis', message: 'No provider models configured for image analysis', - fix: 'ccs config image-analysis --set-model agy gemini-2.5-flash', + fix: 'ccs config image-analysis --set-model agy gemini-3-1-flash-preview', }); console.log(` ${warn('Providers:')} None configured`); return; diff --git a/src/types/config.ts b/src/types/config.ts index f3c2541a..b84658e1 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -75,6 +75,10 @@ export interface ModelPreset { haiku: string; } +export interface CcsImageSettings { + native_read?: boolean; +} + /** * Claude CLI settings.json structure * Located at: ~/.claude/settings.json or profile-specific @@ -83,6 +87,8 @@ export interface Settings { env?: EnvVars; /** Saved model presets for this provider */ presets?: ModelPreset[]; + /** CCS-only per-profile Image preferences */ + ccs_image?: CcsImageSettings; [key: string]: unknown; // Allow other settings } 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..ec7921e4 --- /dev/null +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -0,0 +1,581 @@ +import { + DEFAULT_IMAGE_ANALYSIS_CONFIG, + type ImageAnalysisConfig, +} from '../../config/unified-config-types'; +import { + getProviderDisplayName, + isCLIProxyProvider, + mapExternalProviderName, +} from '../../cliproxy/provider-capabilities'; +import { getProviderCatalog, supportsNativeImageInput } from '../../cliproxy/model-catalog'; +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'; +import { stripModelConfigurationSuffixes } from '../../shared/extended-context-utils'; + +export type ImageAnalysisResolutionSource = + | 'cliproxy-provider' + | 'cliproxy-variant' + | 'cliproxy-composite' + | 'copilot-alias' + | 'cliproxy-bridge' + | 'profile-backend' + | 'fallback-backend' + | 'native-compatible' + | 'disabled' + | 'unsupported-profile' + | 'unresolved' + | 'missing-model'; + +export type ImageAnalysisStatusCode = + | 'active' + | 'mapped' + | 'attention' + | 'disabled' + | 'skipped' + | 'hook-missing'; + +export type ImageAnalysisAuthReadiness = 'not-needed' | 'ready' | 'missing' | 'unknown'; +export type ImageAnalysisProxyReadiness = + | 'not-needed' + | 'ready' + | 'remote' + | 'stopped' + | 'unavailable' + | 'unknown'; +export type ImageAnalysisEffectiveRuntimeMode = 'cliproxy-image-analysis' | 'native-read'; + +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; + authReadiness: ImageAnalysisAuthReadiness; + authProvider: string | null; + authDisplayName: string | null; + authReason: string | null; + proxyReadiness: ImageAnalysisProxyReadiness; + proxyReason: string | null; + effectiveRuntimeMode: ImageAnalysisEffectiveRuntimeMode; + effectiveRuntimeReason: string | null; + profileModel: string | null; + nativeReadPreference: boolean; + nativeImageCapable: boolean | null; + nativeImageReason: string | null; +} + +interface NativeImageSupportResolution { + profileModel: string | null; + nativeReadPreference: boolean; + nativeImageCapable: boolean | null; + nativeImageReason: string | null; +} + +const PROFILE_MODEL_ENV_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +] as const; + +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 resolveNativeImageProvider( + context: ImageAnalysisResolutionContext, + knownBackends: string[] +): string | null { + return normalizeImageAnalysisBackendId( + context.cliproxyProvider ?? + context.cliproxyBridge?.provider ?? + resolveProviderFromBaseUrl(context.settings?.env?.ANTHROPIC_BASE_URL ?? undefined), + knownBackends + ); +} + +function resolveProfileModel( + context: ImageAnalysisResolutionContext, + provider: string | null +): string | null { + const env = context.settings?.env; + if (env && typeof env === 'object') { + for (const key of PROFILE_MODEL_ENV_KEYS) { + const value = env[key]; + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + } + + if (provider && isCLIProxyProvider(provider)) { + return getProviderCatalog(provider)?.defaultModel ?? null; + } + + return null; +} + +function verifyNativeImageCapability( + provider: string | null, + modelId: string | null +): boolean | null { + if (!modelId) { + return null; + } + + if (provider && isCLIProxyProvider(provider) && supportsNativeImageInput(provider, modelId)) { + return true; + } + + const normalizedModel = stripModelConfigurationSuffixes(modelId).trim().toLowerCase(); + if (!normalizedModel) { + return null; + } + + if ( + normalizedModel.startsWith('gemini-') || + normalizedModel.startsWith('claude-') || + normalizedModel.startsWith('gpt-4o') || + normalizedModel.includes('vision') || + normalizedModel.includes('multimodal') || + /(^|[-_.])vl([-. _]|$)/.test(normalizedModel) || + /^glm-[\d.]+v([-. _]|$)/.test(normalizedModel) + ) { + return true; + } + + return null; +} + +function resolveNativeImageSupport( + context: ImageAnalysisResolutionContext, + config: ImageAnalysisConfig +): NativeImageSupportResolution { + const knownBackends = Object.keys(config.provider_models); + const provider = resolveNativeImageProvider(context, knownBackends); + const profileModel = resolveProfileModel(context, provider); + const nativeReadPreference = context.settings?.ccs_image?.native_read === true; + const nativeImageCapable = verifyNativeImageCapability(provider, profileModel); + + let nativeImageReason: string | null = null; + if (!profileModel) { + nativeImageReason = 'No current model is configured for this profile yet.'; + } else if (nativeImageCapable) { + nativeImageReason = `${profileModel} can read images natively.`; + } else { + nativeImageReason = `CCS cannot verify native image support for ${profileModel} yet.`; + } + + return { + profileModel, + nativeReadPreference, + nativeImageCapable, + nativeImageReason, + }; +} + +function resolveBackend( + context: ImageAnalysisResolutionContext, + config: ImageAnalysisConfig, + nativeSupport: NativeImageSupportResolution +): 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 (nativeSupport.nativeReadPreference) { + return { + backendId: null, + backendDisplayName: null, + resolutionSource: 'native-compatible', + reason: + nativeSupport.nativeImageCapable === true + ? 'This profile is set to use native image reading.' + : `${nativeSupport.nativeImageReason ?? 'Native image reading is enabled for this profile.'} CCS will bypass the transformer for this profile.`, + }; + } + + // Explicit profile mappings are the only user-authored override and must + // win before provider/bridge inference. + const mappedBackend = resolveConfiguredProfileBackend(profileName, config); + if (mappedBackend) { + return { + backendId: mappedBackend, + backendDisplayName: getBackendDisplayName(mappedBackend), + resolutionSource: 'profile-backend', + reason: null, + }; + } + + 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 hasDirectAnthropicApiKey = Boolean(settings?.env?.ANTHROPIC_API_KEY?.trim()); + const hasBaseUrl = Boolean(settings?.env?.ANTHROPIC_BASE_URL?.trim()); + if (hasDirectAnthropicApiKey && !hasBaseUrl) { + return { + backendId: null, + backendDisplayName: null, + resolutionSource: 'unresolved', + reason: 'Direct Anthropic settings profiles use native file access unless explicitly mapped.', + }; + } + + 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 nativeSupport = resolveNativeImageSupport(context, config); + const resolution = resolveBackend(context, config, nativeSupport); + const model = resolution.backendId + ? (config.provider_models[resolution.backendId] ?? null) + : null; + const shouldPersistHook = + config.enabled && + context.profileType !== 'default' && + context.profileType !== 'account' && + Boolean(resolution.backendId && model); + + 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'; + if (!context.cliproxyBridge.usesCurrentTarget && !context.cliproxyBridge.usesCurrentAuthToken) { + reason = + 'Runtime uses the current CLIProxy route and auth token instead of the saved values in this profile.'; + } else if (!context.cliproxyBridge.usesCurrentTarget) { + reason = + 'Runtime uses the current CLIProxy route instead of the saved route in this profile.'; + } else { + reason = + 'Runtime uses the current CLIProxy auth token instead of the saved token 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, + authReadiness: + resolution.backendId && model && isCLIProxyProvider(resolution.backendId) + ? 'unknown' + : 'not-needed', + authProvider: + resolution.backendId && isCLIProxyProvider(resolution.backendId) + ? resolution.backendId + : null, + authDisplayName: + resolution.backendId && isCLIProxyProvider(resolution.backendId) + ? getProviderDisplayName(resolution.backendId) + : null, + authReason: + resolution.backendId && model && isCLIProxyProvider(resolution.backendId) + ? 'Auth readiness has not been verified yet.' + : null, + proxyReadiness: resolution.backendId && model ? 'unknown' : 'not-needed', + proxyReason: + resolution.backendId && model + ? 'CLIProxy runtime readiness has not been verified yet.' + : null, + effectiveRuntimeMode: + config.enabled && resolution.backendId && model && status !== 'hook-missing' + ? 'cliproxy-image-analysis' + : 'native-read', + effectiveRuntimeReason: + status === 'hook-missing' || !config.enabled || !resolution.backendId || !model + ? reason + : null, + profileModel: nativeSupport.profileModel, + nativeReadPreference: nativeSupport.nativeReadPreference, + nativeImageCapable: nativeSupport.nativeImageCapable, + nativeImageReason: nativeSupport.nativeImageReason, + }; +} diff --git a/src/utils/hooks/image-analysis-runtime-status.ts b/src/utils/hooks/image-analysis-runtime-status.ts new file mode 100644 index 00000000..8fd910d2 --- /dev/null +++ b/src/utils/hooks/image-analysis-runtime-status.ts @@ -0,0 +1,175 @@ +import { getAuthStatus, initializeAccounts, type AuthStatus } from '../../cliproxy/auth-handler'; +import { fetchRemoteAuthStatus, type RemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; +import { getProxyTarget, type ProxyTarget } from '../../cliproxy/proxy-target-resolver'; +import { getProviderDisplayName, isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; +import { isCliproxyRunning } from '../../cliproxy/stats-fetcher'; +import type { CLIProxyProvider } from '../../cliproxy/types'; +import { + DEFAULT_IMAGE_ANALYSIS_CONFIG, + type ImageAnalysisConfig, +} from '../../config/unified-config-types'; +import { + resolveImageAnalysisStatus, + type ImageAnalysisResolutionContext, + type ImageAnalysisStatus, +} from './image-analysis-backend-resolver'; + +interface ImageAnalysisRuntimeStatusDeps { + fetchRemoteAuthStatus: (target: ProxyTarget) => Promise; + getAuthStatus: (provider: CLIProxyProvider) => AuthStatus; + getProxyTarget: () => ProxyTarget; + initializeAccounts: () => void; + isCliproxyRunning: () => Promise; +} + +const defaultDeps: ImageAnalysisRuntimeStatusDeps = { + fetchRemoteAuthStatus, + getAuthStatus, + getProxyTarget, + initializeAccounts, + isCliproxyRunning: () => isCliproxyRunning(), +}; + +async function resolveAuthReadiness( + status: ImageAnalysisStatus, + deps: ImageAnalysisRuntimeStatusDeps +): Promise< + Pick +> { + if (!status.backendId || !status.model || !isCLIProxyProvider(status.backendId)) { + return { + authReadiness: 'not-needed', + authProvider: null, + authDisplayName: null, + authReason: null, + }; + } + + const authProvider = status.backendId; + const authDisplayName = getProviderDisplayName(authProvider); + + try { + let authenticated = false; + const target = deps.getProxyTarget(); + if (target.isRemote) { + const remoteStatuses = await deps.fetchRemoteAuthStatus(target); + authenticated = remoteStatuses.some( + (entry) => entry.provider === authProvider && entry.authenticated + ); + } else { + deps.initializeAccounts(); + authenticated = deps.getAuthStatus(authProvider).authenticated; + } + + return { + authReadiness: authenticated ? 'ready' : 'missing', + authProvider, + authDisplayName, + authReason: authenticated + ? null + : `${authDisplayName} auth is missing. Run "ccs ${authProvider} --auth" to enable image analysis.`, + }; + } catch (error) { + return { + authReadiness: 'unknown', + authProvider, + authDisplayName, + authReason: `CCS could not verify ${authDisplayName} auth readiness: ${(error as Error).message}`, + }; + } +} + +async function resolveProxyReadiness( + status: ImageAnalysisStatus, + deps: ImageAnalysisRuntimeStatusDeps +): Promise> { + if (!status.backendId || !status.model) { + return { + proxyReadiness: 'not-needed', + proxyReason: null, + }; + } + + const target = deps.getProxyTarget(); + const reachable = await deps.isCliproxyRunning(); + if (target.isRemote) { + return { + proxyReadiness: reachable ? 'remote' : 'unavailable', + proxyReason: reachable + ? `Remote CLIProxy target ${target.host}:${target.port} is reachable.` + : `Remote CLIProxy target ${target.host}:${target.port} is unreachable.`, + }; + } + + return { + proxyReadiness: reachable ? 'ready' : 'stopped', + proxyReason: reachable + ? 'Local CLIProxy service is reachable.' + : 'Local CLIProxy service is idle. CCS will start it automatically when image analysis is needed.', + }; +} + +function resolveEffectiveRuntime( + status: ImageAnalysisStatus +): Pick { + if (!status.enabled || !status.backendId || !status.model) { + return { + effectiveRuntimeMode: 'native-read', + effectiveRuntimeReason: status.reason, + }; + } + + if (status.status === 'hook-missing') { + return { + effectiveRuntimeMode: 'native-read', + effectiveRuntimeReason: status.reason, + }; + } + + if (status.authReadiness === 'missing' || status.authReadiness === 'unknown') { + return { + effectiveRuntimeMode: 'native-read', + effectiveRuntimeReason: status.authReason, + }; + } + + if (status.proxyReadiness === 'unavailable' || status.proxyReadiness === 'unknown') { + return { + effectiveRuntimeMode: 'native-read', + effectiveRuntimeReason: status.proxyReason, + }; + } + + return { + effectiveRuntimeMode: 'cliproxy-image-analysis', + effectiveRuntimeReason: status.status === 'attention' ? status.reason : null, + }; +} + +export async function hydrateImageAnalysisRuntimeStatus( + baseStatus: ImageAnalysisStatus, + deps: Partial = {} +): Promise { + const resolvedDeps = { ...defaultDeps, ...deps }; + const authStatus = await resolveAuthReadiness(baseStatus, resolvedDeps); + const proxyStatus = await resolveProxyReadiness(baseStatus, resolvedDeps); + const mergedStatus = { + ...baseStatus, + ...authStatus, + ...proxyStatus, + }; + + return { + ...mergedStatus, + ...resolveEffectiveRuntime(mergedStatus), + }; +} + +export async function resolveImageAnalysisRuntimeStatus( + context: ImageAnalysisResolutionContext, + config: ImageAnalysisConfig = DEFAULT_IMAGE_ANALYSIS_CONFIG, + deps: Partial = {} +): Promise { + const baseStatus = resolveImageAnalysisStatus(context, config); + return hydrateImageAnalysisRuntimeStatus(baseStatus, deps); +} 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..68973870 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -7,6 +7,17 @@ */ export { getImageAnalysisHookEnv } from './get-image-analysis-hook-env'; +export { + canonicalizeImageAnalysisConfig, + resolveImageAnalysisStatus, + normalizeImageAnalysisBackendId, + type ImageAnalysisResolutionContext, + type ImageAnalysisStatus, +} from './image-analysis-backend-resolver'; +export { + hydrateImageAnalysisRuntimeStatus, + resolveImageAnalysisRuntimeStatus, +} from './image-analysis-runtime-status'; export { getImageAnalyzerHookPath, getImageAnalyzerHookConfig, diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 9aa1ae0d..7053b231 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -36,6 +36,7 @@ export async function startServer(options: ServerOptions): Promise; + fallbackBackend?: string | null; + profileBackends?: Record; +} + +function safeLoadSettings(settingsPath: string | null): Settings | null { + if (!settingsPath) return null; + + try { + const expandedPath = expandPath(settingsPath); + if (!fs.existsSync(expandedPath)) return null; + return loadSettings(expandedPath); + } catch { + return null; + } +} + +function resolveProviderFromBaseUrl(baseUrl: unknown): CLIProxyProvider | 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 resolveTarget(target: unknown): DashboardTarget { + if (target === 'droid' || target === 'codex') return target; + return 'claude'; +} + +function resolveCurrentTargetMode( + target: DashboardTarget, + status: Awaited> +): CurrentTargetMode { + if (!status.enabled) return 'disabled'; + if (target !== 'claude') return 'bypassed'; + if (status.nativeReadPreference) return 'native'; + if (!status.backendId) return 'unresolved'; + if (status.status === 'hook-missing') return 'setup'; + if (status.effectiveRuntimeMode === 'native-read') return 'fallback'; + return 'active'; +} + +function resolveBackendState( + status: Awaited> +): BackendState { + if (status.authReadiness === 'missing') return 'needs_auth'; + if (status.proxyReadiness === 'unavailable') return 'needs_proxy'; + if (status.proxyReadiness === 'stopped') return 'starts_on_launch'; + if (status.effectiveRuntimeMode === 'native-read' || status.status === 'attention') + return 'review'; + return 'ready'; +} + +function getKnownBackends(): string[] { + return Array.from(new Set(CLIPROXY_PROVIDER_IDS)).sort((left, right) => + left.localeCompare(right) + ); +} + +async function buildDashboardPayload() { + const config = getImageAnalysisConfig(); + const { profiles, variants } = listApiProfiles(); + const sharedHookInstalled = hasImageAnalyzerHook(); + + const profileRows = await Promise.all( + profiles.map(async (profile) => { + const settingsPath = profile.settingsPath || null; + const settings = safeLoadSettings(settingsPath); + const cliproxyProvider = + mapExternalProviderName(profile.name) ?? + resolveCliproxyBridgeMetadata(settings ?? undefined)?.provider ?? + resolveProviderFromBaseUrl(settings?.env?.ANTHROPIC_BASE_URL); + const status = await resolveImageAnalysisRuntimeStatus( + { + profileName: profile.name, + profileType: 'settings', + cliproxyProvider, + settingsPath, + settings, + cliproxyBridge: resolveCliproxyBridgeMetadata(settings ?? undefined), + hookInstalled: settingsPath + ? hasImageAnalysisProfileHook(profile.name, settingsPath) + : undefined, + sharedHookInstalled, + }, + config + ); + + return { + name: profile.name, + kind: 'profile' as const, + target: resolveTarget(profile.target), + configured: profile.isConfigured, + settingsPath, + backendId: status.backendId, + backendDisplayName: status.backendDisplayName, + resolutionSource: status.resolutionSource, + status: status.status, + effectiveRuntimeMode: status.effectiveRuntimeMode, + effectiveRuntimeReason: status.effectiveRuntimeReason, + currentTargetMode: resolveCurrentTargetMode(resolveTarget(profile.target), status), + profileModel: status.profileModel, + nativeReadPreference: status.nativeReadPreference, + nativeImageCapable: status.nativeImageCapable, + nativeImageReason: status.nativeImageReason, + }; + }) + ); + + const variantRows = await Promise.all( + variants.map(async (variant) => { + const settingsPath = + typeof variant.settings === 'string' && variant.settings !== '-' ? variant.settings : null; + const settings = safeLoadSettings(settingsPath); + const cliproxyProvider = mapExternalProviderName(variant.provider); + const status = await resolveImageAnalysisRuntimeStatus( + { + profileName: variant.name, + profileType: 'cliproxy', + cliproxyProvider, + isComposite: variant.provider === 'composite', + settingsPath, + settings, + cliproxyBridge: resolveCliproxyBridgeMetadata(settings ?? undefined), + hookInstalled: settingsPath + ? hasImageAnalysisProfileHook(variant.name, settingsPath) + : undefined, + sharedHookInstalled, + }, + config + ); + + return { + name: variant.name, + kind: 'variant' as const, + target: resolveTarget(variant.target), + configured: true, + settingsPath, + backendId: status.backendId, + backendDisplayName: status.backendDisplayName, + resolutionSource: status.resolutionSource, + status: status.status, + effectiveRuntimeMode: status.effectiveRuntimeMode, + effectiveRuntimeReason: status.effectiveRuntimeReason, + currentTargetMode: resolveCurrentTargetMode(resolveTarget(variant.target), status), + profileModel: status.profileModel, + nativeReadPreference: status.nativeReadPreference, + nativeImageCapable: status.nativeImageCapable, + nativeImageReason: status.nativeImageReason, + }; + }) + ); + + const allProfileRows = [...profileRows, ...variantRows].sort((left, right) => + left.name.localeCompare(right.name) + ); + + const backendRows = await Promise.all( + Object.entries(config.provider_models) + .sort(([left], [right]) => left.localeCompare(right)) + .map(async ([backendId, model]) => { + const status = await resolveImageAnalysisRuntimeStatus( + { + profileName: backendId, + profileType: 'cliproxy', + cliproxyProvider: mapExternalProviderName(backendId), + hookInstalled: true, + sharedHookInstalled: true, + }, + config + ); + + return { + backendId, + displayName: getProviderDisplayName(backendId as CLIProxyProvider), + model, + state: resolveBackendState(status), + authReadiness: status.authReadiness, + authReason: status.authReason, + proxyReadiness: status.proxyReadiness, + proxyReason: status.proxyReason, + profilesUsing: allProfileRows.filter( + (profile) => profile.backendId === backendId && !profile.nativeReadPreference + ).length, + }; + }) + ); + + const activeProfileCount = allProfileRows.filter( + (row) => row.currentTargetMode === 'active' + ).length; + const bypassedProfileCount = allProfileRows.filter( + (row) => row.currentTargetMode === 'bypassed' + ).length; + const mappedProfileCount = allProfileRows.filter( + (row) => row.resolutionSource === 'profile-backend' + ).length; + const nativeProfileCount = allProfileRows.filter((row) => row.nativeReadPreference).length; + const blockerCount = backendRows.filter( + (row) => row.state === 'needs_auth' || row.state === 'needs_proxy' || row.state === 'review' + ).length; + + let summaryState: DashboardSummaryState = 'ready'; + let title = 'Ready'; + let detail = `${activeProfileCount} profile${activeProfileCount === 1 ? '' : 's'} route through Image on the current Claude target path.`; + + if (nativeProfileCount > 0) { + detail += ` ${nativeProfileCount} prefer native image reading.`; + } + + if (!config.enabled) { + summaryState = 'disabled'; + title = 'Disabled'; + detail = 'Image is turned off globally. Images and PDFs fall back to native file access.'; + } else if (backendRows.length === 0) { + summaryState = 'needs_setup'; + title = 'Needs provider models'; + detail = 'Add at least one provider model before turning Image on for profiles.'; + } else if (blockerCount > 0) { + summaryState = activeProfileCount > 0 ? 'partial' : 'needs_setup'; + title = activeProfileCount > 0 ? 'Partially ready' : 'Needs setup'; + detail = `${blockerCount} backend${blockerCount === 1 ? '' : 's'} still need auth, runtime, or review before every profile path is healthy.`; + } + + return { + config: { + enabled: config.enabled, + timeout: config.timeout, + providerModels: config.provider_models, + fallbackBackend: config.fallback_backend ?? null, + profileBackends: config.profile_backends ?? {}, + }, + summary: { + state: summaryState, + title, + detail, + backendCount: backendRows.length, + mappedProfileCount, + activeProfileCount, + bypassedProfileCount, + nativeProfileCount, + }, + backends: backendRows, + profiles: allProfileRows, + catalog: { + knownBackends: getKnownBackends(), + profileNames: allProfileRows.map((row) => row.name), + }, + }; +} + +router.use((req: Request, res: Response, next) => { + if (requireLocalAccessWhenAuthDisabled(req, res, IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR)) { + next(); + } +}); + +router.get('/', async (_req: Request, res: Response): Promise => { + try { + res.json(await buildDashboardPayload()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.put('/', async (req: Request, res: Response): Promise => { + const body = req.body as ImageAnalysisRouteBody; + + if (!body || typeof body !== 'object' || Array.isArray(body)) { + res.status(400).json({ error: 'Invalid request body. Must be an object.' }); + return; + } + + if (body.enabled !== undefined && typeof body.enabled !== 'boolean') { + res.status(400).json({ error: 'Invalid value for enabled. Must be a boolean.' }); + return; + } + + if (body.timeout !== undefined) { + if (!Number.isInteger(body.timeout) || body.timeout < 10 || body.timeout > 600) { + res.status(400).json({ error: 'Timeout must be an integer between 10 and 600 seconds.' }); + return; + } + } + + if ( + body.providerModels !== undefined && + (body.providerModels === null || + Array.isArray(body.providerModels) || + typeof body.providerModels !== 'object') + ) { + res.status(400).json({ error: 'Invalid value for providerModels. Must be an object.' }); + return; + } + + if ( + body.profileBackends !== undefined && + (body.profileBackends === null || + Array.isArray(body.profileBackends) || + typeof body.profileBackends !== 'object') + ) { + res.status(400).json({ error: 'Invalid value for profileBackends. Must be an object.' }); + return; + } + + if ( + body.fallbackBackend !== undefined && + body.fallbackBackend !== null && + typeof body.fallbackBackend !== 'string' + ) { + res.status(400).json({ error: 'Invalid value for fallbackBackend. Must be a string or null.' }); + return; + } + + try { + const currentConfig = getImageAnalysisConfig(); + const knownBackends = new Set([ + ...getKnownBackends(), + ...Object.keys(currentConfig.provider_models), + ]); + const nextProviderModels = Object.entries( + body.providerModels ?? currentConfig.provider_models + ).reduce( + (acc, [backendId, model]) => { + const normalizedBackend = normalizeImageAnalysisBackendId(backendId, knownBackends); + const normalizedModel = typeof model === 'string' ? model.trim() : ''; + if (!normalizedBackend || normalizedModel.length === 0) { + return acc; + } + acc[normalizedBackend] = normalizedModel; + return acc; + }, + {} as Record + ); + + if (Object.keys(nextProviderModels).length === 0) { + res.status(400).json({ error: 'At least one provider model must remain configured.' }); + return; + } + + const requestedFallback = + typeof body.fallbackBackend === 'string' + ? body.fallbackBackend + : currentConfig.fallback_backend; + const normalizedFallback = normalizeImageAnalysisBackendId( + requestedFallback, + Object.keys(nextProviderModels) + ); + if (!normalizedFallback || !nextProviderModels[normalizedFallback]) { + res + .status(400) + .json({ error: 'Fallback backend must reference a configured provider model.' }); + return; + } + + const nextProfileBackends = {} as Record; + for (const [profileName, backendId] of Object.entries( + body.profileBackends ?? currentConfig.profile_backends ?? {} + )) { + const trimmedProfileName = profileName.trim(); + if (!trimmedProfileName) { + continue; + } + + const normalizedBackend = normalizeImageAnalysisBackendId( + backendId, + Object.keys(nextProviderModels) + ); + if (!normalizedBackend || !nextProviderModels[normalizedBackend]) { + res.status(400).json({ + error: `Profile mapping for "${trimmedProfileName}" references an unknown backend.`, + }); + return; + } + + nextProfileBackends[trimmedProfileName] = normalizedBackend; + } + + mutateUnifiedConfig((config) => { + config.image_analysis = { + enabled: body.enabled ?? currentConfig.enabled, + timeout: body.timeout ?? currentConfig.timeout, + provider_models: nextProviderModels, + fallback_backend: normalizedFallback, + profile_backends: nextProfileBackends, + }; + }); + + res.json(await buildDashboardPayload()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 4a8876e0..85534e0c 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -17,6 +17,7 @@ import variantRoutes from './variant-routes'; import settingsRoutes from './settings-routes'; import channelsRoutes from './channels-routes'; import websearchRoutes from './websearch-routes'; +import imageAnalysisRoutes from './image-analysis-routes'; import cliproxyAuthRoutes from './cliproxy-auth-routes'; import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxySyncRoutes from './cliproxy-sync-routes'; @@ -68,6 +69,7 @@ apiRoutes.use('/cliproxy/openai-compat', providerRoutes); // ==================== WebSearch ==================== apiRoutes.use('/websearch', websearchRoutes); +apiRoutes.use('/image-analysis', imageAnalysisRoutes); // ==================== Copilot ==================== apiRoutes.use('/copilot', copilotRoutes); diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 193080b4..531b5cd9 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 { resolveImageAnalysisRuntimeStatus } 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,47 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting return changed ? next : settings; } +async function resolveImageAnalysisStatusForProfile( + profileOrVariant: string, + settings: Settings, + settingsPath: string +): Promise>> { + const variants = listVariants(); + const variant = variants[profileOrVariant]; + const cliproxyProvider = resolveProviderForProfile(profileOrVariant); + const cliproxyBridge = resolveCliproxyBridgeMetadata(settings); + const status = await resolveImageAnalysisRuntimeStatus( + { + 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, + }; +} + +async function resolvePreviewImageAnalysisStatus(profileOrVariant: string, settings: Settings) { + const normalizedSettings = canonicalizeProfileSettings(profileOrVariant, settings); + const settingsPath = resolveSettingsPath(profileOrVariant); + + return resolveImageAnalysisStatusForProfile(profileOrVariant, normalizedSettings, settingsPath); +} + function writeSettingsAtomically(settingsPath: string, settings: Settings): void { const tempPath = `${settingsPath}.tmp.${process.pid}`; fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); @@ -318,7 +377,7 @@ function maskApiKeys(settings: Settings): Settings { /** * GET /api/settings/:profile - Get settings with masked API keys */ -router.get('/:profile', (req: Request, res: Response): void => { +router.get('/:profile', async (req: Request, res: Response): Promise => { try { const { profile } = req.params; const settingsPath = resolveSettingsPath(profile); @@ -338,6 +397,11 @@ router.get('/:profile', (req: Request, res: Response): void => { mtime: stat.mtime.getTime(), path: settingsPath, cliproxyBridge: resolveCliproxyBridgeMetadata(settings), + imageAnalysisStatus: await resolveImageAnalysisStatusForProfile( + profile, + settings, + settingsPath + ), }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); @@ -347,7 +411,7 @@ router.get('/:profile', (req: Request, res: Response): void => { /** * GET /api/settings/:profile/raw - Get full settings (for editing) */ -router.get('/:profile/raw', (req: Request, res: Response): void => { +router.get('/:profile/raw', async (req: Request, res: Response): Promise => { if (!requireSensitiveLocalAccess(req, res)) return; try { @@ -368,12 +432,43 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { mtime: stat.mtime.getTime(), path: settingsPath, cliproxyBridge: resolveCliproxyBridgeMetadata(settings), + imageAnalysisStatus: await resolveImageAnalysisStatusForProfile( + profile, + settings, + settingsPath + ), }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); } }); +/** + * POST /api/settings/:profile/image-analysis-status - Preview image analysis status from editor JSON + */ +router.post( + '/:profile/image-analysis-status', + async (req: Request, res: Response): Promise => { + if (!requireSensitiveLocalAccess(req, res)) return; + + try { + const { profile } = req.params; + const { settings } = req.body; + + if (!settings || typeof settings !== 'object') { + res.status(400).json({ error: 'settings object is required in request body' }); + return; + } + + res.json({ + imageAnalysisStatus: await resolvePreviewImageAnalysisStatus(profile, settings as Settings), + }); + } catch (error) { + respondInternalError(res, error, 'Internal server error.'); + } + } +); + /** Required env vars for CLIProxy providers to function */ const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts index 86c5c95e..d9a3015b 100644 --- a/tests/e2e/image-analyzer-hook.e2e.test.ts +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -33,7 +33,7 @@ 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'; + 'agy:gemini-3-1-flash-preview,gemini:gemini-3-flash-preview,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 // ============================================================================ @@ -626,7 +626,7 @@ describe('Image Analyzer Hook', () => { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy', CCS_CURRENT_PROVIDER: 'agy', - CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-3-1-flash-preview', } ); @@ -647,7 +647,7 @@ describe('Image Analyzer Hook', () => { }>; }>; }; - expect(body.model).toBe('gemini-2.5-flash'); + expect(body.model).toBe('gemini-3-1-flash-preview'); expect(body.max_tokens).toBe(4096); expect(body.messages).toHaveLength(1); expect(body.messages[0].role).toBe('user'); @@ -738,7 +738,8 @@ describe('Image Analyzer Hook', () => { 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', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: + 'codex:gpt-5.1-codex-mini,agy:gemini-3-1-flash-preview', } ); @@ -756,7 +757,7 @@ describe('Image Analyzer Hook', () => { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy', CCS_CURRENT_PROVIDER: 'unknown-provider', - CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-3-1-flash-preview', } ); @@ -820,7 +821,9 @@ describe('Image Analyzer Hook', () => { ); const output = JSON.parse(result.stdout); - expect(output.hookSpecificOutput.permissionDecisionReason).toContain('gemini-2.5-flash'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain( + 'gemini-3-1-flash-preview' + ); }); it('should output valid JSON structure on file read error', () => { diff --git a/tests/integration/image-analyzer-hook.test.ts b/tests/integration/image-analyzer-hook.test.ts index b21c8b7a..288e1ce5 100644 --- a/tests/integration/image-analyzer-hook.test.ts +++ b/tests/integration/image-analyzer-hook.test.ts @@ -58,7 +58,8 @@ function invokeHook(env: Record = {}): Promise { 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', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: + 'codex:gpt-5.1-codex-mini,agy:gemini-3-1-flash-preview', ...env, }, stdio: ['pipe', 'pipe', 'pipe'], @@ -195,7 +196,7 @@ describe('image analyzer hook regression coverage', () => { it('skips analysis before contacting CLIProxy when the current provider has no mapped vision model', async () => { const result = await invokeHook({ CCS_CURRENT_PROVIDER: 'unknown-provider', - CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-3-1-flash-preview', }); expect(result.code).toBe(0); diff --git a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts index 61d13406..0978ede3 100644 --- a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts +++ b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts @@ -2,7 +2,11 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it } from 'bun:test'; -import { buildClaudeEnvironment } from '../../../src/cliproxy/executor/env-resolver'; +import { + buildClaudeEnvironment, + resolveCliproxyImageAnalysisEnv, +} from '../../../src/cliproxy/executor/env-resolver'; +import type { ImageAnalysisStatus } from '../../../src/utils/hooks'; const tempDirs: string[] = []; @@ -37,6 +41,37 @@ function createCodexSettingsFile(models: { return settingsPath; } +function createImageAnalysisStatus( + overrides: Partial = {} +): ImageAnalysisStatus { + return { + enabled: true, + supported: true, + status: 'active', + backendId: 'agy', + backendDisplayName: 'Antigravity', + model: 'gemini-2.5-pro', + resolutionSource: 'cliproxy-provider', + reason: null, + shouldPersistHook: true, + persistencePath: '/tmp/orq.settings.json', + runtimePath: '/api/provider/agy', + usesCurrentTarget: true, + usesCurrentAuthToken: true, + hookInstalled: true, + sharedHookInstalled: true, + authReadiness: 'ready', + authProvider: 'agy', + authDisplayName: 'Antigravity', + authReason: null, + proxyReadiness: 'ready', + proxyReason: 'Local CLIProxy service is reachable.', + effectiveRuntimeMode: 'cliproxy-image-analysis', + effectiveRuntimeReason: null, + ...overrides, + }; +} + describe('buildClaudeEnvironment codex fallback normalization', () => { afterEach(() => { while (tempDirs.length > 0) { @@ -112,4 +147,117 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/.ccs/instances/pro'); }); + + it('uses an execution-aware image analysis env override when provided', () => { + const env = buildClaudeEnvironment({ + provider: 'agy', + useRemoteProxy: false, + localPort: 8317, + verbose: false, + imageAnalysisEnv: { + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_IMAGE_ANALYSIS_TIMEOUT: '60', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro', + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }, + }); + + expect(env.CCS_CURRENT_PROVIDER).toBe(''); + expect(env.CCS_IMAGE_ANALYSIS_SKIP).toBe('1'); + }); +}); + +describe('resolveCliproxyImageAnalysisEnv', () => { + it('falls back to native read when runtime status is not launchable', async () => { + const result = await resolveCliproxyImageAnalysisEnv( + { + profileName: 'orq', + provider: 'agy', + profileSettingsPath: '/tmp/orq.settings.json', + proxyTarget: { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, + }, + proxyReachable: true, + }, + { + getImageAnalysisHookEnv: () => ({ + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_IMAGE_ANALYSIS_TIMEOUT: '60', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro', + CCS_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_SKIP: '0', + }), + hasImageAnalysisProfileHook: () => true, + hasImageAnalyzerHook: () => true, + resolveImageAnalysisRuntimeStatus: async (context, _config, deps) => { + expect(context.profileName).toBe('orq'); + expect(context.cliproxyProvider).toBe('agy'); + expect(context.hookInstalled).toBe(true); + expect(context.sharedHookInstalled).toBe(true); + expect(deps?.getProxyTarget?.().isRemote).toBe(false); + return createImageAnalysisStatus({ + authReadiness: 'missing', + authReason: 'Antigravity auth is missing.', + effectiveRuntimeMode: 'native-read', + effectiveRuntimeReason: 'Antigravity auth is missing.', + }); + }, + } + ); + + expect(result.env.CCS_CURRENT_PROVIDER).toBe(''); + expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('1'); + expect(result.warning).toContain('Antigravity auth is missing.'); + expect(result.warning).toContain('native Read'); + }); + + it('keeps cliproxy image analysis active when the execution target is reachable', async () => { + const result = await resolveCliproxyImageAnalysisEnv( + { + profileName: 'orq', + provider: 'agy', + isComposite: true, + proxyTarget: { + host: 'remote.example.com', + port: 9443, + protocol: 'https', + authToken: 'remote-token', + managementKey: 'remote-management-key', + allowSelfSigned: true, + isRemote: true, + }, + proxyReachable: true, + }, + { + getImageAnalysisHookEnv: () => ({ + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_IMAGE_ANALYSIS_TIMEOUT: '60', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro', + CCS_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_SKIP: '0', + }), + hasImageAnalysisProfileHook: () => true, + hasImageAnalyzerHook: () => true, + resolveImageAnalysisRuntimeStatus: async (context, _config, deps) => { + expect(context.isComposite).toBe(true); + expect(deps?.getProxyTarget?.().host).toBe('remote.example.com'); + expect(deps?.getProxyTarget?.().managementKey).toBe('remote-management-key'); + expect(deps?.getProxyTarget?.().allowSelfSigned).toBe(true); + return createImageAnalysisStatus({ + resolutionSource: 'cliproxy-composite', + proxyReadiness: 'remote', + proxyReason: 'Remote CLIProxy target remote.example.com:9443 is reachable.', + }); + }, + } + ); + + expect(result.env.CCS_CURRENT_PROVIDER).toBe('agy'); + expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0'); + expect(result.warning).toBeNull(); + }); }); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 4d675641..b379f5ab 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -100,9 +100,17 @@ describe('Model Catalog', () => { assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier'); }); - it('has 3 models total', () => { + it('includes Gemini Flash via Antigravity', () => { const { MODEL_CATALOG } = modelCatalog; - assert.strictEqual(MODEL_CATALOG.agy.models.length, 3); + const flash = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3-1-flash-preview'); + assert(flash, 'Should include Gemini Flash'); + assert.strictEqual(flash.name, 'Gemini Flash'); + assert.strictEqual(flash.tier, undefined, 'AGY models should not have paid tier'); + }); + + it('has 4 models total', () => { + const { MODEL_CATALOG } = modelCatalog; + assert.strictEqual(MODEL_CATALOG.agy.models.length, 4); }); }); @@ -155,9 +163,17 @@ describe('Model Catalog', () => { assert.strictEqual(gem25.tier, undefined); }); - it('has 2 models total', () => { + it('includes Gemini Flash with pro tier', () => { const { MODEL_CATALOG } = modelCatalog; - assert.strictEqual(MODEL_CATALOG.gemini.models.length, 2); + const flash = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3-flash-preview'); + assert(flash, 'Should include Gemini Flash'); + assert.strictEqual(flash.name, 'Gemini Flash'); + assert.strictEqual(flash.tier, 'pro'); + }); + + it('has 3 models total', () => { + const { MODEL_CATALOG } = modelCatalog; + assert.strictEqual(MODEL_CATALOG.gemini.models.length, 3); }); }); diff --git a/tests/unit/cliproxy/proxy-config-resolver.test.js b/tests/unit/cliproxy/proxy-config-resolver.test.js index ce905846..0ebc8ebc 100644 --- a/tests/unit/cliproxy/proxy-config-resolver.test.js +++ b/tests/unit/cliproxy/proxy-config-resolver.test.js @@ -282,6 +282,18 @@ describe('proxy-config-resolver', () => { expect(config.host).toBe('yaml-host.example.com'); }); + it('should preserve YAML management key for remote mode', () => { + const { config } = resolveProxyConfig([], { + remote: { + host: 'yaml-host.example.com', + auth_token: 'remote-auth-token', + management_key: 'remote-management-key', + }, + }); + expect(config.mode).toBe('remote'); + expect(config.managementKey).toBe('remote-management-key'); + }); + it('should allow CLI --proxy-host to override YAML enabled:false', () => { const { config } = resolveProxyConfig(['--proxy-host', 'cli-host'], { remote: { enabled: false, host: 'yaml-host' }, diff --git a/tests/unit/commands/config-image-analysis-command.test.ts b/tests/unit/commands/config-image-analysis-command.test.ts index 6a7f9134..4e9d44b6 100644 --- a/tests/unit/commands/config-image-analysis-command.test.ts +++ b/tests/unit/commands/config-image-analysis-command.test.ts @@ -4,7 +4,7 @@ * Unit tests for ccs config image-analysis subcommand. */ -import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test'; +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -33,6 +33,13 @@ function createConfigYaml(content: string): void { fs.writeFileSync(path.join(testDir, 'config.yaml'), content, 'utf8'); } +async function loadHandleConfigImageAnalysisCommand() { + const mod = await import( + `../../../src/commands/config-image-analysis-command?test=${Date.now()}-${Math.random()}` + ); + return mod.handleConfigImageAnalysisCommand; +} + describe('config image-analysis command', () => { describe('config file parsing', () => { it('should parse enabled status from config.yaml', () => { @@ -42,13 +49,13 @@ image_analysis: enabled: true timeout: 60 provider_models: - agy: gemini-2.5-flash + agy: gemini-3-1-flash-preview `); const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); expect(content).toContain('enabled: true'); expect(content).toContain('timeout: 60'); - expect(content).toContain('agy: gemini-2.5-flash'); + expect(content).toContain('agy: gemini-3-1-flash-preview'); }); it('should parse disabled status from config.yaml', () => { @@ -72,14 +79,14 @@ image_analysis: enabled: true timeout: 60 provider_models: - agy: gemini-2.5-flash + agy: gemini-3-1-flash-preview gemini: gemini-2.5-pro codex: gpt-5.1-codex-mini kiro: kiro-claude-haiku-4-5 `); const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); - expect(content).toContain('agy: gemini-2.5-flash'); + expect(content).toContain('agy: gemini-3-1-flash-preview'); expect(content).toContain('gemini: gemini-2.5-pro'); expect(content).toContain('codex: gpt-5.1-codex-mini'); expect(content).toContain('kiro: kiro-claude-haiku-4-5'); @@ -152,6 +159,56 @@ image_analysis: expect(validProviders.includes(provider)).toBe(false); } }); + + it('rejects invalid fallback backends that are not configured', async () => { + const handleConfigImageAnalysisCommand = await loadHandleConfigImageAnalysisCommand(); + const originalProcessExit = process.exit; + + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; + + try { + await expect( + handleConfigImageAnalysisCommand(['--set-fallback', 'unknown-provider']) + ).rejects.toThrow('process.exit(1)'); + } finally { + process.exit = originalProcessExit; + } + + const configPath = path.join(testDir, 'config.yaml'); + if (fs.existsSync(configPath)) { + const content = fs.readFileSync(configPath, 'utf8'); + expect(content).not.toContain('fallback_backend: unknown-provider'); + } else { + expect(fs.existsSync(configPath)).toBe(false); + } + }); + + it('rejects invalid profile backend mappings that are not configured', async () => { + const handleConfigImageAnalysisCommand = await loadHandleConfigImageAnalysisCommand(); + const originalProcessExit = process.exit; + + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; + + try { + await expect( + handleConfigImageAnalysisCommand(['--set-profile-backend', 'orq', 'unknown-provider']) + ).rejects.toThrow('process.exit(1)'); + } finally { + process.exit = originalProcessExit; + } + + const configPath = path.join(testDir, 'config.yaml'); + if (fs.existsSync(configPath)) { + const content = fs.readFileSync(configPath, 'utf8'); + expect(content).not.toContain('unknown-provider'); + } else { + expect(fs.existsSync(configPath)).toBe(false); + } + }); }); describe('default configuration', () => { @@ -161,8 +218,8 @@ image_analysis: enabled: true, timeout: 60, provider_models: { - agy: 'gemini-2.5-flash', - gemini: 'gemini-2.5-flash', + agy: 'gemini-3-1-flash-preview', + gemini: 'gemini-3-flash-preview', codex: 'gpt-5.1-codex-mini', kiro: 'kiro-claude-haiku-4-5', ghcp: 'claude-haiku-4.5', @@ -187,7 +244,7 @@ image_analysis: enabled: true timeout: 60 provider_models: - agy: gemini-2.5-flash + agy: gemini-3-1-flash-preview `); const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); diff --git a/tests/unit/copilot/copilot-executor-env.test.ts b/tests/unit/copilot/copilot-executor-env.test.ts index 233c1e42..609be2b5 100644 --- a/tests/unit/copilot/copilot-executor-env.test.ts +++ b/tests/unit/copilot/copilot-executor-env.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'bun:test'; -import { generateCopilotEnv } from '../../../src/copilot/copilot-executor'; +import { + generateCopilotEnv, + resolveCopilotImageAnalysisEnv, +} from '../../../src/copilot/copilot-executor'; import type { CopilotConfig } from '../../../src/config/unified-config-types'; const baseConfig: CopilotConfig = { @@ -50,4 +53,94 @@ describe('generateCopilotEnv', () => { const env = generateCopilotEnv(baseConfig); expect(env.CLAUDE_CONFIG_DIR).toBeUndefined(); }); + + it('falls back to native read when copilot image analysis auth is missing', async () => { + const result = await resolveCopilotImageAnalysisEnv(false, { + getImageAnalysisHookEnv: () => ({ + CCS_CURRENT_PROVIDER: 'ghcp', + CCS_IMAGE_ANALYSIS_SKIP: '0', + }), + resolveImageAnalysisRuntimeStatus: async () => ({ + enabled: true, + supported: true, + status: 'active', + backendId: 'ghcp', + backendDisplayName: 'GitHub Copilot (OAuth)', + model: 'claude-haiku-4.5', + resolutionSource: 'copilot-alias', + reason: null, + shouldPersistHook: true, + persistencePath: 'copilot.settings.json', + runtimePath: '/api/provider/ghcp', + usesCurrentTarget: true, + usesCurrentAuthToken: true, + hookInstalled: true, + sharedHookInstalled: true, + authReadiness: 'missing', + authProvider: 'ghcp', + authDisplayName: 'GitHub Copilot (OAuth)', + authReason: + 'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.', + proxyReadiness: 'stopped', + proxyReason: + 'Local CLIProxy service is idle. CCS will start it automatically when image analysis is needed.', + effectiveRuntimeMode: 'native-read', + effectiveRuntimeReason: + 'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.', + }), + }); + + expect(result.env.CCS_CURRENT_PROVIDER).toBe(''); + expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('1'); + expect(result.warning).toContain('ccs ghcp --auth'); + }); + + it('starts local CLIProxy on demand when copilot image analysis is launchable', async () => { + let ensureCalls = 0; + const result = await resolveCopilotImageAnalysisEnv(false, { + getImageAnalysisHookEnv: () => ({ + CCS_CURRENT_PROVIDER: 'ghcp', + CCS_IMAGE_ANALYSIS_SKIP: '0', + }), + resolveImageAnalysisRuntimeStatus: async () => ({ + enabled: true, + supported: true, + status: 'active', + backendId: 'ghcp', + backendDisplayName: 'GitHub Copilot (OAuth)', + model: 'claude-haiku-4.5', + resolutionSource: 'copilot-alias', + reason: null, + shouldPersistHook: true, + persistencePath: 'copilot.settings.json', + runtimePath: '/api/provider/ghcp', + usesCurrentTarget: true, + usesCurrentAuthToken: true, + hookInstalled: true, + sharedHookInstalled: true, + authReadiness: 'ready', + authProvider: 'ghcp', + authDisplayName: 'GitHub Copilot (OAuth)', + authReason: null, + proxyReadiness: 'stopped', + proxyReason: + 'Local CLIProxy service is idle. CCS will start it automatically when image analysis is needed.', + effectiveRuntimeMode: 'cliproxy-image-analysis', + effectiveRuntimeReason: null, + }), + ensureCliproxyService: async () => { + ensureCalls += 1; + return { + started: true, + alreadyRunning: false, + port: 8317, + }; + }, + }); + + expect(ensureCalls).toBe(1); + expect(result.env.CCS_CURRENT_PROVIDER).toBe('ghcp'); + expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0'); + expect(result.warning).toBeNull(); + }); }); diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index a5095b5e..6c5b3cc9 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -159,12 +159,14 @@ describe('fetchModelsFromDaemon', () => { } }); - it('falls back to defaults when daemon response exceeds max body size', async () => { - const oversizedPayload = 'x'.repeat(1024 * 1024 + 1024); - const server = http.createServer((_req, res) => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(oversizedPayload); - }); + it( + 'falls back to defaults when daemon response exceeds max body size', + async () => { + const oversizedPayload = 'x'.repeat(1024 * 1024 + 1024); + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(oversizedPayload); + }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); @@ -172,13 +174,15 @@ describe('fetchModelsFromDaemon', () => { throw new Error('Unable to resolve test server port'); } - try { - const models = await fetchModelsFromDaemon(address.port); - expect(models).toEqual(DEFAULT_CURSOR_MODELS); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - } - }); + try { + const models = await fetchModelsFromDaemon(address.port); + expect(models).toEqual(DEFAULT_CURSOR_MODELS); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }, + 10000 + ); }); describe('fetchModelsFromCursorApi', () => { diff --git a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts new file mode 100644 index 00000000..84d1ffdb --- /dev/null +++ b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'bun:test'; +import { + DEFAULT_IMAGE_ANALYSIS_CONFIG, + type ImageAnalysisConfig, +} from '../../../../src/config/unified-config-types'; +import { + canonicalizeImageAnalysisConfig, + resolveImageAnalysisStatus, +} from '../../../../src/utils/hooks/image-analysis-backend-resolver'; + +describe('image-analysis-backend-resolver', () => { + it('canonicalizes provider aliases in config', () => { + const config = canonicalizeImageAnalysisConfig({ + enabled: true, + timeout: 60, + provider_models: { + copilot: 'claude-haiku-4.5', + gemini: 'gemini-2.5-flash', + }, + fallback_backend: 'Gemini', + profile_backends: { + orq: 'copilot', + }, + }); + + expect(config.provider_models.ghcp).toBe('claude-haiku-4.5'); + expect(config.provider_models.copilot).toBeUndefined(); + expect(config.fallback_backend).toBe('gemini'); + expect(config.profile_backends?.orq).toBe('ghcp'); + }); + + it('resolves copilot to the ghcp backend without a duplicate provider key', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'copilot', + profileType: 'copilot', + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.supported).toBe(true); + expect(status.backendId).toBe('ghcp'); + expect(status.model).toBe('claude-haiku-4.5'); + expect(status.resolutionSource).toBe('copilot-alias'); + }); + + it('uses the fallback backend for an unmapped third-party settings profile', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'glm', + profileType: 'settings', + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_AUTH_TOKEN: 'glm-test-key', + }, + }, + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.supported).toBe(true); + expect(status.backendId).toBe('gemini'); + expect(status.resolutionSource).toBe('fallback-backend'); + expect(status.model).toBe('gemini-3-flash-preview'); + }); + + it('keeps direct Anthropic settings profiles on native read unless explicitly mapped', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'claude-direct', + profileType: 'settings', + settings: { + env: { + ANTHROPIC_API_KEY: 'anthropic-test-key', + }, + }, + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.supported).toBe(false); + expect(status.backendId).toBeNull(); + expect(status.status).toBe('skipped'); + expect(status.shouldPersistHook).toBe(false); + expect(status.reason).toContain('native file access'); + }); + + it('uses explicit profile_backends overrides for custom aliases', () => { + const config: ImageAnalysisConfig = { + ...DEFAULT_IMAGE_ANALYSIS_CONFIG, + profile_backends: { + orq: 'copilot', + }, + }; + + const status = resolveImageAnalysisStatus( + { + profileName: 'orq', + profileType: 'settings', + }, + config + ); + + expect(status.supported).toBe(true); + expect(status.status).toBe('mapped'); + expect(status.backendId).toBe('ghcp'); + expect(status.resolutionSource).toBe('profile-backend'); + }); + + it('lets explicit profile_backends overrides win over cliproxy provider inference', () => { + const config: ImageAnalysisConfig = { + ...DEFAULT_IMAGE_ANALYSIS_CONFIG, + profile_backends: { + glmv: 'ghcp', + }, + }; + + const status = resolveImageAnalysisStatus( + { + profileName: 'glmv', + profileType: 'cliproxy', + cliproxyProvider: 'gemini', + }, + config + ); + + expect(status.supported).toBe(true); + expect(status.status).toBe('mapped'); + expect(status.backendId).toBe('ghcp'); + expect(status.resolutionSource).toBe('profile-backend'); + }); + + it('reports hook-missing when the profile should persist a hook but it is absent', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'glm', + profileType: 'settings', + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_AUTH_TOKEN: 'glm-test-key', + }, + }, + hookInstalled: false, + sharedHookInstalled: true, + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.status).toBe('hook-missing'); + expect(status.reason).toContain('Profile hook is missing'); + }); + + it('prefers native image reading when the profile settings opt into it', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'glmv', + profileType: 'settings', + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_MODEL: 'glm-4.5v', + ANTHROPIC_AUTH_TOKEN: 'glm-test-key', + }, + ccs_image: { + native_read: true, + }, + }, + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.backendId).toBeNull(); + expect(status.resolutionSource).toBe('native-compatible'); + expect(status.nativeReadPreference).toBe(true); + expect(status.profileModel).toBe('glm-4.5v'); + expect(status.nativeImageCapable).toBe(true); + expect(status.shouldPersistHook).toBe(false); + expect(status.effectiveRuntimeMode).toBe('native-read'); + }); +}); diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts new file mode 100644 index 00000000..dd099371 --- /dev/null +++ b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'bun:test'; +import { hydrateImageAnalysisRuntimeStatus } from '../../../../src/utils/hooks/image-analysis-runtime-status'; +import type { ImageAnalysisStatus } from '../../../../src/utils/hooks/image-analysis-backend-resolver'; + +function createStatus(overrides: Partial = {}): ImageAnalysisStatus { + return { + enabled: true, + supported: true, + status: 'active', + backendId: 'ghcp', + backendDisplayName: 'GitHub Copilot (OAuth)', + model: 'claude-haiku-4.5', + resolutionSource: 'profile-backend', + reason: null, + shouldPersistHook: true, + persistencePath: '/tmp/orq.settings.json', + runtimePath: '/api/provider/ghcp', + usesCurrentTarget: true, + usesCurrentAuthToken: true, + hookInstalled: true, + sharedHookInstalled: true, + authReadiness: 'unknown', + authProvider: 'ghcp', + authDisplayName: 'GitHub Copilot (OAuth)', + authReason: 'Auth readiness has not been verified yet.', + proxyReadiness: 'unknown', + proxyReason: 'CLIProxy runtime readiness has not been verified yet.', + effectiveRuntimeMode: 'native-read', + effectiveRuntimeReason: null, + profileModel: 'claude-haiku-4.5', + nativeReadPreference: false, + nativeImageCapable: true, + nativeImageReason: 'claude-haiku-4.5 can read images natively.', + ...overrides, + }; +} + +describe('image-analysis-runtime-status', () => { + it('falls back to native read when provider auth is missing', async () => { + const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + getProxyTarget: () => ({ + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, + }), + initializeAccounts: () => {}, + getAuthStatus: () => ({ + provider: 'ghcp', + authenticated: false, + tokenDir: '/tmp/auth', + tokenFiles: [], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + }); + + expect(status.authReadiness).toBe('missing'); + expect(status.effectiveRuntimeMode).toBe('native-read'); + expect(status.effectiveRuntimeReason).toContain('ccs ghcp --auth'); + }); + + it('marks an idle local proxy as launchable when auth is ready', async () => { + const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + getProxyTarget: () => ({ + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, + }), + initializeAccounts: () => {}, + getAuthStatus: () => ({ + provider: 'ghcp', + authenticated: true, + tokenDir: '/tmp/auth', + tokenFiles: ['github-copilot-test.json'], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => false, + }); + + expect(status.authReadiness).toBe('ready'); + expect(status.proxyReadiness).toBe('stopped'); + expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); + }); + + it('treats an unreachable remote proxy as unavailable', async () => { + const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + getProxyTarget: () => ({ + host: 'remote.example', + port: 443, + protocol: 'https', + authToken: 'token', + managementKey: 'secret', + isRemote: true, + }), + fetchRemoteAuthStatus: async () => [ + { + provider: 'ghcp', + displayName: 'GitHub Copilot (OAuth)', + authenticated: true, + tokenFiles: 1, + accounts: [], + defaultAccount: null, + source: 'remote', + }, + ], + isCliproxyRunning: async () => false, + }); + + expect(status.authReadiness).toBe('ready'); + expect(status.proxyReadiness).toBe('unavailable'); + expect(status.effectiveRuntimeMode).toBe('native-read'); + expect(status.effectiveRuntimeReason).toContain('remote.example:443'); + }); + + it('keeps hook-missing on native read even when auth and proxy are ready', async () => { + const status = await hydrateImageAnalysisRuntimeStatus( + createStatus({ + status: 'hook-missing', + reason: 'Profile hook is missing from the persisted settings file.', + }), + { + getProxyTarget: () => ({ + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, + }), + initializeAccounts: () => {}, + getAuthStatus: () => ({ + provider: 'ghcp', + authenticated: true, + tokenDir: '/tmp/auth', + tokenFiles: ['github-copilot-test.json'], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + } + ); + + expect(status.authReadiness).toBe('ready'); + expect(status.proxyReadiness).toBe('ready'); + expect(status.effectiveRuntimeMode).toBe('native-read'); + expect(status.effectiveRuntimeReason).toContain('Profile hook is missing'); + }); +}); diff --git a/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts b/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts new file mode 100644 index 00000000..96c07f32 --- /dev/null +++ b/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + ensureProfileHooks, + getImageAnalysisProfileSettingsPath, + hasImageAnalysisProfileHook, +} from '../../../../src/utils/hooks/image-analyzer-profile-hook-injector'; + +function writeJson(filePath: string, value: Record): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8'); +} + +describe('image-analyzer-profile-hook-injector', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analyzer-profile-hook-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('persists dotted settings profile hooks into the resolved custom settings path', () => { + const customSettingsPath = path.join(tempHome, 'profiles', 'foo.bar.settings.json'); + writeJson(customSettingsPath, { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }, + }); + + const ensured = ensureProfileHooks({ + profileName: 'foo.bar', + profileType: 'settings', + settingsPath: customSettingsPath, + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }, + }, + }); + + const defaultSettingsPath = path.join(tempHome, '.ccs', 'foo.bar.settings.json'); + const persisted = JSON.parse(fs.readFileSync(customSettingsPath, 'utf8')) as { + hooks?: { PreToolUse?: Array<{ matcher?: string }> }; + }; + + expect(ensured).toBe(true); + expect(getImageAnalysisProfileSettingsPath('foo.bar', customSettingsPath)).toBe( + customSettingsPath + ); + expect(hasImageAnalysisProfileHook('foo.bar', customSettingsPath)).toBe(true); + expect(fs.existsSync(defaultSettingsPath)).toBe(false); + expect(persisted.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe(true); + }); +}); diff --git a/tests/unit/web-server/image-analysis-routes.test.ts b/tests/unit/web-server/image-analysis-routes.test.ts new file mode 100644 index 00000000..557a9916 --- /dev/null +++ b/tests/unit/web-server/image-analysis-routes.test.ts @@ -0,0 +1,244 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; +import imageAnalysisRoutes from '../../../src/web-server/routes/image-analysis-routes'; + +describe('image-analysis routes', () => { + let server: Server; + let baseUrl = ''; + let tempHome: string; + let originalCcsHome: string | undefined; + let originalDashboardAuthEnabled: string | undefined; + let forcedRemoteAddress = '127.0.0.1'; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + Object.defineProperty(req.socket, 'remoteAddress', { + value: forcedRemoteAddress, + configurable: true, + }); + next(); + }); + app.use('/api/image-analysis', imageAnalysisRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analysis-routes-test-')); + originalCcsHome = process.env.CCS_HOME; + originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + process.env.CCS_HOME = tempHome; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + forcedRemoteAddress = '127.0.0.1'; + + const glmSettingsPath = path.join(tempHome, 'glm.settings.json'); + const codexSettingsPath = path.join(tempHome, 'codex-profile.settings.json'); + + fs.writeFileSync( + glmSettingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/gemini', + ANTHROPIC_AUTH_TOKEN: 'glm-token', + }, + }, + null, + 2 + ) + ); + fs.writeFileSync( + codexSettingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/ghcp', + ANTHROPIC_AUTH_TOKEN: 'codex-token', + }, + }, + null, + 2 + ) + ); + + mutateUnifiedConfig((config) => { + config.profiles.glm = { + settings: glmSettingsPath, + target: 'claude', + }; + config.profiles.codexProfile = { + settings: codexSettingsPath, + target: 'droid', + }; + config.image_analysis = { + enabled: true, + timeout: 60, + provider_models: { + gemini: 'gemini-3-flash-preview', + ghcp: 'claude-haiku-4.5', + }, + fallback_backend: 'gemini', + profile_backends: { + codexProfile: 'ghcp', + }, + }; + }); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalDashboardAuthEnabled !== undefined) { + process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; + } else { + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('blocks remote access when dashboard auth is disabled', async () => { + forcedRemoteAddress = '10.10.0.24'; + + const response = await fetch(`${baseUrl}/api/image-analysis`); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'Image Analysis endpoints require localhost access when dashboard auth is disabled.', + }); + }); + + it('returns global settings, backend readiness, and profile coverage', async () => { + const response = await fetch(`${baseUrl}/api/image-analysis`); + expect(response.status).toBe(200); + const payload = await response.json(); + + expect(payload.config).toMatchObject({ + enabled: true, + timeout: 60, + fallbackBackend: 'gemini', + profileBackends: { + codexProfile: 'ghcp', + }, + }); + expect(payload.catalog.knownBackends).toContain('gemini'); + expect(payload.backends).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + backendId: 'gemini', + model: 'gemini-3-flash-preview', + }), + expect.objectContaining({ + backendId: 'ghcp', + profilesUsing: 1, + }), + ]) + ); + expect(payload.profiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'glm', + target: 'claude', + currentTargetMode: 'setup', + }), + expect.objectContaining({ + name: 'codexProfile', + target: 'droid', + backendId: 'ghcp', + currentTargetMode: 'bypassed', + }), + ]) + ); + expect(payload.summary).toMatchObject({ + backendCount: 2, + bypassedProfileCount: 1, + }); + }); + + it('updates the saved config through the dashboard route', async () => { + const response = await fetch(`${baseUrl}/api/image-analysis`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: true, + timeout: 120, + providerModels: { + gemini: 'gemini-2.5-pro', + ghcp: 'claude-haiku-4.5', + }, + fallbackBackend: 'ghcp', + profileBackends: { + glm: 'gemini', + codexProfile: 'ghcp', + }, + }), + }); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.config).toMatchObject({ + timeout: 120, + fallbackBackend: 'ghcp', + profileBackends: { + glm: 'gemini', + codexProfile: 'ghcp', + }, + }); + expect(payload.config.providerModels).toMatchObject({ + gemini: 'gemini-2.5-pro', + }); + }); + + it('rejects profile mappings that point to a missing backend with a client error', async () => { + const response = await fetch(`${baseUrl}/api/image-analysis`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providerModels: { + gemini: 'gemini-3-flash-preview', + }, + fallbackBackend: 'gemini', + profileBackends: { + codexProfile: 'ghcp', + }, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Profile mapping for "codexProfile" references an unknown backend.', + }); + }); +}); diff --git a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts new file mode 100644 index 00000000..bb375874 --- /dev/null +++ b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts @@ -0,0 +1,292 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import settingsRoutes from '../../../src/web-server/routes/settings-routes'; + +function writeJson(filePath: string, value: Record): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n'); +} + +function installSharedHook(tempHome: string): string { + const hookPath = path.join(tempHome, '.ccs', 'hooks', 'image-analyzer-transformer.cjs'); + fs.mkdirSync(path.dirname(hookPath), { recursive: true }); + fs.writeFileSync(hookPath, '#!/usr/bin/env node\n', 'utf8'); + return hookPath; +} + +function writeProfileSettings( + tempHome: string, + profileName: string, + env: Record, + settingsPath = path.join(tempHome, '.ccs', `${profileName}.settings.json`) +): string { + const hookPath = installSharedHook(tempHome); + writeJson(settingsPath, { + env, + hooks: { + PreToolUse: [ + { + matcher: 'Read', + hooks: [{ type: 'command', command: `node "${hookPath}"`, timeout: 65000 }], + }, + ], + }, + }); + return settingsPath; +} + +describe('settings-routes image-analysis status', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/settings', settingsRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-status-routes-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('returns fallback-backed image analysis status for settings profiles', async () => { + writeProfileSettings(tempHome, 'glm', { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }); + + const response = await fetch(`${baseUrl}/api/settings/glm/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + imageAnalysisStatus: { + status: string; + backendId: string | null; + resolutionSource: string; + model: string | null; + persistencePath: string | null; + authReadiness: string; + effectiveRuntimeMode: string; + }; + }; + + expect(body.imageAnalysisStatus.status).toBe('active'); + expect(body.imageAnalysisStatus.backendId).toBe('gemini'); + expect(body.imageAnalysisStatus.resolutionSource).toBe('fallback-backend'); + expect(body.imageAnalysisStatus.model).toBe('gemini-3-flash-preview'); + expect(body.imageAnalysisStatus.persistencePath).toContain('glm.settings.json'); + expect(body.imageAnalysisStatus.authReadiness).toBe('missing'); + expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read'); + }); + + it('keeps direct Anthropic settings profiles on native read diagnostics', async () => { + writeJson(path.join(tempHome, '.ccs', 'claude-direct.settings.json'), { + env: { + ANTHROPIC_API_KEY: 'anthropic-test-key', + }, + }); + + const response = await fetch(`${baseUrl}/api/settings/claude-direct/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + imageAnalysisStatus: { + status: string; + backendId: string | null; + shouldPersistHook: boolean; + runtimePath: string | null; + reason: string | null; + authReadiness: string; + proxyReadiness: string; + }; + }; + + expect(body.imageAnalysisStatus.status).toBe('skipped'); + expect(body.imageAnalysisStatus.backendId).toBeNull(); + expect(body.imageAnalysisStatus.shouldPersistHook).toBe(false); + expect(body.imageAnalysisStatus.runtimePath).toBeNull(); + expect(body.imageAnalysisStatus.reason).toContain('native file access'); + expect(body.imageAnalysisStatus.authReadiness).toBe('not-needed'); + expect(body.imageAnalysisStatus.proxyReadiness).toBe('not-needed'); + }); + + it('returns explicit mapped status for custom aliases', async () => { + writeJson(path.join(tempHome, '.ccs', 'config.yaml'), { + version: 11, + image_analysis: { + enabled: true, + timeout: 60, + provider_models: { + gemini: 'gemini-2.5-flash', + ghcp: 'claude-haiku-4.5', + }, + profile_backends: { + orq: 'copilot', + }, + }, + }); + writeProfileSettings(tempHome, 'orq', { + ANTHROPIC_BASE_URL: 'https://openrouter.ai/api/v1', + ANTHROPIC_API_KEY: 'orq-test-key', + }); + + const response = await fetch(`${baseUrl}/api/settings/orq/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + imageAnalysisStatus: { + status: string; + backendId: string | null; + resolutionSource: string; + model: string | null; + authReadiness: string; + effectiveRuntimeMode: string; + }; + }; + + expect(body.imageAnalysisStatus.status).toBe('mapped'); + expect(body.imageAnalysisStatus.backendId).toBe('ghcp'); + expect(body.imageAnalysisStatus.resolutionSource).toBe('profile-backend'); + expect(body.imageAnalysisStatus.model).toBe('claude-haiku-4.5'); + expect(body.imageAnalysisStatus.authReadiness).toBe('missing'); + expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read'); + }); + + it('uses the configured custom settings path for status and persistence diagnostics', async () => { + const customSettingsPath = path.join(tempHome, 'profiles', 'foo.bar.settings.json'); + writeJson(path.join(tempHome, '.ccs', 'config.json'), { + profiles: { + 'foo.bar': customSettingsPath, + }, + }); + writeProfileSettings( + tempHome, + 'foo.bar', + { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }, + customSettingsPath + ); + + const response = await fetch(`${baseUrl}/api/settings/foo.bar/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + path: string; + imageAnalysisStatus: { + persistencePath: string | null; + hookInstalled: boolean | null; + }; + }; + + expect(body.path).toBe(customSettingsPath); + expect(body.imageAnalysisStatus.persistencePath).toBe(customSettingsPath); + expect(body.imageAnalysisStatus.hookInstalled).toBe(true); + }); + + it('previews image-analysis status from unsaved editor settings', async () => { + writeProfileSettings(tempHome, 'glm', { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }); + + const response = await fetch(`${baseUrl}/api/settings/glm/image-analysis-status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/ghcp', + ANTHROPIC_AUTH_TOKEN: 'preview-token', + }, + }, + }), + }); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + imageAnalysisStatus: { + backendId: string | null; + resolutionSource: string; + authReadiness: string; + }; + }; + + expect(body.imageAnalysisStatus.backendId).toBe('ghcp'); + expect(body.imageAnalysisStatus.resolutionSource).toBe('cliproxy-bridge'); + expect(body.imageAnalysisStatus.authReadiness).toBe('missing'); + }); + + it('respects per-profile native image preference stored in settings json', async () => { + writeJson(path.join(tempHome, '.ccs', 'glmv.settings.json'), { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_MODEL: 'glm-4.5v', + ANTHROPIC_AUTH_TOKEN: 'glmv-test-key', + }, + ccs_image: { + native_read: true, + }, + }); + + const response = await fetch(`${baseUrl}/api/settings/glmv/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + imageAnalysisStatus: { + backendId: string | null; + resolutionSource: string; + profileModel: string | null; + nativeReadPreference: boolean; + nativeImageCapable: boolean | null; + effectiveRuntimeMode: string; + }; + }; + + expect(body.imageAnalysisStatus.backendId).toBeNull(); + expect(body.imageAnalysisStatus.resolutionSource).toBe('native-compatible'); + expect(body.imageAnalysisStatus.profileModel).toBe('glm-4.5v'); + expect(body.imageAnalysisStatus.nativeReadPreference).toBe(true); + expect(body.imageAnalysisStatus.nativeImageCapable).toBe(true); + expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read'); + }); +}); diff --git a/tests/unit/web-server/start-server-host.test.ts b/tests/unit/web-server/start-server-host.test.ts index 7c1204c6..e0a48464 100644 --- a/tests/unit/web-server/start-server-host.test.ts +++ b/tests/unit/web-server/start-server-host.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'bun:test'; +import { afterEach, describe, expect, it, mock } from 'bun:test'; import type { AddressInfo } from 'net'; import { startServer } from '../../../src/web-server'; @@ -15,6 +15,8 @@ afterEach(async () => { instance.cleanup(); await new Promise((resolve) => instance.server.close(() => resolve())); } + + mock.restore(); }); describe('startServer host binding', () => { @@ -41,4 +43,27 @@ describe('startServer host binding', () => { const address = instance.server.address() as AddressInfo; expect(['0.0.0.0', '::']).toContain(address.address); }); + + it('attaches Vite HMR to the existing HTTP server in dev mode', async () => { + let viteConfig: Record | undefined; + + mock.module('vite', () => ({ + createServer: async (config: Record) => { + viteConfig = config; + return { + middlewares: (_req: unknown, _res: unknown, next: () => void) => next(), + }; + }, + })); + + const instance = await startServer({ port: 0, dev: true }); + instances.push(instance); + + expect(viteConfig).toBeDefined(); + const serverConfig = viteConfig?.server as + | { middlewareMode?: boolean; hmr?: { server?: unknown } } + | undefined; + expect(serverConfig?.middlewareMode).toBe(true); + expect(serverConfig?.hmr?.server).toBe(instance.server); + }); }); diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index 4af6f2bd..69f9a0e9 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -30,7 +30,7 @@ const singleProviderSchema = z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), model: z.string().optional(), account: z.string().optional(), - target: z.enum(['claude', 'droid']), + target: z.enum(['claude', 'droid', 'codex']), }); const compositeSchema = z.object({ @@ -39,7 +39,7 @@ const compositeSchema = z.object({ .min(1, 'Name is required') .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'), default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }), - target: z.enum(['claude', 'droid']), + target: z.enum(['claude', 'droid', 'codex']), tiers: z.object({ opus: z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), @@ -249,6 +249,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { > + @@ -353,6 +354,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { > + diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index 07d3b430..32c0e229 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -23,12 +23,12 @@ const singleProviderSchema = z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), model: z.string().optional(), account: z.string().optional(), - target: z.enum(['claude', 'droid']), + target: z.enum(['claude', 'droid', 'codex']), }); const compositeSchema = z.object({ default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }), - target: z.enum(['claude', 'droid']), + target: z.enum(['claude', 'droid', 'codex']), tiers: z.object({ opus: z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), @@ -375,6 +375,7 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit > + @@ -433,6 +434,7 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit > + diff --git a/ui/src/components/profiles/editor/header-section.tsx b/ui/src/components/profiles/editor/header-section.tsx index 637a93f3..0ed87e02 100644 --- a/ui/src/components/profiles/editor/header-section.tsx +++ b/ui/src/components/profiles/editor/header-section.tsx @@ -85,6 +85,7 @@ export function HeaderSection({ Claude Code Factory Droid + Codex CLI {isTargetSaving && } 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..c1100487 --- /dev/null +++ b/ui/src/components/profiles/editor/image-analysis-status-section.tsx @@ -0,0 +1,221 @@ +import { ArrowUpRight, Image as ImageIcon } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { cn } from '@/lib/utils'; +import type { CliTarget, ImageAnalysisStatus } from '@/lib/api-client'; + +interface ImageAnalysisStatusSectionProps { + status?: ImageAnalysisStatus | null; + target?: CliTarget; + source?: 'saved' | 'editor'; + previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid'; + nativeReadPreferenceOverride?: boolean; + onToggleNativeRead?: (enabled: boolean) => void; +} + +const TARGET_LABELS: Record = { + claude: 'Claude Code', + droid: 'Factory Droid', + codex: 'Codex CLI', +}; + +function getPreviewLabel( + source: 'saved' | 'editor', + previewState: ImageAnalysisStatusSectionProps['previewState'] +) { + if (previewState === 'refreshing') return 'Refreshing preview'; + if (previewState === 'invalid') return 'Saved status'; + return source === 'editor' ? 'Live preview' : 'Saved status'; +} + +function getHeaderLabel(status: ImageAnalysisStatus, target: CliTarget): string { + if (status.status === 'disabled') return 'Disabled globally'; + if (target !== 'claude') return `${TARGET_LABELS[target]} bypasses the hook`; + if (status.nativeReadPreference) return 'Native image reading'; + if (status.status === 'hook-missing') return 'Setup needed'; + if (status.authReadiness === 'missing') return 'Needs auth'; + if (status.proxyReadiness === 'unavailable') return 'Needs proxy'; + if (status.effectiveRuntimeMode === 'native-read') return 'Native fallback'; + return 'Transformer ready'; +} + +function getHeaderBadge( + status: ImageAnalysisStatus, + target: CliTarget +): { + label: string; + className: string; +} { + if (status.status === 'disabled') { + return { + label: 'Disabled', + className: 'border-border/80 bg-background/85 text-muted-foreground', + }; + } + if (target !== 'claude') { + return { + label: 'Bypassed', + className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200', + }; + } + if (status.nativeReadPreference) { + return { + label: 'Native', + className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200', + }; + } + if (status.status === 'hook-missing' || status.authReadiness === 'missing') { + return { + label: status.status === 'hook-missing' ? 'Setup' : 'Auth', + className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200', + }; + } + if (status.proxyReadiness === 'unavailable') { + return { + label: 'Proxy', + className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200', + }; + } + return { + label: 'Ready', + className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200', + }; +} + +function getToggleSummary(status: ImageAnalysisStatus, target: CliTarget): string { + if (status.nativeReadPreference) { + if (status.profileModel && status.nativeImageCapable) { + return `${status.profileModel} looks image-ready. CCS will bypass the transformer here.`; + } + if (status.profileModel) { + return `CCS will prefer native reading for ${status.profileModel}.`; + } + return 'CCS will prefer native image reading for this profile.'; + } + + if (!status.backendDisplayName && target === 'claude') { + return 'This profile currently stays on native file access.'; + } + + if (!status.backendDisplayName) { + return `Saved Claude-side image routing is inactive while ${TARGET_LABELS[target]} is selected.`; + } + + const modelSuffix = status.model ? ` · ${status.model}` : ''; + return `Transformer route: ${status.backendDisplayName}${modelSuffix}.`; +} + +function getExceptionalNote(status: ImageAnalysisStatus, target: CliTarget): string | null { + if (status.status === 'disabled') { + return 'Image is disabled globally in CCS settings.'; + } + if (target !== 'claude') { + return `Current target ${TARGET_LABELS[target]} bypasses the Claude Read hook.`; + } + if (status.nativeReadPreference) { + return status.nativeImageCapable === true ? null : status.nativeImageReason; + } + if (status.status === 'hook-missing') { + return 'Persist the profile hook before transformer routing can run here.'; + } + if (status.authReadiness === 'missing') { + return status.authReason; + } + if (status.proxyReadiness === 'unavailable') { + return status.proxyReason; + } + return null; +} + +export function ImageAnalysisStatusSection({ + status, + target = 'claude', + source = 'saved', + previewState = 'saved', + nativeReadPreferenceOverride, + onToggleNativeRead, +}: ImageAnalysisStatusSectionProps) { + if (!status) { + return ( +

+
+
+
+ ); + } + + const nativeReadChecked = nativeReadPreferenceOverride ?? status.nativeReadPreference; + const effectiveStatus = { ...status, nativeReadPreference: nativeReadChecked }; + const headerBadge = getHeaderBadge(effectiveStatus, target); + const note = getExceptionalNote(effectiveStatus, target); + const capabilityLabel = status.nativeImageCapable + ? 'Verified' + : status.profileModel + ? 'Unknown' + : null; + + return ( +
+
+
+
+
+ +
+
+
+

Image

+ + {headerBadge.label} + +
+

+ {getPreviewLabel(source, previewState)} · {getHeaderLabel(effectiveStatus, target)} +

+
+
+
+ + +
+ +
+
+
+
+
Use native image reading
+ {capabilityLabel && ( + + {capabilityLabel} + + )} +
+

+ {getToggleSummary(effectiveStatus, target)} +

+
+ + +
+
+ + {note && ( +
+ {note} +
+ )} +
+ ); +} diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx index f9709845..a7daa66a 100644 --- a/ui/src/components/profiles/editor/index.tsx +++ b/ui/src/components/profiles/editor/index.tsx @@ -4,7 +4,7 @@ */ /* eslint-disable react-refresh/only-export-components */ -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect, useDeferredValue } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/shared/confirm-dialog'; @@ -67,6 +67,31 @@ export function ProfileEditor({ setRawJsonEdits(value); }, []); + const updateNativeImageRead = useCallback( + (enabled: boolean) => { + const nextSettings = { ...(currentSettings ?? {}) } as Settings; + const currentCcsImage = + nextSettings.ccs_image && typeof nextSettings.ccs_image === 'object' + ? { ...nextSettings.ccs_image } + : {}; + + if (enabled) { + currentCcsImage.native_read = true; + } else { + delete currentCcsImage.native_read; + } + + if (Object.keys(currentCcsImage).length > 0) { + nextSettings.ccs_image = currentCcsImage; + } else { + delete nextSettings.ccs_image; + } + + setRawJsonEdits(JSON.stringify(nextSettings, null, 2)); + }, + [currentSettings] + ); + // Sync Visual Editor changes to Raw JSON const updateEnvValue = (key: string, value: string) => { const newEnv = { ...(currentSettings?.env || {}), [key]: value }; @@ -107,6 +132,66 @@ export function ProfileEditor({ return Object.keys(localEdits).length > 0; }, [rawJsonEdits, localEdits, settings]); + const deferredPreviewJson = useDeferredValue(computedRawJsonContent); + const previewSettings = useMemo((): Settings | null => { + if (!computedHasChanges || !computedIsRawJsonValid) { + return null; + } + + try { + return JSON.parse(deferredPreviewJson) as Settings; + } catch { + return null; + } + }, [computedHasChanges, computedIsRawJsonValid, deferredPreviewJson]); + + const { + data: previewStatusResponse, + isFetching: isPreviewStatusFetching, + isError: isPreviewStatusError, + isPlaceholderData: isPreviewStatusPlaceholderData, + } = useQuery<{ imageAnalysisStatus: SettingsResponse['imageAnalysisStatus'] }>({ + queryKey: ['settings', profileName, 'image-analysis-status-preview', deferredPreviewJson], + enabled: previewSettings !== null, + placeholderData: (previousData) => previousData, + queryFn: async () => { + const res = await fetch(`/api/settings/${profileName}/image-analysis-status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: previewSettings }), + }); + + if (!res.ok) { + throw new Error(`Failed to preview image-analysis status: ${res.status}`); + } + + return res.json(); + }, + }); + + const imageAnalysisStatus = + computedHasChanges && computedIsRawJsonValid && !isPreviewStatusError + ? (previewStatusResponse?.imageAnalysisStatus ?? data?.imageAnalysisStatus) + : data?.imageAnalysisStatus; + const imageAnalysisStatusSource = + computedHasChanges && + computedIsRawJsonValid && + !isPreviewStatusError && + previewStatusResponse?.imageAnalysisStatus + ? 'editor' + : 'saved'; + const imageAnalysisStatusPreviewState = !computedHasChanges + ? 'saved' + : !computedIsRawJsonValid + ? 'invalid' + : isPreviewStatusError + ? 'saved' + : isPreviewStatusFetching && + (!previewStatusResponse?.imageAnalysisStatus || isPreviewStatusPlaceholderData) + ? 'refreshing' + : 'preview'; + const nativeReadPreferenceOverride = currentSettings?.ccs_image?.native_read === true; + // Check for missing required fields (informational warning) const missingRequiredFields = useMemo(() => { const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; @@ -162,7 +247,8 @@ export function ProfileEditor({ toast.success(i18n.t('commonToast.defaultTargetUpdated')); }, onError: (error: Error, target: CliTarget) => { - const targetLabel = target === 'droid' ? 'Factory Droid' : 'Claude Code'; + const targetLabel = + target === 'droid' ? 'Factory Droid' : target === 'codex' ? 'Codex CLI' : 'Claude Code'; const suffix = error.message.trim() ? `: ${error.message}` : ''; toast.error(i18n.t('commonToast.failedUpdateDefaultTarget', { target: targetLabel, suffix })); }, @@ -254,6 +340,12 @@ export function ProfileEditor({ isRawJsonValid={computedIsRawJsonValid} rawJsonEdits={rawJsonEdits} settings={settings} + profileTarget={resolvedTarget} + imageAnalysisStatus={imageAnalysisStatus} + imageAnalysisStatusSource={imageAnalysisStatusSource} + imageAnalysisStatusPreviewState={imageAnalysisStatusPreviewState} + nativeReadPreferenceOverride={nativeReadPreferenceOverride} + onToggleNativeRead={updateNativeImageRead} 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..924e187b 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 { CliTarget, ImageAnalysisStatus } from '@/lib/api-client'; // Lazy load CodeEditor const CodeEditor = lazy(() => @@ -18,6 +20,12 @@ interface RawEditorSectionProps { isRawJsonValid: boolean; rawJsonEdits: string | null; settings: Settings | undefined; + profileTarget?: CliTarget; + imageAnalysisStatus?: ImageAnalysisStatus | null; + imageAnalysisStatusSource?: 'saved' | 'editor'; + imageAnalysisStatusPreviewState?: 'saved' | 'preview' | 'refreshing' | 'invalid'; + nativeReadPreferenceOverride?: boolean; + onToggleNativeRead?: (enabled: boolean) => void; onChange: (value: string) => void; missingRequiredFields?: string[]; } @@ -27,6 +35,12 @@ export function RawEditorSection({ isRawJsonValid, rawJsonEdits, settings, + profileTarget = 'claude', + imageAnalysisStatus, + imageAnalysisStatusSource = 'saved', + imageAnalysisStatusPreviewState = 'saved', + nativeReadPreferenceOverride, + onToggleNativeRead, onChange, missingRequiredFields = [], }: RawEditorSectionProps) { @@ -75,6 +89,16 @@ 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..38453fa8 100644 --- a/ui/src/components/profiles/editor/types.ts +++ b/ui/src/components/profiles/editor/types.ts @@ -2,10 +2,13 @@ * 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; + ccs_image?: { + native_read?: boolean; + }; } export interface SettingsResponse { @@ -14,6 +17,7 @@ export interface SettingsResponse { mtime: number; path: string; cliproxyBridge?: CliproxyBridgeMetadata | null; + imageAnalysisStatus?: ImageAnalysisStatus | null; } export interface ProfileEditorProps { diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index ca256fe2..cdf8b449 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -80,7 +80,7 @@ const schema = z.object({ opusModel: z.string().optional(), sonnetModel: z.string().optional(), haikuModel: z.string().optional(), - target: z.enum(['claude', 'droid']), + target: z.enum(['claude', 'droid', 'codex']), }); type FormData = z.infer; @@ -521,6 +521,7 @@ export function ProfileCreateDialog({ Claude Code (default) Factory Droid + Codex CLI

@@ -529,6 +530,11 @@ export function ProfileCreateDialog({ {t('profileEditor.targetHintPreferredAlias')}{' '} ccs-droid. + ) : targetValue === 'codex' ? ( + <> + {t('profileEditor.targetHintPreferredAlias')}{' '} + ccsx. + ) : ( <> {t('profileEditor.targetHintClaudeDefault')}{' '} @@ -541,6 +547,12 @@ export function ProfileCreateDialog({ {t('profileEditor.targetHintLegacyAlias')}{' '} ccsd. + ) : targetValue === 'codex' ? ( + <> + {' '} + {t('profileEditor.targetHintLegacyAlias')}{' '} + ccs-codex. + ) : null}{' '} {t('profileEditor.targetHintOverride')}{' '} --target. diff --git a/ui/src/hooks/use-websocket.ts b/ui/src/hooks/use-websocket.ts index 81164214..aab8a6e1 100644 --- a/ui/src/hooks/use-websocket.ts +++ b/ui/src/hooks/use-websocket.ts @@ -71,7 +71,7 @@ export function useWebSocket() { setStatus('connecting'); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${protocol}//${window.location.host}`); + const ws = new WebSocket(`${protocol}//${window.location.host}/ws`); wsRef.current = ws; ws.onopen = () => { diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 749c63f3..b092142a 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -99,7 +99,7 @@ async function request(url: string, options?: RequestInit): Promise { } // Types -export type CliTarget = 'claude' | 'droid'; +export type CliTarget = 'claude' | 'droid' | 'codex'; export interface CliproxyBridgeMetadata { provider: CLIProxyProvider; @@ -111,6 +111,126 @@ 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' + | 'native-compatible' + | '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; + authReadiness: 'not-needed' | 'ready' | 'missing' | 'unknown'; + authProvider: string | null; + authDisplayName: string | null; + authReason: string | null; + proxyReadiness: 'not-needed' | 'ready' | 'remote' | 'stopped' | 'unavailable' | 'unknown'; + proxyReason: string | null; + effectiveRuntimeMode: 'cliproxy-image-analysis' | 'native-read'; + effectiveRuntimeReason: string | null; + profileModel: string | null; + nativeReadPreference: boolean; + nativeImageCapable: boolean | null; + nativeImageReason: string | null; +} + +export interface ImageAnalysisSettingsConfig { + enabled: boolean; + timeout: number; + providerModels: Record; + fallbackBackend: string | null; + profileBackends: Record; +} + +export interface ImageAnalysisDashboardSummary { + state: 'ready' | 'partial' | 'needs_setup' | 'disabled'; + title: string; + detail: string; + backendCount: number; + mappedProfileCount: number; + activeProfileCount: number; + bypassedProfileCount: number; + nativeProfileCount: number; +} + +export interface ImageAnalysisDashboardBackend { + backendId: string; + displayName: string; + model: string; + state: 'ready' | 'starts_on_launch' | 'needs_auth' | 'needs_proxy' | 'review'; + authReadiness: ImageAnalysisStatus['authReadiness']; + authReason: string | null; + proxyReadiness: ImageAnalysisStatus['proxyReadiness']; + proxyReason: string | null; + profilesUsing: number; +} + +export interface ImageAnalysisDashboardProfile { + name: string; + kind: 'profile' | 'variant'; + target: CliTarget; + configured: boolean; + settingsPath: string | null; + backendId: string | null; + backendDisplayName: string | null; + resolutionSource: ImageAnalysisStatus['resolutionSource']; + status: ImageAnalysisStatus['status']; + effectiveRuntimeMode: ImageAnalysisStatus['effectiveRuntimeMode']; + effectiveRuntimeReason: string | null; + currentTargetMode: + | 'active' + | 'bypassed' + | 'fallback' + | 'setup' + | 'disabled' + | 'native' + | 'unresolved'; + profileModel: string | null; + nativeReadPreference: boolean; + nativeImageCapable: boolean | null; + nativeImageReason: string | null; +} + +export interface ImageAnalysisDashboardCatalog { + knownBackends: string[]; + profileNames: string[]; +} + +export interface ImageAnalysisDashboardData { + config: ImageAnalysisSettingsConfig; + summary: ImageAnalysisDashboardSummary; + backends: ImageAnalysisDashboardBackend[]; + profiles: ImageAnalysisDashboardProfile[]; + catalog: ImageAnalysisDashboardCatalog; +} + +export interface UpdateImageAnalysisSettingsPayload { + enabled?: boolean; + timeout?: number; + providerModels?: Record; + fallbackBackend?: string | null; + profileBackends?: Record; +} + export interface Profile { name: string; settingsPath: string; @@ -806,6 +926,14 @@ export const api = { body: JSON.stringify(data), }), }, + imageAnalysis: { + get: () => request('/image-analysis'), + update: (data: UpdateImageAnalysisSettingsPayload) => + request('/image-analysis', { + method: 'PUT', + body: JSON.stringify(data), + }), + }, cliproxy: { list: () => request<{ variants: Variant[] }>('/cliproxy'), getAuthStatus: () => diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index b8a3484e..d1df019a 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -150,19 +150,19 @@ export const MODEL_CATALOGS: Record = { default: 'gemini-3.1-pro-preview', opus: 'gemini-3.1-pro-preview', sonnet: 'gemini-3.1-pro-preview', - haiku: 'gemini-3-flash-preview', + haiku: 'gemini-3-1-flash-preview', }, }, { - id: 'gemini-3-flash-preview', + id: 'gemini-3-1-flash-preview', name: 'Gemini Flash', description: 'Resolves to the best advertised Gemini Flash preview via Antigravity', extendedContext: true, presetMapping: { - default: 'gemini-3-flash-preview', + default: 'gemini-3-1-flash-preview', opus: 'gemini-3.1-pro-preview', sonnet: 'gemini-3.1-pro-preview', - haiku: 'gemini-3-flash-preview', + haiku: 'gemini-3-1-flash-preview', }, }, ], diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts index 33c48e2d..8d3cbd61 100644 --- a/ui/src/lib/support-updates-catalog.ts +++ b/ui/src/lib/support-updates-catalog.ts @@ -70,7 +70,7 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ 'Use ccs-codex or ccsx for native Codex runs.', 'Use ccsxp for the built-in CCS Codex provider shortcut on native Codex.', 'Built-in Codex and Codex bridge profiles can run on native Codex with --target codex.', - 'Saved default targets for API profiles and variants remain claude or droid.', + 'Saved default targets for API profiles and variants can now be claude, droid, or codex.', ], actions: [ { @@ -256,7 +256,7 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [ routes: [{ label: 'Codex CLI', path: '/codex' }], commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex', 'ccs codex-api --target codex'], notes: - 'Runtime-only in v1. Saved default targets for API profiles and CLIProxy variants remain claude or droid.', + 'Saved default targets for API profiles and CLIProxy variants can now be claude, droid, or codex.', }, { id: 'codex-cliproxy', diff --git a/ui/src/pages/settings/components/tab-navigation.tsx b/ui/src/pages/settings/components/tab-navigation.tsx index 0c3fb57d..c3a59058 100644 --- a/ui/src/pages/settings/components/tab-navigation.tsx +++ b/ui/src/pages/settings/components/tab-navigation.tsx @@ -4,7 +4,16 @@ */ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Globe, Settings2, Server, KeyRound, Brain, Archive, MessageSquare } from 'lucide-react'; +import { + Globe, + Image as ImageIcon, + Settings2, + Server, + KeyRound, + Brain, + Archive, + MessageSquare, +} from 'lucide-react'; import type { SettingsTab } from '../types'; import { useTranslation } from 'react-i18next'; @@ -17,6 +26,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { const { t } = useTranslation(); const tabs = [ { value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe }, + { value: 'image' as const, label: 'Image', icon: ImageIcon }, { value: 'channels' as const, label: 'Channels', icon: MessageSquare }, { value: 'globalenv' as const, label: t('settingsTabs.env'), icon: Settings2 }, { value: 'thinking' as const, label: t('settingsTabs.think'), icon: Brain }, @@ -27,9 +37,9 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { return ( onTabChange(v as SettingsTab)}> - + {tabs.map(({ value, label, icon: Icon }) => ( - + {label} diff --git a/ui/src/pages/settings/hooks/use-settings-tab.ts b/ui/src/pages/settings/hooks/use-settings-tab.ts index 15a05a5f..e2ce37f5 100644 --- a/ui/src/pages/settings/hooks/use-settings-tab.ts +++ b/ui/src/pages/settings/hooks/use-settings-tab.ts @@ -11,19 +11,21 @@ export function useSettingsTab() { // Normalize to lowercase for case-insensitive matching (fixes ?tab=Backups vs ?tab=backups) const tabParam = searchParams.get('tab')?.toLowerCase(); const activeTab: SettingsTab = - tabParam === 'channels' - ? 'channels' - : tabParam === 'globalenv' - ? 'globalenv' - : tabParam === 'proxy' - ? 'proxy' - : tabParam === 'auth' - ? 'auth' - : tabParam === 'thinking' - ? 'thinking' - : tabParam === 'backups' - ? 'backups' - : 'websearch'; + tabParam === 'imageanalysis' || tabParam === 'image' + ? 'image' + : tabParam === 'channels' + ? 'channels' + : tabParam === 'globalenv' + ? 'globalenv' + : tabParam === 'proxy' + ? 'proxy' + : tabParam === 'auth' + ? 'auth' + : tabParam === 'thinking' + ? 'thinking' + : tabParam === 'backups' + ? 'backups' + : 'websearch'; const setActiveTab = useCallback( (tab: SettingsTab) => { diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index 5d1bf4a1..9608eab5 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -48,6 +48,7 @@ function lazyWithRetry>(importFn: () => Promise // Lazy-loaded sections with retry capability const WebSearchSection = lazyWithRetry(() => import('./sections/websearch')); +const ImageAnalysisSection = lazyWithRetry(() => import('./sections/image-analysis')); const ChannelsSection = lazyWithRetry(() => import('./sections/channels')); const GlobalEnvSection = lazyWithRetry(() => import('./sections/globalenv-section')); const ThinkingSection = lazyWithRetry(() => import('./sections/thinking')); @@ -131,6 +132,7 @@ function SettingsPageInner() { }> {activeTab === 'websearch' && } + {activeTab === 'image' && } {activeTab === 'channels' && } {activeTab === 'globalenv' && } {activeTab === 'thinking' && } @@ -144,7 +146,7 @@ function SettingsPageInner() { {/* Desktop View - Side-by-side panels */} {/* Left Panel - Settings Controls */} - +

{/* Header with Tabs */}
@@ -155,6 +157,7 @@ function SettingsPageInner() { }> {activeTab === 'websearch' && } + {activeTab === 'image' && } {activeTab === 'channels' && } {activeTab === 'globalenv' && } {activeTab === 'thinking' && } @@ -172,7 +175,7 @@ function SettingsPageInner() { {/* Right Panel - Config Viewer */} - +
{/* Header */}
diff --git a/ui/src/pages/settings/sections/image-analysis/index.tsx b/ui/src/pages/settings/sections/image-analysis/index.tsx new file mode 100644 index 00000000..a644dea9 --- /dev/null +++ b/ui/src/pages/settings/sections/image-analysis/index.tsx @@ -0,0 +1,1300 @@ +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Activity, + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronUp, + GitBranch, + Image as ImageIcon, + Plus, + RefreshCw, + SlidersHorizontal, + Sparkles, + Trash2, +} from 'lucide-react'; +import { api, type ImageAnalysisDashboardData } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +import { useRawConfig } from '../../hooks'; + +interface MappingDraft { + id: string; + profileName: string; + backendId: string; +} + +type ImageBackend = ImageAnalysisDashboardData['backends'][number]; +type ImageProfile = ImageAnalysisDashboardData['profiles'][number]; + +const NO_BACKEND = '__no_backend__'; + +function isStringRecord(value: unknown): value is Record { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.values(value).every((entry) => typeof entry === 'string') + ); +} + +function isImageAnalysisDashboardData(value: unknown): value is ImageAnalysisDashboardData { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + const candidate = value as Partial; + return ( + !!candidate.config && + typeof candidate.config.enabled === 'boolean' && + typeof candidate.config.timeout === 'number' && + isStringRecord(candidate.config.providerModels) && + (candidate.config.fallbackBackend === null || + typeof candidate.config.fallbackBackend === 'string') && + isStringRecord(candidate.config.profileBackends) && + !!candidate.summary && + typeof candidate.summary.state === 'string' && + typeof candidate.summary.title === 'string' && + typeof candidate.summary.detail === 'string' && + Array.isArray(candidate.backends) && + Array.isArray(candidate.profiles) && + !!candidate.catalog && + Array.isArray(candidate.catalog.knownBackends) && + Array.isArray(candidate.catalog.profileNames) && + typeof candidate.summary.nativeProfileCount === 'number' + ); +} + +function toMappingDrafts(profileBackends: Record): MappingDraft[] { + return Object.entries(profileBackends) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([profileName, backendId], index) => ({ + id: `${profileName}-${backendId}-${index}`, + profileName, + backendId, + })); +} + +function summaryToneClass(state: ImageAnalysisDashboardData['summary']['state']): string { + switch (state) { + case 'ready': + return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200'; + case 'partial': + return 'border-amber-500/25 bg-amber-500/10 text-amber-900 dark:text-amber-200'; + case 'needs_setup': + return 'border-rose-500/25 bg-rose-500/10 text-rose-900 dark:text-rose-200'; + case 'disabled': + return 'border-border/80 bg-background/85 text-muted-foreground'; + } +} + +function backendStateClass(state: ImageAnalysisDashboardData['backends'][number]['state']): string { + switch (state) { + case 'ready': + return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200'; + case 'starts_on_launch': + return 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200'; + case 'needs_auth': + return 'border-rose-500/25 bg-rose-500/10 text-rose-800 dark:text-rose-200'; + case 'needs_proxy': + return 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200'; + case 'review': + return 'border-border/80 bg-background/85 text-muted-foreground'; + } +} + +function currentTargetModeLabel( + mode: ImageAnalysisDashboardData['profiles'][number]['currentTargetMode'] +): string { + switch (mode) { + case 'active': + return 'Active'; + case 'bypassed': + return 'Bypassed'; + case 'fallback': + return 'Native fallback'; + case 'setup': + return 'Needs setup'; + case 'disabled': + return 'Disabled'; + case 'native': + return 'Native'; + case 'unresolved': + return 'Native only'; + } +} + +function currentTargetModeClass( + mode: ImageAnalysisDashboardData['profiles'][number]['currentTargetMode'] +): string { + switch (mode) { + case 'active': + return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200'; + case 'bypassed': + return 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200'; + case 'fallback': + case 'setup': + return 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200'; + case 'native': + return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200'; + case 'disabled': + case 'unresolved': + return 'border-border/80 bg-background/85 text-muted-foreground'; + } +} + +function backendStateLabel(state: ImageBackend['state']): string { + switch (state) { + case 'starts_on_launch': + return 'Starts on launch'; + case 'needs_auth': + return 'Needs auth'; + case 'needs_proxy': + return 'Needs proxy'; + case 'review': + return 'Review'; + case 'ready': + return 'Ready'; + } +} + +function backendStatusNote(backend: ImageBackend | undefined): string | null { + if (!backend) { + return 'No model configured.'; + } + + switch (backend.state) { + case 'needs_auth': + return backend.authReason || 'Authenticate to route here.'; + case 'needs_proxy': + return backend.proxyReason || 'Proxy unavailable.'; + case 'starts_on_launch': + return 'Auth ready. Launches locally on demand.'; + case 'review': + return 'Needs manual review.'; + case 'ready': + return null; + } +} + +function routeSourceLabel(source: ImageProfile['resolutionSource']): string { + switch (source) { + case 'profile-backend': + return 'Explicit mapping'; + case 'fallback-backend': + return 'Fallback backend'; + case 'cliproxy-provider': + return 'Provider match'; + case 'cliproxy-bridge': + return 'Bridge match'; + case 'native-compatible': + return 'Native path'; + case 'copilot-alias': + return 'Copilot alias'; + default: + return source.replace(/-/g, ' '); + } +} + +type SectionTone = 'sky' | 'amber' | 'emerald' | 'cyan' | 'slate'; + +function getInsetPanelClass(_tone?: SectionTone): string { + return 'border-border/50 bg-background/40'; +} + +function getBackendRowClass(state: ImageBackend['state'] | undefined): string { + switch (state) { + case 'ready': + return 'bg-[linear-gradient(90deg,rgba(16,185,129,0.08),transparent_18%),linear-gradient(180deg,rgba(255,255,255,0.72),rgba(255,255,255,0.46))] dark:bg-[linear-gradient(90deg,rgba(16,185,129,0.12),transparent_18%),linear-gradient(180deg,rgba(15,23,42,0.82),rgba(15,23,42,0.56))]'; + case 'starts_on_launch': + return 'bg-[linear-gradient(90deg,rgba(14,165,233,0.08),transparent_18%),linear-gradient(180deg,rgba(255,255,255,0.72),rgba(255,255,255,0.46))] dark:bg-[linear-gradient(90deg,rgba(14,165,233,0.12),transparent_18%),linear-gradient(180deg,rgba(15,23,42,0.82),rgba(15,23,42,0.56))]'; + case 'needs_auth': + return 'bg-[linear-gradient(90deg,rgba(244,63,94,0.08),transparent_18%),linear-gradient(180deg,rgba(255,255,255,0.72),rgba(255,255,255,0.46))] dark:bg-[linear-gradient(90deg,rgba(244,63,94,0.12),transparent_18%),linear-gradient(180deg,rgba(15,23,42,0.82),rgba(15,23,42,0.56))]'; + case 'needs_proxy': + return 'bg-[linear-gradient(90deg,rgba(245,158,11,0.08),transparent_18%),linear-gradient(180deg,rgba(255,255,255,0.72),rgba(255,255,255,0.46))] dark:bg-[linear-gradient(90deg,rgba(245,158,11,0.12),transparent_18%),linear-gradient(180deg,rgba(15,23,42,0.82),rgba(15,23,42,0.56))]'; + case 'review': + default: + return 'bg-[linear-gradient(180deg,rgba(255,255,255,0.74),rgba(255,255,255,0.5))] dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.8),rgba(15,23,42,0.58))]'; + } +} + +function getBackendRailClass(state: ImageBackend['state'] | undefined): string { + switch (state) { + case 'ready': + return 'from-emerald-500 to-emerald-400/30'; + case 'starts_on_launch': + return 'from-sky-500 to-sky-400/30'; + case 'needs_auth': + return 'from-rose-500 to-rose-400/30'; + case 'needs_proxy': + return 'from-amber-500 to-amber-400/30'; + case 'review': + default: + return 'from-slate-400 to-slate-300/20'; + } +} + +function getCoverageRowClass(index: number, profile: ImageProfile): string { + if (profile.nativeReadPreference) { + return index % 2 === 0 ? 'bg-emerald-500/[0.06]' : 'bg-emerald-500/[0.08]'; + } + + return index % 2 === 0 ? 'bg-background/75' : 'bg-muted/18'; +} + +function summaryCompactDetail(summary: ImageAnalysisDashboardData['summary']): string { + const parts = [`${summary.activeProfileCount} routed`, `${summary.nativeProfileCount} native`]; + + if (summary.mappedProfileCount > 0) { + parts.push( + `${summary.mappedProfileCount} override${summary.mappedProfileCount === 1 ? '' : 's'}` + ); + } + + return parts.join(' · '); +} + +function buildProviderModelsPayload( + providerModels: Record +): Record { + return Object.entries(providerModels).reduce( + (acc, [backendId, model]) => { + const normalizedModel = model.trim(); + acc[backendId] = normalizedModel || null; + return acc; + }, + {} as Record + ); +} + +function getConfiguredBackendIds(providerModels: Record): string[] { + return Object.entries(providerModels) + .filter(([, model]) => model.trim().length > 0) + .map(([backendId]) => backendId); +} + +function buildProfileBackends(mappingDrafts: MappingDraft[]): Record { + return mappingDrafts.reduce( + (acc, row) => { + const profileName = row.profileName.trim(); + if (!profileName || !row.backendId) { + return acc; + } + + acc[profileName] = row.backendId; + return acc; + }, + {} as Record + ); +} + +function normalizeTimeoutDraft(rawValue: string, fallbackValue: string): string { + const parsed = Number.parseInt(rawValue.trim(), 10); + if (!Number.isInteger(parsed)) { + return fallbackValue; + } + + return String(Math.min(600, Math.max(10, parsed))); +} + +interface ImageSectionPanelProps { + tone?: SectionTone; + eyebrow?: string; + title: string; + description: string; + icon: ReactNode; + meta?: ReactNode; + action?: ReactNode; + children: ReactNode; + className?: string; +} + +function ImageSectionPanel({ + title, + description, + icon, + meta, + action, + children, + className, +}: ImageSectionPanelProps) { + return ( +
+
+
+
+
+
+ {icon} +
+
+
+

{title}

+ {meta} +
+

{description}

+
+
+ {action &&
{action}
} +
+
{children}
+
+
+ ); +} + +export default function ImageAnalysisSection() { + const { fetchRawConfig } = useRawConfig(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [showProfileRouting, setShowProfileRouting] = useState(false); + + const [enabled, setEnabled] = useState(true); + const [timeout, setTimeout] = useState('60'); + const [fallbackBackend, setFallbackBackend] = useState(''); + const [providerModels, setProviderModels] = useState>({}); + const [mappingDrafts, setMappingDrafts] = useState([]); + + const hydrateDraft = useCallback((nextData: ImageAnalysisDashboardData) => { + setEnabled(nextData.config.enabled); + setTimeout(String(nextData.config.timeout)); + setFallbackBackend(nextData.config.fallbackBackend ?? ''); + setProviderModels( + nextData.catalog.knownBackends.reduce( + (acc, backendId) => { + acc[backendId] = nextData.config.providerModels[backendId] ?? ''; + return acc; + }, + {} as Record + ) + ); + setMappingDrafts(toMappingDrafts(nextData.config.profileBackends)); + }, []); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + const payload = await api.imageAnalysis.get(); + if (!isImageAnalysisDashboardData(payload)) { + throw new Error( + 'Image settings returned an unexpected response. Restart the dashboard server so the new API route is available.' + ); + } + setData(payload); + hydrateDraft(payload); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load image settings.'); + } finally { + setLoading(false); + } + }, [hydrateDraft]); + + useEffect(() => { + void fetchData(); + void fetchRawConfig(); + }, [fetchData, fetchRawConfig]); + + useEffect(() => { + if (!success) return; + const timer = window.setTimeout(() => setSuccess(null), 2500); + return () => window.clearTimeout(timer); + }, [success]); + + useEffect(() => { + if (!data) return; + if (Object.keys(data.config.profileBackends).length > 0) { + setShowProfileRouting(true); + } + }, [data]); + + const configuredBackendIds = useMemo( + () => getConfiguredBackendIds(providerModels), + [providerModels] + ); + + const orderedBackendIds = useMemo(() => { + if (!data) return []; + + const configured = data.catalog.knownBackends.filter((backendId) => + configuredBackendIds.includes(backendId) + ); + const inactive = data.catalog.knownBackends.filter( + (backendId) => !configuredBackendIds.includes(backendId) + ); + + return [...configured, ...inactive]; + }, [configuredBackendIds, data]); + + const nativeReadProfiles = useMemo( + () => data?.profiles.filter((profile) => profile.nativeReadPreference) ?? [], + [data] + ); + + useEffect(() => { + if (configuredBackendIds.length === 0) { + setFallbackBackend(''); + return; + } + if (!configuredBackendIds.includes(fallbackBackend)) { + setFallbackBackend(configuredBackendIds[0]); + } + }, [configuredBackendIds, fallbackBackend]); + + const persistSettings = useCallback( + async (overrides?: { + enabled?: boolean; + timeout?: string; + fallbackBackend?: string; + providerModels?: Record; + mappingDrafts?: MappingDraft[]; + }) => { + if (!data) return false; + + const nextEnabled = overrides?.enabled ?? enabled; + const nextProviderModels = overrides?.providerModels ?? providerModels; + const nextConfiguredBackendIds = getConfiguredBackendIds(nextProviderModels); + const nextTimeout = normalizeTimeoutDraft( + overrides?.timeout ?? timeout, + String(data.config.timeout) + ); + const requestedFallbackBackend = overrides?.fallbackBackend ?? fallbackBackend; + const nextFallbackBackend = + nextConfiguredBackendIds.length === 0 + ? '' + : nextConfiguredBackendIds.includes(requestedFallbackBackend) + ? requestedFallbackBackend + : nextConfiguredBackendIds[0]; + const nextMappingDrafts = overrides?.mappingDrafts ?? mappingDrafts; + const nextPayload = { + enabled: nextEnabled, + timeout: nextTimeout, + fallbackBackend: nextFallbackBackend, + providerModels: buildProviderModelsPayload(nextProviderModels), + profileBackends: buildProfileBackends(nextMappingDrafts), + }; + + const currentPayload = { + enabled: data.config.enabled, + timeout: String(data.config.timeout), + fallbackBackend: data.config.fallbackBackend ?? '', + providerModels: data.catalog.knownBackends.reduce( + (acc, backendId) => { + acc[backendId] = data.config.providerModels[backendId] ?? null; + return acc; + }, + {} as Record + ), + profileBackends: data.config.profileBackends, + }; + + if (JSON.stringify(nextPayload) === JSON.stringify(currentPayload)) { + return true; + } + + if (nextEnabled && nextConfiguredBackendIds.length === 0) { + setError('Keep at least one provider model configured, or disable Image globally.'); + hydrateDraft(data); + return false; + } + + try { + setSaving(true); + setError(null); + const payload = await api.imageAnalysis.update({ + enabled: nextEnabled, + timeout: Number.parseInt(nextTimeout, 10), + fallbackBackend: nextFallbackBackend || null, + providerModels: nextPayload.providerModels, + profileBackends: nextPayload.profileBackends, + }); + setData(payload); + hydrateDraft(payload); + setSuccess('Image settings saved.'); + await fetchRawConfig(); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save image settings.'); + hydrateDraft(data); + return false; + } finally { + setSaving(false); + } + }, + [ + data, + enabled, + fallbackBackend, + fetchRawConfig, + hydrateDraft, + mappingDrafts, + providerModels, + timeout, + ] + ); + + const handleRefresh = async () => { + if (loading || saving) return; + setSuccess(null); + await Promise.all([fetchData(), fetchRawConfig()]); + }; + + const handleEnabledChange = async (nextEnabled: boolean) => { + if (saving) return; + if (nextEnabled && configuredBackendIds.length === 0) { + setError('Keep at least one provider model configured, or disable Image globally.'); + return; + } + + setEnabled(nextEnabled); + await persistSettings({ enabled: nextEnabled }); + }; + + const commitTimeout = async (nextValue: string) => { + if (!data || saving) return; + const normalizedTimeout = normalizeTimeoutDraft(nextValue, String(data.config.timeout)); + setTimeout(normalizedTimeout); + await persistSettings({ timeout: normalizedTimeout }); + }; + + const commitFallbackBackend = async (nextFallbackBackend: string) => { + if (saving) return; + setFallbackBackend(nextFallbackBackend); + await persistSettings({ fallbackBackend: nextFallbackBackend }); + }; + + const commitProviderModel = async (backendId: string, nextValue: string) => { + if (!data || saving) return; + + const normalizedValue = nextValue.trim(); + const nextProviderModels = { + ...providerModels, + [backendId]: normalizedValue, + }; + const nextConfiguredBackendIds = getConfiguredBackendIds(nextProviderModels); + + if (enabled && nextConfiguredBackendIds.length === 0) { + setError('Disable Image first or keep one backend configured.'); + setProviderModels((current) => ({ + ...current, + [backendId]: data.config.providerModels[backendId] ?? '', + })); + return; + } + + const nextFallbackBackend = + nextConfiguredBackendIds.length === 0 + ? '' + : nextConfiguredBackendIds.includes(fallbackBackend) + ? fallbackBackend + : nextConfiguredBackendIds[0]; + + setProviderModels(nextProviderModels); + setFallbackBackend(nextFallbackBackend); + await persistSettings({ + providerModels: nextProviderModels, + fallbackBackend: nextFallbackBackend, + }); + }; + + const updateMappingRow = (rowId: string, patch: Partial) => { + setMappingDrafts((current) => + current.map((entry) => (entry.id === rowId ? { ...entry, ...patch } : entry)) + ); + }; + + const commitMappingDrafts = async (nextMappingDrafts: MappingDraft[]) => { + if (saving) return; + setMappingDrafts(nextMappingDrafts); + await persistSettings({ mappingDrafts: nextMappingDrafts }); + }; + + const completeMappingCount = mappingDrafts.filter( + (row) => row.profileName.trim() && row.backendId + ).length; + + if (loading) { + return ( +
+
+ + Loading image settings... +
+
+ ); + } + + if (!data) { + return ( +
+ + + {error ?? 'Failed to load image settings.'} + +
+ +
+
+ ); + } + + return ( +
+
+ {error && ( + + + {error} + + )} + {success && ( +
+ + {success} +
+ )} +
+ + +
+
+
+
+
+
+
+
+
+ +
+

Image

+
+
+ + {data.summary.title} + + {summaryCompactDetail(data.summary)} +
+
+ +
+ +
+
+
+ Active routes +
+
+ {data.summary.activeProfileCount} +
+

Current target path

+
+
+
+ Native path +
+
+ {data.summary.nativeProfileCount} +
+

Skip transformer

+
+
+
+
+ + } + meta={ + + {configuredBackendIds.length} configured + + } + > +
+
+
+ Enabled +
+
+
+
+ {enabled ? 'Transformer on' : 'Transformer off'} +
+

+ Profile flags stay untouched. +

+
+ { + void handleEnabledChange(checked); + }} + disabled={saving} + /> +
+
+ +
+
+ Timeout +
+
+ setTimeout(event.target.value)} + inputMode="numeric" + className="h-10 border-amber-500/15 bg-background/90 text-base" + disabled={saving} + onBlur={(event) => { + void commitTimeout(event.currentTarget.value); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + }} + /> + sec +
+

+ Keeps large reads from hanging. +

+
+ +
+
+ Fallback backend +
+
+ +
+

+ Used when no direct route exists. +

+
+
+ +
+
+ {completeMappingCount} overrides +
+
+ {nativeReadProfiles.length} native +
+
+ {fallbackBackend || 'No fallback'} fallback +
+
+
+ + } + meta={ + + {orderedBackendIds.length} backends + + } + > +
+ {orderedBackendIds.map((backendId, index) => { + const backendStatus = data.backends.find((item) => item.backendId === backendId); + const displayName = backendStatus?.displayName || backendId; + const currentModel = providerModels[backendId] ?? ''; + const statusNote = backendStatusNote(backendStatus); + const usageLine = currentModel + ? [ + `${backendStatus?.profilesUsing ?? 0} active`, + backendStatus?.authReadiness === 'missing' + ? 'auth missing' + : backendStatus?.proxyReadiness === 'stopped' + ? 'starts on launch' + : null, + ] + .filter(Boolean) + .join(' · ') + : 'No model configured.'; + + return ( +
0 && 'border-t border-cyan-500/10', + getBackendRowClass(backendStatus?.state) + )} + > +
+ +
+
+
+
+

{displayName}

+ + {backendId} + + {backendStatus?.profilesUsing ? ( + + {backendStatus.profilesUsing} active + + ) : null} +
+

+ {usageLine} +

+
+ + {backendStatus ? backendStateLabel(backendStatus.state) : 'Inactive'} + +
+ +
+ + setProviderModels((current) => ({ + ...current, + [backendId]: event.target.value, + })) + } + onBlur={(event) => { + void commitProviderModel(backendId, event.currentTarget.value); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + }} + /> + {currentModel.trim().length > 0 && ( + + )} +
+ + {statusNote && ( +

+ {statusNote} +

+ )} +
+
+ ); + })} +
+ + + } + meta={{nativeReadProfiles.length} profiles} + > + {nativeReadProfiles.length === 0 ? ( +
+ No profiles prefer native reading yet. +
+ ) : ( +
+ {nativeReadProfiles.map((profile) => ( +
+
+
+
+
+ {profile.name} +
+ + {profile.kind === 'variant' ? 'Variant' : 'Profile'} + + + {profile.nativeImageCapable ? 'Verified' : 'Review'} + +
+
+ {profile.profileModel || 'Model not detected'} ·{' '} + {profile.nativeImageReason || 'Native read preferred.'} +
+
+ + {currentTargetModeLabel(profile.currentTargetMode)} + +
+
+ ))} +
+ )} +
+ + } + meta={ + + Advanced + + } + action={ +
+ + {showProfileRouting && ( + + )} +
+ } + className="border-dashed" + > + + {data.catalog.profileNames.map((profileName) => ( + + + {showProfileRouting ? ( +
+ {mappingDrafts.length === 0 ? ( +
+ No explicit overrides saved. +
+ ) : ( +
+ {mappingDrafts.map((row) => ( +
+
+
+ Direct override + {!(row.profileName.trim() && row.backendId) && ( + + Draft + + )} +
+ +
+ +
+ { + updateMappingRow(row.id, { profileName: event.target.value }); + }} + onBlur={(event) => { + const nextMappingDrafts = mappingDrafts.map((entry) => + entry.id === row.id + ? { ...entry, profileName: event.currentTarget.value.trim() } + : entry + ); + void commitMappingDrafts(nextMappingDrafts); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + }} + /> + +
+
+ ))} +
+ )} +
+ ) : ( +
+ Hidden by default. + {mappingDrafts.length > 0 + ? ` ${mappingDrafts.length} override${mappingDrafts.length === 1 ? '' : 's'} saved.` + : ' No overrides saved.'} +
+ )} +
+ + } + meta={{data.profiles.length} profiles} + > +
+ {data.profiles.map((profile, index) => ( +
0 && 'border-t border-slate-400/12', + getCoverageRowClass(index, profile) + )} + > +
+
+
+ {profile.name} +
+ + {profile.kind === 'variant' ? 'Variant' : 'Profile'} + + + {profile.target} + + {profile.nativeReadPreference && ( + + Native + + )} +
+
+ {profile.backendDisplayName || profile.profileModel || 'Native file access'} ·{' '} + {routeSourceLabel(profile.resolutionSource)} +
+
+ +
+ {profile.profileModel && ( + + {profile.profileModel} + + )} + + {currentTargetModeLabel(profile.currentTargetMode)} + +
+
+ ))} +
+
+
+ +
+ ); +} diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index de75c527..70af1d16 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -161,6 +161,7 @@ export interface OfficialChannelsStatus { export type SettingsTab = | 'websearch' + | 'image' | 'channels' | 'globalenv' | 'proxy' diff --git a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx new file mode 100644 index 00000000..6739f4fb --- /dev/null +++ b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx @@ -0,0 +1,441 @@ +import { fireEvent, render, screen, waitFor } from '@tests/setup/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ImageAnalysisStatus } from '@/lib/api-client'; + +vi.mock('@/components/shared/code-editor', () => ({ + CodeEditor: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( +