Merge pull request #877 from kaitranntt/kai/feat/867-image-analysis-backend-status

feat(image): unify settings UX, native image routing, and backend visibility
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-01 17:04:42 -04:00
committed by GitHub
60 changed files with 5962 additions and 140 deletions
+2
View File
@@ -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 <backend>` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend <profile> <backend>` and `--clear-profile-backend <profile>` 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.
+1 -1
View File
@@ -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",
+29
View File
@@ -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;
+81 -8
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 } 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<void> {
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<void> {
} 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<void> {
// 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<void> {
: 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<void> {
}
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 : {};
+100 -1
View File
@@ -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<string, string>;
}
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<string, string>;
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<CliproxyImageAnalysisDeps> = {}
): Promise<CliproxyImageAnalysisResolution> {
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<string,
compositeTiers,
compositeDefaultTier,
claudeConfigDir,
imageAnalysisEnv: resolvedImageAnalysisEnv,
} = config;
// Build base env vars - check remote mode first
@@ -253,7 +352,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
// Add hook environment variables
const webSearchEnv = getWebSearchHookEnv();
const imageAnalysisEnv = getImageAnalysisHookEnv(provider);
const imageAnalysisEnv = resolvedImageAnalysisEnv ?? getImageAnalysisHookEnv(provider);
// Merge all environment variables (filter undefined values)
const baseEnv = Object.fromEntries(
+38 -1
View File
@@ -66,7 +66,11 @@ import { resolveProfileContinuityInheritance } from '../../auth/profile-continui
// Import modular components
import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
import { buildClaudeEnvironment, logEnvironment } from './env-resolver';
import {
buildClaudeEnvironment,
logEnvironment,
resolveCliproxyImageAnalysisEnv,
} from './env-resolver';
import {
isNetworkError,
handleNetworkError,
@@ -172,6 +176,7 @@ export async function execClaudeWithCLIProxy(
port: cliproxyServerConfig.remote.port,
protocol: cliproxyServerConfig.remote.protocol,
auth_token: cliproxyServerConfig.remote.auth_token,
management_key: cliproxyServerConfig.remote.management_key,
timeout: cliproxyServerConfig.remote.timeout,
}
: undefined,
@@ -819,6 +824,33 @@ export async function execClaudeWithCLIProxy(
}
}
const imageAnalysisProxyTarget =
useRemoteProxy && proxyConfig.host
? {
host: proxyConfig.host,
port: proxyConfig.port,
protocol: proxyConfig.protocol,
authToken: proxyConfig.authToken,
managementKey: proxyConfig.managementKey,
allowSelfSigned: proxyConfig.allowSelfSigned,
isRemote: true as const,
}
: {
host: '127.0.0.1',
port: cfg.port,
protocol: 'http' as const,
isRemote: false as const,
};
const { env: imageAnalysisEnv, warning: imageAnalysisWarning } =
await resolveCliproxyImageAnalysisEnv({
profileName: cfg.profileName || provider,
provider,
profileSettingsPath: cfg.customSettingsPath,
isComposite: cfg.isComposite,
proxyTarget: imageAnalysisProxyTarget,
proxyReachable: true,
});
// 9. Setup tool sanitization proxy
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
let toolSanitizationPort: number | null = null;
@@ -861,6 +893,7 @@ export async function execClaudeWithCLIProxy(
compositeTiers: cfg.compositeTiers,
compositeDefaultTier: cfg.compositeDefaultTier,
claudeConfigDir: inheritedClaudeConfigDir,
imageAnalysisEnv,
});
if (initialEnvVars.ANTHROPIC_BASE_URL) {
@@ -957,6 +990,7 @@ export async function execClaudeWithCLIProxy(
compositeTiers: cfg.compositeTiers,
compositeDefaultTier: cfg.compositeDefaultTier,
claudeConfigDir: inheritedClaudeConfigDir,
imageAnalysisEnv,
});
if (cfg.isComposite && cfg.compositeTiers && cfg.compositeDefaultTier) {
@@ -973,6 +1007,9 @@ export async function execClaudeWithCLIProxy(
const webSearchEnv = getWebSearchHookEnv();
logEnvironment(env, webSearchEnv, verbose);
if (imageAnalysisWarning) {
console.error(info(imageAnalysisWarning));
}
// 11b. Print thinking status feedback (TTY only, non-piped sessions)
if (process.stderr.isTTY) {
+39
View File
@@ -59,6 +59,8 @@ export interface ModelEntry {
thinking?: ThinkingSupport;
/** Whether model supports 1M extended context window (appends [1m] suffix) */
extendedContext?: boolean;
/** Whether model can read image inputs natively without the Image transformer */
nativeImageInput?: boolean;
}
/**
@@ -86,6 +88,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
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.
+3 -1
View File
@@ -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;
+5
View File
@@ -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,
};
}
+86 -11
View File
@@ -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<string, string>,
target: ProxyTarget
): Promise<Response> {
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<Response>((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<Remot
const url = buildProxyUrl(proxyTarget, '/v0/management/auth-files');
const headers = buildManagementHeaders(proxyTarget);
const controller = new AbortController();
const timeoutId = setTimeout(() => 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<Remot
return transformRemoteAuthFiles(data.files as RemoteAuthFile[]);
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Remote proxy connection timed out');
}
if (error instanceof Error && error.message === 'Request timeout') {
throw new Error('Remote proxy connection timed out');
}
throw error;
}
}
+19 -4
View File
@@ -161,7 +161,12 @@ export function createSettingsFile(
}
// Inject Image Analyzer hooks into variant settings
ensureImageAnalyzerHooks(`${provider}-${name}`);
ensureImageAnalyzerHooks({
profileName: `${provider}-${name}`,
profileType: 'cliproxy',
cliproxyProvider: provider,
settingsPath,
});
return settingsPath;
}
@@ -195,7 +200,12 @@ export function createSettingsFileUnified(
}
// Inject Image Analyzer hooks into variant settings
ensureImageAnalyzerHooks(`${provider}-${name}`);
ensureImageAnalyzerHooks({
profileName: `${provider}-${name}`,
profileType: 'cliproxy',
cliproxyProvider: provider,
settingsPath,
});
return settingsPath;
}
@@ -284,7 +294,6 @@ export function createCompositeSettingsFile(
ensureDir(settingsDir);
writeSettings(settingsPath, settings);
// Hook injectors target ~/.ccs/<profile>.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;
+2
View File
@@ -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) */
+117 -1
View File
@@ -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<string, string>
): 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 <backend>';
}
}
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 <profile> <backend>';
}
}
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 <profile>';
}
}
return options;
}
@@ -82,6 +125,13 @@ function showHelp(): void {
console.log(` ${color('--disable', 'command')} Disable image analysis`);
console.log(` ${color('--timeout <seconds>', 'command')} Set analysis timeout (10-600)`);
console.log(` ${color('--set-model <p> <m>', 'command')} Set model for provider`);
console.log(` ${color('--set-fallback <backend>', 'command')} Set fallback backend`);
console.log(
` ${color('--set-profile-backend <p> <b>', 'command')} Map a profile alias to a backend`
);
console.log(
` ${color('--clear-profile-backend <p>', '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'));
+13 -4
View File
@@ -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>): 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,
});
}
/**
+8 -2
View File
@@ -759,6 +759,10 @@ export interface ImageAnalysisConfig {
timeout: number;
/** Provider-to-model mapping for vision analysis */
provider_models: Record<string, string>;
/** 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<string, string>;
}
/**
@@ -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: {},
};
/**
+75 -2
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,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);
@@ -42,7 +42,7 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise<void>
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;
+6
View File
@@ -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
}
+21 -7
View File
@@ -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, string>): 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<string, string> {
export function getImageAnalysisHookEnv(
input?: string | ImageAnalysisResolutionContext
): Record<string, string> {
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',
};
}
@@ -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<Settings, 'env' | 'ccs_image'> | 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<string, string> | 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> = []
): 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<string, string>
);
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<string, string>
);
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<ImageAnalysisStatus, 'backendId' | 'backendDisplayName' | 'resolutionSource' | 'reason'> {
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,
};
}
@@ -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,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<string, unknown>): 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<string, unknown>;
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<string, unknown> = {};
+11
View File
@@ -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,
+6 -1
View File
@@ -36,6 +36,7 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const server = http.createServer(app);
const wss = new WebSocketServer({
server,
path: '/ws',
maxPayload: 1024 * 1024, // 1MB hard limit to prevent DoS
perMessageDeflate: false, // Prevent zip bomb attacks
});
@@ -88,7 +89,11 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const { createServer: createViteServer } = await import('vite');
const vite = await createViteServer({
root: path.join(__dirname, '../../ui'),
server: { middlewareMode: true },
server: {
middlewareMode: true,
// Reuse the dashboard HTTP server for HMR in middleware mode.
hmr: { server },
},
appType: 'spa',
});
app.use(vite.middlewares);
@@ -0,0 +1,444 @@
import { Router, type Request, type Response } from 'express';
import * as fs from 'fs';
import { getImageAnalysisConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
import {
CLIPROXY_PROVIDER_IDS,
getProviderDisplayName,
mapExternalProviderName,
} from '../../cliproxy/provider-capabilities';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { listApiProfiles, resolveCliproxyBridgeMetadata } from '../../api/services';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
import { expandPath } from '../../utils/helpers';
import { loadSettings } from '../../utils/config-manager';
import type { Settings } from '../../types/config';
import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer';
import {
normalizeImageAnalysisBackendId,
resolveImageAnalysisRuntimeStatus,
} from '../../utils/hooks';
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector';
const router = Router();
const IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR =
'Image Analysis endpoints require localhost access when dashboard auth is disabled.';
type DashboardTarget = 'claude' | 'droid' | 'codex';
type DashboardSummaryState = 'ready' | 'partial' | 'needs_setup' | 'disabled';
type BackendState = 'ready' | 'starts_on_launch' | 'needs_auth' | 'needs_proxy' | 'review';
type CurrentTargetMode =
| 'active'
| 'bypassed'
| 'fallback'
| 'setup'
| 'disabled'
| 'native'
| 'unresolved';
interface ImageAnalysisRouteBody {
enabled?: boolean;
timeout?: number;
providerModels?: Record<string, string | null>;
fallbackBackend?: string | null;
profileBackends?: Record<string, string>;
}
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<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
): 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<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
): 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<void> => {
try {
res.json(await buildDashboardPayload());
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
router.put('/', async (req: Request, res: Response): Promise<void> => {
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<string, string>
);
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<string, string>;
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;
+2
View File
@@ -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);
+102 -7
View File
@@ -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/<profile>.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<Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>> {
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<void> => {
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<void> => {
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<void> => {
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;
+9 -6
View File
@@ -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', () => {
@@ -58,7 +58,8 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
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);
@@ -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> = {}
): 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();
});
});
+20 -4
View File
@@ -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);
});
});
@@ -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' },
@@ -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');
@@ -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();
});
});
+17 -13
View File
@@ -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<void>((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<void>((resolve) => server.close(() => resolve()));
}
});
try {
const models = await fetchModelsFromDaemon(address.port);
expect(models).toEqual(DEFAULT_CURSOR_MODELS);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
},
10000
);
});
describe('fetchModelsFromCursorApi', () => {
@@ -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');
});
});
@@ -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> = {}): 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');
});
});
@@ -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<string, unknown>): 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);
});
});
@@ -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<void>((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<void>((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.',
});
});
});
@@ -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<string, unknown>): 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<string, string>,
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<void>((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<void>((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');
});
});
@@ -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<void>((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<string, unknown> | undefined;
mock.module('vite', () => ({
createServer: async (config: Record<string, unknown>) => {
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);
});
});
@@ -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) {
>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
<option value="codex">Codex CLI</option>
</select>
</div>
@@ -353,6 +354,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
<option value="codex">Codex CLI</option>
</select>
</div>
@@ -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
>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
<option value="codex">Codex CLI</option>
</select>
</div>
@@ -433,6 +434,7 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
<option value="codex">Codex CLI</option>
</select>
</div>
@@ -85,6 +85,7 @@ export function HeaderSection({
<SelectContent>
<SelectItem value="claude">Claude Code</SelectItem>
<SelectItem value="droid">Factory Droid</SelectItem>
<SelectItem value="codex">Codex CLI</SelectItem>
</SelectContent>
</Select>
{isTargetSaving && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
@@ -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<CliTarget, string> = {
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 (
<div className="rounded-2xl border bg-muted/20 px-4 py-3" aria-live="polite">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="mt-2 h-3 w-52 animate-pulse rounded bg-muted" />
</div>
);
}
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 (
<section className="rounded-2xl border bg-background/95 px-4 py-3 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-300">
<ImageIcon className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">Image</h3>
<Badge className={cn('h-5 border px-1.5 text-[10px]', headerBadge.className)}>
{headerBadge.label}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{getPreviewLabel(source, previewState)} · {getHeaderLabel(effectiveStatus, target)}
</p>
</div>
</div>
</div>
<Button size="sm" variant="outline" className="h-8 shrink-0" asChild>
<Link to="/settings?tab=image">
Open Settings
<ArrowUpRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
</div>
<div className="mt-3 rounded-xl border bg-muted/15 px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium text-foreground">Use native image reading</div>
{capabilityLabel && (
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{capabilityLabel}
</Badge>
)}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{getToggleSummary(effectiveStatus, target)}
</p>
</div>
<Switch
checked={nativeReadChecked}
onCheckedChange={onToggleNativeRead}
disabled={!onToggleNativeRead}
aria-label="Use native image reading"
/>
</div>
</div>
{note && (
<div className="mt-2 rounded-lg border border-border/70 bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
{note}
</div>
)}
</section>
);
}
+94 -2
View File
@@ -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}
/>
@@ -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({
/>
</div>
</div>
<div className="mx-6 mb-4">
<ImageAnalysisStatusSection
status={imageAnalysisStatus}
target={profileTarget}
source={imageAnalysisStatusSource}
previewState={imageAnalysisStatusPreviewState}
nativeReadPreferenceOverride={nativeReadPreferenceOverride}
onToggleNativeRead={onToggleNativeRead}
/>
</div>
{/* Global Env Indicator */}
<div className="mx-6 mb-4">
<div className="border rounded-md overflow-hidden">
+5 -1
View File
@@ -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<string, string>;
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 {
@@ -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<typeof schema>;
@@ -521,6 +521,7 @@ export function ProfileCreateDialog({
<SelectContent>
<SelectItem value="claude">Claude Code (default)</SelectItem>
<SelectItem value="droid">Factory Droid</SelectItem>
<SelectItem value="codex">Codex CLI</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
@@ -529,6 +530,11 @@ export function ProfileCreateDialog({
{t('profileEditor.targetHintPreferredAlias')}{' '}
<code className="bg-muted px-1 rounded text-[10px]">ccs-droid</code>.
</>
) : targetValue === 'codex' ? (
<>
{t('profileEditor.targetHintPreferredAlias')}{' '}
<code className="bg-muted px-1 rounded text-[10px]">ccsx</code>.
</>
) : (
<>
{t('profileEditor.targetHintClaudeDefault')}{' '}
@@ -541,6 +547,12 @@ export function ProfileCreateDialog({
{t('profileEditor.targetHintLegacyAlias')}{' '}
<code className="bg-muted px-1 rounded text-[10px]">ccsd</code>.
</>
) : targetValue === 'codex' ? (
<>
{' '}
{t('profileEditor.targetHintLegacyAlias')}{' '}
<code className="bg-muted px-1 rounded text-[10px]">ccs-codex</code>.
</>
) : null}{' '}
{t('profileEditor.targetHintOverride')}{' '}
<code className="bg-muted px-1 rounded text-[10px]">--target</code>.
+1 -1
View File
@@ -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 = () => {
+129 -1
View File
@@ -99,7 +99,7 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
}
// 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<string, string>;
fallbackBackend: string | null;
profileBackends: Record<string, string>;
}
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<string, string | null>;
fallbackBackend?: string | null;
profileBackends?: Record<string, string>;
}
export interface Profile {
name: string;
settingsPath: string;
@@ -806,6 +926,14 @@ export const api = {
body: JSON.stringify(data),
}),
},
imageAnalysis: {
get: () => request<ImageAnalysisDashboardData>('/image-analysis'),
update: (data: UpdateImageAnalysisSettingsPayload) =>
request<ImageAnalysisDashboardData>('/image-analysis', {
method: 'PUT',
body: JSON.stringify(data),
}),
},
cliproxy: {
list: () => request<{ variants: Variant[] }>('/cliproxy'),
getAuthStatus: () =>
+4 -4
View File
@@ -150,19 +150,19 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
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',
},
},
],
+2 -2
View File
@@ -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',
@@ -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 (
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
<TabsList className="grid w-full grid-cols-7">
<TabsList className="grid w-full grid-cols-8">
{tabs.map(({ value, label, icon: Icon }) => (
<TabsTrigger key={value} value={value} className="gap-1.5 px-1 text-xs">
<TabsTrigger key={value} value={value} className="gap-1.5 px-2 text-xs">
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{label}</span>
</TabsTrigger>
+15 -13
View File
@@ -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) => {
+5 -2
View File
@@ -48,6 +48,7 @@ function lazyWithRetry<T extends ComponentType<unknown>>(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() {
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'image' && <ImageAnalysisSection />}
{activeTab === 'channels' && <ChannelsSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'thinking' && <ThinkingSection />}
@@ -144,7 +146,7 @@ function SettingsPageInner() {
{/* Desktop View - Side-by-side panels */}
<PanelGroup direction="horizontal" className="h-full hidden md:flex">
{/* Left Panel - Settings Controls */}
<Panel defaultSize={40} minSize={30} maxSize={55}>
<Panel defaultSize={46} minSize={36} maxSize={62}>
<div className="h-full border-r flex flex-col bg-muted/30 relative">
{/* Header with Tabs */}
<div className="p-5 border-b bg-background">
@@ -155,6 +157,7 @@ function SettingsPageInner() {
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'image' && <ImageAnalysisSection />}
{activeTab === 'channels' && <ChannelsSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'thinking' && <ThinkingSection />}
@@ -172,7 +175,7 @@ function SettingsPageInner() {
</PanelResizeHandle>
{/* Right Panel - Config Viewer */}
<Panel defaultSize={60} minSize={35}>
<Panel defaultSize={54} minSize={35}>
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-4 border-b bg-background flex items-center justify-between">
File diff suppressed because it is too large Load Diff
+1
View File
@@ -161,6 +161,7 @@ export interface OfficialChannelsStatus {
export type SettingsTab =
| 'websearch'
| 'image'
| 'channels'
| 'globalenv'
| 'proxy'
@@ -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 }) => (
<textarea
aria-label="raw config editor"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
vi.mock('@/components/profiles/editor/header-section', () => ({
HeaderSection: () => <div data-testid="profile-editor-header" />,
}));
vi.mock('@/components/profiles/editor/friendly-ui-section', () => ({
FriendlyUISection: () => <div data-testid="profile-editor-friendly-ui" />,
}));
vi.mock('@/components/shared/confirm-dialog', () => ({
ConfirmDialog: () => null,
}));
vi.mock('@/components/shared/global-env-indicator', () => ({
GlobalEnvIndicator: () => <div data-testid="global-env-indicator" />,
}));
import { ImageAnalysisStatusSection } from '@/components/profiles/editor/image-analysis-status-section';
import { ProfileEditor } from '@/components/profiles/editor';
function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalysisStatus {
return {
enabled: true,
supported: true,
status: 'active',
backendId: 'gemini',
backendDisplayName: 'Google Gemini',
model: 'gemini-3-flash-preview',
resolutionSource: 'cliproxy-bridge',
reason: null,
shouldPersistHook: true,
persistencePath: '/tmp/.ccs/glm.settings.json',
runtimePath: '/api/provider/gemini',
usesCurrentTarget: true,
usesCurrentAuthToken: true,
hookInstalled: true,
sharedHookInstalled: true,
authReadiness: 'ready',
authProvider: 'gemini',
authDisplayName: 'Google Gemini',
authReason: null,
proxyReadiness: 'ready',
proxyReason: 'Local CLIProxy service is reachable.',
effectiveRuntimeMode: 'cliproxy-image-analysis',
effectiveRuntimeReason: null,
profileModel: 'gemini-3-flash-preview',
nativeReadPreference: false,
nativeImageCapable: true,
nativeImageReason: 'gemini-3-flash-preview can read images natively.',
...overrides,
};
}
function createJsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('ImageAnalysisStatusSection', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('renders a compact saved summary with a settings link', () => {
render(<ImageAnalysisStatusSection status={createStatus()} />);
expect(screen.getByText('Image')).toBeInTheDocument();
expect(screen.getByText(/Saved status · Transformer ready/i)).toBeInTheDocument();
expect(screen.getByText('Ready')).toBeInTheDocument();
expect(screen.getByText('Use native image reading')).toBeInTheDocument();
expect(
screen.getByText(/Transformer route: Google Gemini · gemini-3-flash-preview\./i)
).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Open Settings/i })).toHaveAttribute(
'href',
'/settings?tab=image'
);
});
it('shows bypassed mode when the current target is not Claude Code', () => {
render(<ImageAnalysisStatusSection status={createStatus()} target="codex" />);
expect(screen.getByText('Bypassed')).toBeInTheDocument();
expect(screen.getByText(/Saved status · Codex CLI bypasses the hook/i)).toBeInTheDocument();
expect(
screen.getByText(/Transformer route: Google Gemini · gemini-3-flash-preview\./i)
).toBeInTheDocument();
expect(
screen.getByText(/Current target Codex CLI bypasses the Claude Read hook/i)
).toBeInTheDocument();
});
it('keeps auth failures visible without a long diagnostic wall', () => {
render(
<ImageAnalysisStatusSection
status={createStatus({
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
profileModel: 'claude-haiku-4.5',
authReadiness: 'missing',
authProvider: 'ghcp',
authDisplayName: 'GitHub Copilot (OAuth)',
authReason:
'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.',
effectiveRuntimeMode: 'native-read',
effectiveRuntimeReason:
'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.',
})}
/>
);
expect(screen.getByText('Auth')).toBeInTheDocument();
expect(
screen.getByText(/Transformer route: GitHub Copilot \(OAuth\) · claude-haiku-4.5\./i)
).toBeInTheDocument();
expect(
screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i).length
).toBeGreaterThanOrEqual(1);
});
it('calls the toggle handler immediately for native image reading', () => {
const onToggleNativeRead = vi.fn();
render(
<ImageAnalysisStatusSection status={createStatus()} onToggleNativeRead={onToggleNativeRead} />
);
fireEvent.click(screen.getByRole('switch', { name: /Use native image reading/i }));
expect(onToggleNativeRead).toHaveBeenCalledWith(true);
});
it('writes the native image preference into the raw settings json', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/settings/glm/raw')) {
return Promise.resolve(
createJsonResponse({
profile: 'glm',
settings: {
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
ANTHROPIC_AUTH_TOKEN: 'saved-token',
},
},
mtime: 1,
path: '/tmp/glm.settings.json',
imageAnalysisStatus: createStatus(),
})
);
}
if (url.includes('/api/settings/glm/image-analysis-status')) {
return Promise.resolve(
createJsonResponse({
imageAnalysisStatus: createStatus({
backendId: null,
backendDisplayName: null,
model: null,
resolutionSource: 'native-compatible',
supported: false,
shouldPersistHook: false,
runtimePath: null,
authReadiness: 'not-needed',
authProvider: null,
authDisplayName: null,
authReason: null,
proxyReadiness: 'not-needed',
proxyReason: null,
effectiveRuntimeMode: 'native-read',
nativeReadPreference: true,
}),
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
await screen.findByText(/Transformer route: Google Gemini/i);
fireEvent.click(screen.getByRole('switch', { name: /Use native image reading/i }));
await waitFor(() => {
expect((screen.getByLabelText('raw config editor') as HTMLTextAreaElement).value).toContain(
'"ccs_image"'
);
});
expect((screen.getByLabelText('raw config editor') as HTMLTextAreaElement).value).toContain(
'"native_read": true'
);
});
it('switches to live preview when editor JSON changes', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/api/settings/glm/raw')) {
return Promise.resolve(
createJsonResponse({
profile: 'glm',
settings: {
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
ANTHROPIC_AUTH_TOKEN: 'saved-token',
},
},
mtime: 1,
path: '/tmp/glm.settings.json',
imageAnalysisStatus: createStatus(),
})
);
}
if (url.includes('/api/settings/glm/image-analysis-status')) {
expect(init?.method).toBe('POST');
return Promise.resolve(
createJsonResponse({
imageAnalysisStatus: createStatus({
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
authReadiness: 'ready',
authProvider: 'ghcp',
authDisplayName: 'GitHub Copilot (OAuth)',
}),
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
});
vi.stubGlobal('fetch', fetchMock);
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: {
value: JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/ghcp',
ANTHROPIC_AUTH_TOKEN: 'preview-token',
},
},
null,
2
),
},
});
await waitFor(() => {
expect(screen.getByText(/Live preview/i)).toBeInTheDocument();
});
expect(screen.getByText(/GitHub Copilot \(OAuth\)/i)).toBeInTheDocument();
});
it('falls back to saved status messaging when the editor JSON is invalid', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/settings/glm/raw')) {
return Promise.resolve(
createJsonResponse({
profile: 'glm',
settings: {
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
ANTHROPIC_AUTH_TOKEN: 'saved-token',
},
},
mtime: 1,
path: '/tmp/glm.settings.json',
imageAnalysisStatus: createStatus(),
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: { value: '{' },
});
await waitFor(() => {
expect(screen.getByText(/Saved status/i)).toBeInTheDocument();
});
});
it('marks the preview as refreshing when a newer preview is still loading', async () => {
let secondPreviewResolver: ((value: Response) => void) | null = null;
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/api/settings/glm/raw')) {
return Promise.resolve(
createJsonResponse({
profile: 'glm',
settings: {
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
ANTHROPIC_AUTH_TOKEN: 'saved-token',
},
},
mtime: 1,
path: '/tmp/glm.settings.json',
imageAnalysisStatus: createStatus(),
})
);
}
if (url.includes('/api/settings/glm/image-analysis-status')) {
expect(init?.method).toBe('POST');
const body = JSON.parse(String(init?.body ?? '{}')) as {
settings?: { env?: Record<string, string> };
};
const baseUrl = body.settings?.env?.ANTHROPIC_BASE_URL ?? '';
if (baseUrl.includes('/ghcp')) {
return Promise.resolve(
createJsonResponse({
imageAnalysisStatus: createStatus({
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
authReadiness: 'ready',
authProvider: 'ghcp',
authDisplayName: 'GitHub Copilot (OAuth)',
}),
})
);
}
if (baseUrl.includes('/codex')) {
return new Promise<Response>((resolve) => {
secondPreviewResolver = resolve;
});
}
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
});
vi.stubGlobal('fetch', fetchMock);
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: {
value: JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/ghcp',
ANTHROPIC_AUTH_TOKEN: 'preview-token',
},
},
null,
2
),
},
});
expect(await screen.findByText(/GitHub Copilot \(OAuth\)/i)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: {
value: JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/codex',
ANTHROPIC_AUTH_TOKEN: 'preview-token-2',
},
},
null,
2
),
},
});
await waitFor(() => {
expect(screen.getByText(/Refreshing preview/i)).toBeInTheDocument();
});
secondPreviewResolver?.(
createJsonResponse({
imageAnalysisStatus: createStatus({
backendId: 'codex',
backendDisplayName: 'Codex',
model: 'gpt-5.4',
authReadiness: 'ready',
authProvider: 'codex',
authDisplayName: 'Codex',
}),
})
);
await waitFor(() => {
expect(screen.getByText(/Codex/i)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,337 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
import ImageAnalysisSection from '@/pages/settings/sections/image-analysis';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('ImageAnalysisSection', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
let payload = {
config: {
enabled: true,
timeout: 60,
providerModels: {
gemini: 'gemini-3-flash-preview',
ghcp: 'claude-haiku-4.5',
},
fallbackBackend: 'gemini',
profileBackends: {
codexProfile: 'ghcp',
},
},
summary: {
state: 'partial',
title: 'Partially ready',
detail:
'1 profile routes through Image on the current Claude target path. 1 prefer native image reading.',
backendCount: 2,
mappedProfileCount: 1,
activeProfileCount: 1,
bypassedProfileCount: 1,
nativeProfileCount: 1,
},
backends: [
{
backendId: 'gemini',
displayName: 'Google Gemini',
model: 'gemini-3-flash-preview',
state: 'ready',
authReadiness: 'ready',
authReason: null,
proxyReadiness: 'ready',
proxyReason: null,
profilesUsing: 1,
},
{
backendId: 'ghcp',
displayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
state: 'needs_auth',
authReadiness: 'missing',
authReason: 'Run ccs ghcp --auth',
proxyReadiness: 'ready',
proxyReason: null,
profilesUsing: 1,
},
],
profiles: [
{
name: 'glm',
kind: 'profile',
target: 'claude',
configured: true,
settingsPath: '/tmp/glm.settings.json',
backendId: 'gemini',
backendDisplayName: 'Google Gemini',
resolutionSource: 'cliproxy-bridge',
status: 'active',
effectiveRuntimeMode: 'cliproxy-image-analysis',
effectiveRuntimeReason: null,
currentTargetMode: 'active',
profileModel: 'gemini-3-flash-preview',
nativeReadPreference: false,
nativeImageCapable: true,
nativeImageReason: 'gemini-3-flash-preview can read images natively.',
},
{
name: 'codexProfile',
kind: 'profile',
target: 'codex',
configured: true,
settingsPath: '/tmp/codex.settings.json',
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
resolutionSource: 'profile-backend',
status: 'mapped',
effectiveRuntimeMode: 'cliproxy-image-analysis',
effectiveRuntimeReason: null,
currentTargetMode: 'bypassed',
profileModel: 'claude-haiku-4.5',
nativeReadPreference: true,
nativeImageCapable: true,
nativeImageReason: 'claude-haiku-4.5 can read images natively.',
},
],
catalog: {
knownBackends: ['gemini', 'ghcp', 'codex'],
profileNames: ['glm', 'codexProfile'],
},
};
fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
if (url === '/api/image-analysis' && method === 'GET') {
return jsonResponse(payload);
}
if (url === '/api/config/raw' && method === 'GET') {
return new Response('image_analysis:\n enabled: true\n');
}
if (url === '/api/image-analysis' && method === 'PUT') {
const body = JSON.parse(String(init?.body ?? '{}')) as {
enabled?: boolean;
timeout?: number;
fallbackBackend?: string;
profileBackends?: Record<string, string>;
providerModels?: Record<string, string | null>;
};
const providerModels = body.providerModels ?? {};
payload = {
...payload,
config: {
enabled: body.enabled ?? payload.config.enabled,
timeout: body.timeout ?? payload.config.timeout,
fallbackBackend:
'fallbackBackend' in body
? (body.fallbackBackend ?? null)
: payload.config.fallbackBackend,
providerModels: {
gemini:
'gemini' in providerModels
? (providerModels.gemini ?? '')
: payload.config.providerModels.gemini,
ghcp:
'ghcp' in providerModels
? (providerModels.ghcp ?? '')
: payload.config.providerModels.ghcp,
},
profileBackends: body.profileBackends ?? payload.config.profileBackends,
},
};
return jsonResponse(payload);
}
return jsonResponse({ error: `Unhandled request: ${method} ${url}` }, 500);
});
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('renders global controls and saves updated config', async () => {
render(<ImageAnalysisSection />, { withSettingsProvider: true });
expect(await screen.findByText('Image')).toBeInTheDocument();
expect(screen.getByText('Partially ready')).toBeInTheDocument();
expect(screen.getByText('Core setup')).toBeInTheDocument();
expect(screen.getAllByText('Native reading').length).toBeGreaterThan(0);
expect(screen.getByText('Profile routing')).toBeInTheDocument();
expect(screen.getAllByText('Coverage').length).toBeGreaterThan(0);
expect(screen.getAllByText('Bypassed').length).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
const timeoutInput = screen.getByDisplayValue('60');
await userEvent.clear(timeoutInput);
await userEvent.type(timeoutInput, '120');
await userEvent.tab();
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'/api/image-analysis',
expect.objectContaining({
method: 'PUT',
})
);
});
const putCall = fetchMock.mock.calls
.filter(
([url, init]) =>
url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT'
)
.at(-1);
expect(putCall).toBeDefined();
const requestBody = JSON.parse(String((putCall?.[1] as RequestInit | undefined)?.body ?? '{}'));
expect(requestBody).toMatchObject({
timeout: 120,
fallbackBackend: 'gemini',
profileBackends: {
codexProfile: 'ghcp',
},
});
expect(await screen.findByText('Image settings saved.')).toBeInTheDocument();
});
it('allows saving a disabled configuration even when every provider model is cleared', async () => {
render(<ImageAnalysisSection />, { withSettingsProvider: true });
await screen.findByDisplayValue('gemini-3-flash-preview');
await userEvent.click(screen.getByRole('switch'));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'/api/image-analysis',
expect.objectContaining({
method: 'PUT',
})
);
});
await waitFor(() => {
expect(screen.getByDisplayValue('gemini-3-flash-preview')).not.toBeDisabled();
});
fetchMock.mockClear();
await userEvent.click(screen.getAllByRole('button', { name: 'Clear' })[0]);
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'/api/image-analysis',
expect.objectContaining({
method: 'PUT',
})
);
});
await waitFor(() => {
expect(screen.getAllByRole('button', { name: 'Clear' })).toHaveLength(1);
});
fetchMock.mockClear();
await userEvent.click(screen.getByRole('button', { name: 'Clear' }));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'/api/image-analysis',
expect.objectContaining({
method: 'PUT',
})
);
});
const putCall = fetchMock.mock.calls
.filter(
([url, init]) =>
url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT'
)
.at(-1);
expect(putCall).toBeDefined();
const requestBody = JSON.parse(String((putCall?.[1] as RequestInit | undefined)?.body ?? '{}'));
expect(requestBody).toMatchObject({
enabled: false,
fallbackBackend: null,
providerModels: {
gemini: null,
ghcp: null,
},
});
});
it('auto-saves edits without rendering a dedicated save button', async () => {
const { container } = render(<ImageAnalysisSection />, { withSettingsProvider: true });
expect(await screen.findByText('Image')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument();
expect(container.firstElementChild).toHaveClass(
'relative',
'flex',
'min-h-0',
'flex-1',
'flex-col'
);
const timeoutInput = screen.getByDisplayValue('60');
await userEvent.clear(timeoutInput);
await userEvent.type(timeoutInput, '90');
await userEvent.tab();
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'/api/image-analysis',
expect.objectContaining({
method: 'PUT',
})
);
});
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
});
it('surfaces a clear retryable error when the backend route is not available yet', async () => {
fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
if (url === '/api/image-analysis' && method === 'GET') {
return new Response('<!doctype html><html></html>', {
status: 200,
headers: { 'Content-Type': 'text/html; charset=UTF-8' },
});
}
if (url === '/api/config/raw' && method === 'GET') {
return new Response('image_analysis:\n enabled: true\n');
}
return jsonResponse({ error: `Unhandled request: ${method} ${url}` }, 500);
});
render(<ImageAnalysisSection />, { withSettingsProvider: true });
expect(
await screen.findByText(
/Image settings returned an unexpected response\. Restart the dashboard server/i
)
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
});