fix(image-analysis): surface runtime readiness

This commit is contained in:
Tam Nhu Tran committed 2026-04-01 15:32:37 -04:00
1 parent d394772f7c
commit 1a01c6fc68
12 files changed
+788 -123

No files matched your search

+14 -4
View File
@@ -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, installImageAnalyzerHook } from './utils/hooks';
import {
getImageAnalysisHookEnv,
installImageAnalyzerHook,
resolveImageAnalysisRuntimeStatus,
} from './utils/hooks';
import { fail, info, warn } from './utils/ui';
import { isCopilotSubcommandToken } from './copilot/constants';
import {
@@ -1013,6 +1017,12 @@ async function main(): Promise<void> {
}
const webSearchEnv = getWebSearchHookEnv();
const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({
profileName: profileInfo.name,
profileType: profileInfo.type,
settings,
cliproxyBridge,
});
let imageAnalysisEnv = getImageAnalysisHookEnv({
profileName: profileInfo.name,
profileType: profileInfo.type,
@@ -1032,10 +1042,10 @@ async function main(): Promise<void> {
targetRemainingArgs.includes('--verbose') ||
targetRemainingArgs.includes('-v');
if (!isAuthenticated(imageAnalysisProvider as CLIProxyProvider)) {
if (imageAnalysisStatus.effectiveRuntimeMode === 'native-read') {
console.error(
info(
`Image analysis via ${imageAnalysisProvider} is configured, but CLIProxy auth is missing. This session will use native Read. Run "ccs ${imageAnalysisProvider} --auth" to enable it.`
`${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This session will use native Read.`
)
);
imageAnalysisEnv = {
@@ -1043,7 +1053,7 @@ async function main(): Promise<void> {
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
} else {
} else if (imageAnalysisStatus.proxyReadiness === 'stopped') {
const ensureServiceResult = await ensureCliproxyService(
CLIPROXY_DEFAULT_PORT,
verboseProxyLaunch
+75 -5
View File
@@ -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<string, string>;
warning: string | null;
}
/**
* Get full copilot status (auth + daemon).
*/
@@ -75,6 +88,62 @@ export function generateCopilotEnv(
};
}
export async function resolveCopilotImageAnalysisEnv(
verbose = false,
deps: Partial<CopilotImageAnalysisDeps> = {}
): Promise<CopilotImageAnalysisResolution> {
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,10 +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({
profileName: 'copilot',
profileType: 'copilot',
});
const { env: imageAnalysisEnv, warning: imageAnalysisWarning } =
await resolveCopilotImageAnalysisEnv();
const env = stripClaudeCodeEnv({
...process.env,
...globalEnv,
@@ -179,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);
@@ -33,6 +33,16 @@ export type ImageAnalysisStatusCode =
| '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;
@@ -61,6 +71,14 @@ export interface ImageAnalysisStatus {
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;
}
function resolveProviderFromBaseUrl(baseUrl: unknown): string | null {
@@ -397,5 +415,34 @@ export function resolveImageAnalysisStatus(
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,
};
}
@@ -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<RemoteAuthStatus[]>;
getAuthStatus: (provider: CLIProxyProvider) => AuthStatus;
getProxyTarget: () => ProxyTarget;
initializeAccounts: () => void;
isCliproxyRunning: () => Promise<boolean>;
}
const defaultDeps: ImageAnalysisRuntimeStatusDeps = {
fetchRemoteAuthStatus,
getAuthStatus,
getProxyTarget,
initializeAccounts,
isCliproxyRunning: () => isCliproxyRunning(),
};
async function resolveAuthReadiness(
status: ImageAnalysisStatus,
deps: ImageAnalysisRuntimeStatusDeps
): Promise<
Pick<ImageAnalysisStatus, 'authReadiness' | 'authProvider' | 'authDisplayName' | 'authReason'>
> {
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<Pick<ImageAnalysisStatus, 'proxyReadiness' | 'proxyReason'>> {
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<ImageAnalysisStatus, 'effectiveRuntimeMode' | 'effectiveRuntimeReason'> {
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<ImageAnalysisRuntimeStatusDeps> = {}
): Promise<ImageAnalysisStatus> {
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<ImageAnalysisRuntimeStatusDeps> = {}
): Promise<ImageAnalysisStatus> {
const baseStatus = resolveImageAnalysisStatus(context, config);
return hydrateImageAnalysisRuntimeStatus(baseStatus, deps);
}
+4
View File
@@ -14,6 +14,10 @@ export {
type ImageAnalysisResolutionContext,
type ImageAnalysisStatus,
} from './image-analysis-backend-resolver';
export {
hydrateImageAnalysisRuntimeStatus,
resolveImageAnalysisRuntimeStatus,
} from './image-analysis-runtime-status';
export {
getImageAnalyzerHookPath,
getImageAnalyzerHookConfig,
+35 -24
View File
@@ -41,7 +41,7 @@ import {
hasImageAnalysisProfileHook,
} from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
import { resolveImageAnalysisStatus } from '../../utils/hooks';
import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks';
const router = Router();
const MODEL_ENV_KEYS = [
@@ -269,16 +269,16 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting
return changed ? next : settings;
}
function resolveImageAnalysisStatusForProfile(
async function resolveImageAnalysisStatusForProfile(
profileOrVariant: string,
settings: Settings,
settingsPath: string
) {
): Promise<Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>> {
const variants = listVariants();
const variant = variants[profileOrVariant];
const cliproxyProvider = resolveProviderForProfile(profileOrVariant);
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
const status = resolveImageAnalysisStatus(
const status = await resolveImageAnalysisRuntimeStatus(
{
profileName: profileOrVariant,
profileType: cliproxyProvider ? 'cliproxy' : 'settings',
@@ -303,7 +303,7 @@ function resolveImageAnalysisStatusForProfile(
};
}
function resolvePreviewImageAnalysisStatus(profileOrVariant: string, settings: Settings) {
async function resolvePreviewImageAnalysisStatus(profileOrVariant: string, settings: Settings) {
const normalizedSettings = canonicalizeProfileSettings(profileOrVariant, settings);
const settingsPath = resolveSettingsPath(profileOrVariant);
@@ -377,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<void> => {
try {
const { profile } = req.params;
const settingsPath = resolveSettingsPath(profile);
@@ -397,7 +397,11 @@ router.get('/:profile', (req: Request, res: Response): void => {
mtime: stat.mtime.getTime(),
path: settingsPath,
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
imageAnalysisStatus: resolveImageAnalysisStatusForProfile(profile, settings, settingsPath),
imageAnalysisStatus: await resolveImageAnalysisStatusForProfile(
profile,
settings,
settingsPath
),
});
} catch (error) {
respondInternalError(res, error, 'Internal server error.');
@@ -407,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<void> => {
if (!requireSensitiveLocalAccess(req, res)) return;
try {
@@ -428,7 +432,11 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
mtime: stat.mtime.getTime(),
path: settingsPath,
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
imageAnalysisStatus: resolveImageAnalysisStatusForProfile(profile, settings, settingsPath),
imageAnalysisStatus: await resolveImageAnalysisStatusForProfile(
profile,
settings,
settingsPath
),
});
} catch (error) {
respondInternalError(res, error, 'Internal server error.');
@@ -438,25 +446,28 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
/**
* POST /api/settings/:profile/image-analysis-status - Preview image analysis status from editor JSON
*/
router.post('/:profile/image-analysis-status', (req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(req, res)) return;
router.post(
'/:profile/image-analysis-status',
async (req: Request, res: Response): Promise<void> => {
if (!requireSensitiveLocalAccess(req, res)) return;
try {
const { profile } = req.params;
const { settings } = req.body;
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;
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.');
}
res.json({
imageAnalysisStatus: 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;