feat(image): add native-read controls and autosave settings

- add per-profile native image preferences and native-capability detection
- redesign Settings -> Image around compact sections with Web-style autosave
- expose richer backend/profile status and update Gemini Flash defaults
This commit is contained in:
Tam Nhu Tran
2026-04-01 15:32:37 -04:00
parent 9d1d281e34
commit 982ffc5895
30 changed files with 1893 additions and 708 deletions
+1 -1
View File
@@ -150,7 +150,7 @@ 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-analysis 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 raw JSON editor, CCS now shows a derived `Image-analysis backend` status block below the JSON viewer so you can see the configured backend, whether the hook is persisted, and whether the result is runtime-derived rather than saved in the profile JSON.
> **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.
+21
View File
@@ -9,6 +9,7 @@
* - WebSearch and ImageAnalysis hook integration
*/
import * as fs from 'fs';
import {
getEffectiveEnvVars,
getRemoteEnvVars,
@@ -29,6 +30,7 @@ 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;
@@ -129,17 +131,36 @@ 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
+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.
@@ -140,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:'));
+2 -2
View File
@@ -773,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',
@@ -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
}
@@ -7,10 +7,12 @@ import {
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'
@@ -20,6 +22,7 @@ export type ImageAnalysisResolutionSource =
| 'cliproxy-bridge'
| 'profile-backend'
| 'fallback-backend'
| 'native-compatible'
| 'disabled'
| 'unsupported-profile'
| 'unresolved'
@@ -49,7 +52,7 @@ export interface ImageAnalysisResolutionContext {
settingsPath?: string | null;
cliproxyProvider?: string | null;
isComposite?: boolean;
settings?: Pick<Settings, 'env'> | null;
settings?: Pick<Settings, 'env' | 'ccs_image'> | null;
cliproxyBridge?: CliproxyBridgeMetadata | null;
hookInstalled?: boolean;
sharedHookInstalled?: boolean;
@@ -79,8 +82,26 @@ export interface ImageAnalysisStatus {
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;
@@ -233,9 +254,102 @@ function getRuntimePath(backendId: string | null): string | null {
return `/api/provider/${backendId}`;
}
function resolveBackend(
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;
@@ -258,6 +372,30 @@ function resolveBackend(
};
}
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 {
@@ -300,16 +438,6 @@ function resolveBackend(
};
}
const mappedBackend = resolveConfiguredProfileBackend(profileName, config);
if (mappedBackend) {
return {
backendId: mappedBackend,
backendDisplayName: getBackendDisplayName(mappedBackend),
resolutionSource: 'profile-backend',
reason: null,
};
}
const hasDirectAnthropicApiKey = Boolean(settings?.env?.ANTHROPIC_API_KEY?.trim());
const hasBaseUrl = Boolean(settings?.env?.ANTHROPIC_BASE_URL?.trim());
if (hasDirectAnthropicApiKey && !hasBaseUrl) {
@@ -347,7 +475,8 @@ export function resolveImageAnalysisStatus(
rawConfig: ImageAnalysisConfig = DEFAULT_IMAGE_ANALYSIS_CONFIG
): ImageAnalysisStatus {
const config = canonicalizeImageAnalysisConfig(rawConfig);
const resolution = resolveBackend(context, config);
const nativeSupport = resolveNativeImageSupport(context, config);
const resolution = resolveBackend(context, config, nativeSupport);
const model = resolution.backendId
? (config.provider_models[resolution.backendId] ?? null)
: null;
@@ -444,5 +573,9 @@ export function resolveImageAnalysisStatus(
status === 'hook-missing' || !config.enabled || !resolution.backendId || !model
? reason
: null,
profileModel: nativeSupport.profileModel,
nativeReadPreference: nativeSupport.nativeReadPreference,
nativeImageCapable: nativeSupport.nativeImageCapable,
nativeImageReason: nativeSupport.nativeImageReason,
};
}
+30 -7
View File
@@ -27,7 +27,14 @@ const IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR =
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' | 'unresolved';
type CurrentTargetMode =
| 'active'
| 'bypassed'
| 'fallback'
| 'setup'
| 'disabled'
| 'native'
| 'unresolved';
interface ImageAnalysisRouteBody {
enabled?: boolean;
@@ -74,8 +81,9 @@ function resolveCurrentTargetMode(
status: Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
): CurrentTargetMode {
if (!status.enabled) return 'disabled';
if (!status.backendId) return 'unresolved';
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';
@@ -140,6 +148,10 @@ async function buildDashboardPayload() {
effectiveRuntimeMode: status.effectiveRuntimeMode,
effectiveRuntimeReason: status.effectiveRuntimeReason,
currentTargetMode: resolveCurrentTargetMode(resolveTarget(profile.target), status),
profileModel: status.profileModel,
nativeReadPreference: status.nativeReadPreference,
nativeImageCapable: status.nativeImageCapable,
nativeImageReason: status.nativeImageReason,
};
})
);
@@ -180,6 +192,10 @@ async function buildDashboardPayload() {
effectiveRuntimeMode: status.effectiveRuntimeMode,
effectiveRuntimeReason: status.effectiveRuntimeReason,
currentTargetMode: resolveCurrentTargetMode(resolveTarget(variant.target), status),
profileModel: status.profileModel,
nativeReadPreference: status.nativeReadPreference,
nativeImageCapable: status.nativeImageCapable,
nativeImageReason: status.nativeImageReason,
};
})
);
@@ -212,7 +228,9 @@ async function buildDashboardPayload() {
authReason: status.authReason,
proxyReadiness: status.proxyReadiness,
proxyReason: status.proxyReason,
profilesUsing: allProfileRows.filter((profile) => profile.backendId === backendId).length,
profilesUsing: allProfileRows.filter(
(profile) => profile.backendId === backendId && !profile.nativeReadPreference
).length,
};
})
);
@@ -226,23 +244,27 @@ async function buildDashboardPayload() {
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'} can use Image Analysis on the current Claude target path.`;
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 Analysis is turned off globally. Images and PDFs fall back to native file access.';
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 Analysis on for profiles.';
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';
@@ -265,6 +287,7 @@ async function buildDashboardPayload() {
mappedProfileCount,
activeProfileCount,
bypassedProfileCount,
nativeProfileCount,
},
backends: backendRows,
profiles: allProfileRows,
+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);
+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);
});
});
@@ -49,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', () => {
@@ -79,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');
@@ -218,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',
@@ -244,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');
@@ -62,7 +62,7 @@ describe('image-analysis-backend-resolver', () => {
expect(status.supported).toBe(true);
expect(status.backendId).toBe('gemini');
expect(status.resolutionSource).toBe('fallback-backend');
expect(status.model).toBe('gemini-2.5-flash');
expect(status.model).toBe('gemini-3-flash-preview');
});
it('keeps direct Anthropic settings profiles on native read unless explicitly mapped', () => {
@@ -108,6 +108,29 @@ describe('image-analysis-backend-resolver', () => {
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(
{
@@ -128,4 +151,32 @@ describe('image-analysis-backend-resolver', () => {
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');
});
});
@@ -27,6 +27,10 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
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,
};
}
@@ -101,7 +101,7 @@ describe('image-analysis routes', () => {
enabled: true,
timeout: 60,
provider_models: {
gemini: 'gemini-2.5-flash',
gemini: 'gemini-3-flash-preview',
ghcp: 'claude-haiku-4.5',
},
fallback_backend: 'gemini',
@@ -158,7 +158,7 @@ describe('image-analysis routes', () => {
expect.arrayContaining([
expect.objectContaining({
backendId: 'gemini',
model: 'gemini-2.5-flash',
model: 'gemini-3-flash-preview',
}),
expect.objectContaining({
backendId: 'ghcp',
@@ -227,7 +227,7 @@ describe('image-analysis routes', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerModels: {
gemini: 'gemini-2.5-flash',
gemini: 'gemini-3-flash-preview',
},
fallbackBackend: 'gemini',
profileBackends: {
@@ -111,7 +111,7 @@ describe('settings-routes image-analysis status', () => {
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-2.5-flash');
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');
@@ -255,4 +255,38 @@ describe('settings-routes image-analysis status', () => {
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,7 +1,8 @@
import { AlertTriangle, ArrowUpRight, Image as ImageIcon } from 'lucide-react';
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';
@@ -10,158 +11,122 @@ interface ImageAnalysisStatusSectionProps {
target?: CliTarget;
source?: 'saved' | 'editor';
previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
nativeReadPreferenceOverride?: boolean;
onToggleNativeRead?: (enabled: boolean) => void;
}
const SOURCE_LABELS: Record<ImageAnalysisStatus['resolutionSource'], string> = {
'cliproxy-provider': 'Direct provider route',
'cliproxy-variant': 'Variant route',
'cliproxy-composite': 'Composite route',
'copilot-alias': 'Copilot alias',
'cliproxy-bridge': 'Derived from profile route',
'profile-backend': 'Explicit profile mapping',
'fallback-backend': 'Fallback backend',
disabled: 'Disabled globally',
'unsupported-profile': 'Unsupported profile type',
unresolved: 'No backend mapped',
'missing-model': 'Missing model',
};
const TARGET_LABELS: Record<CliTarget, string> = {
claude: 'Claude Code',
droid: 'Factory Droid',
codex: 'Codex CLI',
};
function getPreviewBadge(
function getPreviewLabel(
source: 'saved' | 'editor',
previewState: ImageAnalysisStatusSectionProps['previewState']
) {
if (previewState === 'refreshing') {
return { label: 'Refreshing', variant: 'outline' as const };
}
if (source === 'editor') {
return { label: 'Live Preview', variant: 'secondary' as const };
}
return { label: 'Saved', variant: 'outline' as const };
if (previewState === 'refreshing') return 'Refreshing preview';
if (previewState === 'invalid') return 'Saved status';
return source === 'editor' ? 'Live preview' : 'Saved status';
}
function getRuntimeBadge(status: ImageAnalysisStatus, target: CliTarget) {
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 (status.status === 'hook-missing') {
return {
label: 'Setup needed',
className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
};
}
if (status.authReadiness === 'missing') {
return {
label: 'Needs auth',
className: 'border-rose-500/25 bg-rose-500/10 text-rose-800 dark:text-rose-200',
};
}
if (status.proxyReadiness === 'unavailable') {
return {
label: 'Needs proxy',
className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
};
}
if (target !== 'claude') {
return {
label: 'Bypassed',
className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200',
};
}
if (status.effectiveRuntimeMode === 'native-read') {
if (status.nativeReadPreference) {
return {
label: 'Native fallback',
className: 'border-border/80 bg-background/85 text-muted-foreground',
label: 'Native',
className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
};
}
if (status.proxyReadiness === 'stopped') {
if (status.status === 'hook-missing' || status.authReadiness === 'missing') {
return {
label: 'Starts on launch',
className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200',
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: 'CLIProxy active',
label: 'Ready',
className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
};
}
function getStatusContext(
source: 'saved' | 'editor',
previewState: ImageAnalysisStatusSectionProps['previewState']
) {
if (previewState === 'invalid') {
return 'Showing saved status until the JSON above is valid again.';
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 (previewState === 'refreshing') {
return 'Refreshing from the current editor state.';
if (!status.backendDisplayName && target === 'claude') {
return 'This profile currently stays on native file access.';
}
return source === 'editor'
? 'Preview from the current editor JSON.'
: 'Saved status for this profile.';
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 getSummary(status: ImageAnalysisStatus, target: CliTarget): string {
const backendName = status.backendDisplayName || status.backendId || 'native file access';
function getExceptionalNote(status: ImageAnalysisStatus, target: CliTarget): string | null {
if (status.status === 'disabled') {
return 'Image Analysis is disabled globally for this profile.';
}
if (!status.backendId) {
return target === 'claude'
? 'This profile currently uses native file access for images and PDFs.'
: `Current target ${TARGET_LABELS[target]} bypasses the hook and no saved backend is mapped.`;
return 'Image is disabled globally in CCS settings.';
}
if (target !== 'claude') {
return status.effectiveRuntimeMode === 'native-read'
? `Current target ${TARGET_LABELS[target]} bypasses the hook. Saved Claude-side setup for ${backendName} currently falls back to native file access.`
: `Current target ${TARGET_LABELS[target]} bypasses the hook. Saved Claude-side backend: ${backendName}.`;
return `Current target ${TARGET_LABELS[target]} bypasses the Claude Read hook.`;
}
if (status.effectiveRuntimeMode === 'native-read') {
return `Saved backend: ${backendName}. Current runtime falls back to native file access.`;
}
return `Saved backend: ${backendName}. Images and PDFs resolve through CLIProxy on this target.`;
}
function getTargetDetail(status: ImageAnalysisStatus, target: CliTarget): string {
if (target !== 'claude') {
return 'Current launch path bypasses the Claude Read hook.';
if (status.nativeReadPreference) {
return status.nativeImageCapable === true ? null : status.nativeImageReason;
}
if (status.status === 'hook-missing') {
return 'Hook must be persisted before this profile can use Image Analysis.';
return 'Persist the profile hook before transformer routing can run here.';
}
if (status.effectiveRuntimeMode === 'native-read') {
return status.effectiveRuntimeReason || status.reason || 'Using native file access.';
if (status.authReadiness === 'missing') {
return status.authReason;
}
if (status.proxyReadiness === 'stopped') {
return 'Auth is ready. Local CLIProxy will start on demand.';
if (status.proxyReadiness === 'unavailable') {
return status.proxyReason;
}
return 'Current target can use the saved backend.';
}
function getPersistenceValue(status: ImageAnalysisStatus): string {
if (!status.shouldPersistHook || !status.persistencePath) {
return 'Not required';
}
return status.hookInstalled ? 'Hook saved' : 'Hook missing';
}
function getPersistenceDetail(status: ImageAnalysisStatus): string {
if (!status.shouldPersistHook || !status.persistencePath) {
return 'No profile-level hook persistence required.';
}
return status.persistencePath;
}
function getModelValue(status: ImageAnalysisStatus): string {
return status.model || status.reason || 'Unavailable';
return null;
}
export function ImageAnalysisStatusSection({
@@ -169,147 +134,88 @@ export function ImageAnalysisStatusSection({
target = 'claude',
source = 'saved',
previewState = 'saved',
nativeReadPreferenceOverride,
onToggleNativeRead,
}: ImageAnalysisStatusSectionProps) {
if (!status) {
return (
<div className="rounded-md border bg-muted/20 p-4" aria-live="polite">
<div className="h-4 w-40 animate-pulse rounded bg-muted" />
<div className="mt-2 h-3 w-64 animate-pulse rounded bg-muted" />
<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 previewBadge = getPreviewBadge(source, previewState);
const runtimeBadge = getRuntimeBadge(status, target);
const showNotice =
status.status === 'hook-missing' ||
status.authReadiness === 'missing' ||
status.proxyReadiness === 'unavailable';
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-md border bg-muted/20 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<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">
<ImageIcon className="h-4 w-4 text-sky-600" />
<h3 className="text-sm font-semibold">Image Analysis</h3>
<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>
<p className="mt-1 text-xs text-muted-foreground">
{getStatusContext(source, previewState)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={previewBadge.variant} className="h-5 px-1.5 text-[10px]">
{previewBadge.label}
</Badge>
<Badge className={cn('h-5 border px-1.5 text-[10px]', runtimeBadge.className)}>
{runtimeBadge.label}
</Badge>
</div>
</div>
<p className="mt-3 text-sm leading-6 text-muted-foreground">{getSummary(status, target)}</p>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<div className="rounded-md border bg-background/70 p-3">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Backend
</div>
<div className="mt-2 text-sm font-medium text-foreground">
{status.backendDisplayName || status.backendId || 'Native file access'}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{SOURCE_LABELS[status.resolutionSource]}
</p>
</div>
<div className="rounded-md border bg-background/70 p-3">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Current target
</div>
<div className="mt-2 text-sm font-medium text-foreground">{TARGET_LABELS[target]}</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{getTargetDetail(status, target)}
</p>
</div>
<div className="rounded-md border bg-background/70 p-3">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Persistence
</div>
<div className="mt-2 text-sm font-medium text-foreground">
{getPersistenceValue(status)}
</div>
<p
className="mt-1 text-xs leading-5 text-muted-foreground"
title={status.persistencePath || 'Not required'}
>
{getPersistenceDetail(status)}
</p>
</div>
</div>
<dl className="mt-4 grid gap-x-4 gap-y-3 sm:grid-cols-3">
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Auth
</dt>
<dd className="text-sm text-foreground">
{status.authReadiness === 'ready'
? `${status.authDisplayName || status.authProvider} ready`
: status.authReadiness === 'missing'
? status.authReason
: status.authReadiness === 'not-needed'
? 'Not required'
: 'Unknown'}
</dd>
</div>
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Proxy
</dt>
<dd className="text-sm text-foreground">
{status.proxyReadiness === 'ready'
? 'Local CLIProxy ready'
: status.proxyReadiness === 'remote'
? 'Remote CLIProxy ready'
: status.proxyReadiness === 'stopped'
? 'Local CLIProxy idle'
: status.proxyReadiness === 'not-needed'
? 'Not required'
: status.proxyReason || 'Unknown'}
</dd>
</div>
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Model
</dt>
<dd className={cn('text-sm text-foreground', status.model && 'font-mono text-xs')}>
{getModelValue(status)}
</dd>
</div>
</dl>
{showNotice && (
<div className="mt-4 flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<span>
{status.effectiveRuntimeReason || status.reason || 'Review the saved configuration.'}
</span>
</div>
)}
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-3">
<p className="text-xs text-muted-foreground">
CRUD and backend routing now live in global Settings.
</p>
<Button size="sm" variant="outline" asChild>
<Link to="/settings?tab=imageanalysis">
<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>
);
}
@@ -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 };
@@ -165,6 +190,7 @@ export function ProfileEditor({
(!previewStatusResponse?.imageAnalysisStatus || isPreviewStatusPlaceholderData)
? 'refreshing'
: 'preview';
const nativeReadPreferenceOverride = currentSettings?.ccs_image?.native_read === true;
// Check for missing required fields (informational warning)
const missingRequiredFields = useMemo(() => {
@@ -318,6 +344,8 @@ export function ProfileEditor({
imageAnalysisStatus={imageAnalysisStatus}
imageAnalysisStatusSource={imageAnalysisStatusSource}
imageAnalysisStatusPreviewState={imageAnalysisStatusPreviewState}
nativeReadPreferenceOverride={nativeReadPreferenceOverride}
onToggleNativeRead={updateNativeImageRead}
onChange={handleRawJsonChange}
missingRequiredFields={missingRequiredFields}
/>
@@ -24,6 +24,8 @@ interface RawEditorSectionProps {
imageAnalysisStatus?: ImageAnalysisStatus | null;
imageAnalysisStatusSource?: 'saved' | 'editor';
imageAnalysisStatusPreviewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
nativeReadPreferenceOverride?: boolean;
onToggleNativeRead?: (enabled: boolean) => void;
onChange: (value: string) => void;
missingRequiredFields?: string[];
}
@@ -37,6 +39,8 @@ export function RawEditorSection({
imageAnalysisStatus,
imageAnalysisStatusSource = 'saved',
imageAnalysisStatusPreviewState = 'saved',
nativeReadPreferenceOverride,
onToggleNativeRead,
onChange,
missingRequiredFields = [],
}: RawEditorSectionProps) {
@@ -91,6 +95,8 @@ export function RawEditorSection({
target={profileTarget}
source={imageAnalysisStatusSource}
previewState={imageAnalysisStatusPreviewState}
nativeReadPreferenceOverride={nativeReadPreferenceOverride}
onToggleNativeRead={onToggleNativeRead}
/>
</div>
{/* Global Env Indicator */}
@@ -6,6 +6,9 @@ import type { CliTarget, CliproxyBridgeMetadata, ImageAnalysisStatus } from '@/l
export interface Settings {
env?: Record<string, string>;
ccs_image?: {
native_read?: boolean;
};
}
export interface SettingsResponse {
+18 -1
View File
@@ -126,6 +126,7 @@ export interface ImageAnalysisStatus {
| 'cliproxy-bridge'
| 'profile-backend'
| 'fallback-backend'
| 'native-compatible'
| 'disabled'
| 'unsupported-profile'
| 'unresolved'
@@ -146,6 +147,10 @@ export interface ImageAnalysisStatus {
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 {
@@ -164,6 +169,7 @@ export interface ImageAnalysisDashboardSummary {
mappedProfileCount: number;
activeProfileCount: number;
bypassedProfileCount: number;
nativeProfileCount: number;
}
export interface ImageAnalysisDashboardBackend {
@@ -190,7 +196,18 @@ export interface ImageAnalysisDashboardProfile {
status: ImageAnalysisStatus['status'];
effectiveRuntimeMode: ImageAnalysisStatus['effectiveRuntimeMode'];
effectiveRuntimeReason: string | null;
currentTargetMode: 'active' | 'bypassed' | 'fallback' | 'setup' | 'disabled' | 'unresolved';
currentTargetMode:
| 'active'
| 'bypassed'
| 'fallback'
| 'setup'
| 'disabled'
| 'native'
| 'unresolved';
profileModel: string | null;
nativeReadPreference: boolean;
nativeImageCapable: boolean | null;
nativeImageReason: string | null;
}
export interface ImageAnalysisDashboardCatalog {
+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',
},
},
],
@@ -26,7 +26,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
const { t } = useTranslation();
const tabs = [
{ value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe },
{ value: 'imageanalysis' as const, label: 'Image Analysis', icon: ImageIcon },
{ 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 },
@@ -39,7 +39,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
<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>
@@ -11,8 +11,8 @@ 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 === 'imageanalysis'
? 'imageanalysis'
tabParam === 'imageanalysis' || tabParam === 'image'
? 'image'
: tabParam === 'channels'
? 'channels'
: tabParam === 'globalenv'
+4 -4
View File
@@ -132,7 +132,7 @@ function SettingsPageInner() {
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'imageanalysis' && <ImageAnalysisSection />}
{activeTab === 'image' && <ImageAnalysisSection />}
{activeTab === 'channels' && <ChannelsSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'thinking' && <ThinkingSection />}
@@ -146,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">
@@ -157,7 +157,7 @@ function SettingsPageInner() {
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'imageanalysis' && <ImageAnalysisSection />}
{activeTab === 'image' && <ImageAnalysisSection />}
{activeTab === 'channels' && <ChannelsSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'thinking' && <ThinkingSection />}
@@ -175,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 -1
View File
@@ -161,7 +161,7 @@ export interface OfficialChannelsStatus {
export type SettingsTab =
| 'websearch'
| 'imageanalysis'
| 'image'
| 'channels'
| 'globalenv'
| 'proxy'
@@ -38,7 +38,7 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
status: 'active',
backendId: 'gemini',
backendDisplayName: 'Google Gemini',
model: 'gemini-2.5-flash',
model: 'gemini-3-flash-preview',
resolutionSource: 'cliproxy-bridge',
reason: null,
shouldPersistHook: true,
@@ -56,6 +56,10 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
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,
};
}
@@ -80,18 +84,16 @@ describe('ImageAnalysisStatusSection', () => {
it('renders a compact saved summary with a settings link', () => {
render(<ImageAnalysisStatusSection status={createStatus()} />);
expect(screen.getByText('Image Analysis')).toBeInTheDocument();
expect(screen.getByText('Saved')).toBeInTheDocument();
expect(screen.getByText('CLIProxy active')).toBeInTheDocument();
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(/Saved backend: Google Gemini\. Images and PDFs resolve through CLIProxy/i)
screen.getByText(/Transformer route: Google Gemini · gemini-3-flash-preview\./i)
).toBeInTheDocument();
expect(screen.getByText('Backend')).toBeInTheDocument();
expect(screen.getByText('Current target')).toBeInTheDocument();
expect(screen.getByText('Persistence')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Open Settings/i })).toHaveAttribute(
'href',
'/settings?tab=imageanalysis'
'/settings?tab=image'
);
});
@@ -99,12 +101,12 @@ describe('ImageAnalysisStatusSection', () => {
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(/Current target Codex CLI bypasses the hook\. Saved Claude-side backend/i)
screen.getByText(/Transformer route: Google Gemini · gemini-3-flash-preview\./i)
).toBeInTheDocument();
expect(screen.getByText('Codex CLI')).toBeInTheDocument();
expect(
screen.getByText(/Current launch path bypasses the Claude Read hook/i)
screen.getByText(/Current target Codex CLI bypasses the Claude Read hook/i)
).toBeInTheDocument();
});
@@ -114,6 +116,8 @@ describe('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)',
@@ -126,15 +130,94 @@ describe('ImageAnalysisStatusSection', () => {
/>
);
expect(screen.getByText('Needs auth')).toBeInTheDocument();
expect(screen.getByText('Auth')).toBeInTheDocument();
expect(
screen.getByText(/Saved backend: GitHub Copilot \(OAuth\)\. Current runtime falls back/i)
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);
@@ -179,7 +262,7 @@ describe('ImageAnalysisStatusSection', () => {
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: {
@@ -197,10 +280,9 @@ describe('ImageAnalysisStatusSection', () => {
});
await waitFor(() => {
expect(screen.getByText('Live Preview')).toBeInTheDocument();
expect(screen.getByText(/Live preview/i)).toBeInTheDocument();
});
expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
expect(screen.getByText(/Preview from the current editor JSON/i)).toBeInTheDocument();
expect(screen.getByText(/GitHub Copilot \(OAuth\)/i)).toBeInTheDocument();
});
it('falls back to saved status messaging when the editor JSON is invalid', async () => {
@@ -232,16 +314,14 @@ describe('ImageAnalysisStatusSection', () => {
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: { value: '{' },
});
await waitFor(() => {
expect(
screen.getByText(/Showing saved status until the JSON above is valid again/i)
).toBeInTheDocument();
expect(screen.getByText(/Saved status/i)).toBeInTheDocument();
});
});
@@ -303,7 +383,7 @@ describe('ImageAnalysisStatusSection', () => {
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: {
@@ -320,7 +400,7 @@ describe('ImageAnalysisStatusSection', () => {
},
});
expect(await screen.findByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
expect(await screen.findByText(/GitHub Copilot \(OAuth\)/i)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: {
@@ -338,9 +418,8 @@ describe('ImageAnalysisStatusSection', () => {
});
await waitFor(() => {
expect(screen.getByText('Refreshing')).toBeInTheDocument();
expect(screen.getByText(/Refreshing preview/i)).toBeInTheDocument();
});
expect(screen.getByText(/Refreshing from the current editor state/i)).toBeInTheDocument();
secondPreviewResolver?.(
createJsonResponse({
@@ -356,7 +435,7 @@ describe('ImageAnalysisStatusSection', () => {
);
await waitFor(() => {
expect(screen.getByText('Codex')).toBeInTheDocument();
expect(screen.getByText(/Codex/i)).toBeInTheDocument();
});
});
});
@@ -18,7 +18,7 @@ describe('ImageAnalysisSection', () => {
enabled: true,
timeout: 60,
providerModels: {
gemini: 'gemini-2.5-flash',
gemini: 'gemini-3-flash-preview',
ghcp: 'claude-haiku-4.5',
},
fallbackBackend: 'gemini',
@@ -30,17 +30,18 @@ describe('ImageAnalysisSection', () => {
state: 'partial',
title: 'Partially ready',
detail:
'1 backend still needs auth, runtime, or review before every profile path is healthy.',
'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-2.5-flash',
model: 'gemini-3-flash-preview',
state: 'ready',
authReadiness: 'ready',
authReason: null,
@@ -74,6 +75,10 @@ describe('ImageAnalysisSection', () => {
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',
@@ -88,6 +93,10 @@ describe('ImageAnalysisSection', () => {
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: {
@@ -110,21 +119,32 @@ describe('ImageAnalysisSection', () => {
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: true,
enabled: body.enabled ?? payload.config.enabled,
timeout: body.timeout ?? payload.config.timeout,
fallbackBackend: body.fallbackBackend ?? payload.config.fallbackBackend,
fallbackBackend:
'fallbackBackend' in body
? (body.fallbackBackend ?? null)
: payload.config.fallbackBackend,
providerModels: {
gemini: String(body.providerModels?.gemini ?? payload.config.providerModels.gemini),
ghcp: String(body.providerModels?.ghcp ?? payload.config.providerModels.ghcp),
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,
},
@@ -146,18 +166,19 @@ describe('ImageAnalysisSection', () => {
it('renders global controls and saves updated config', async () => {
render(<ImageAnalysisSection />, { withSettingsProvider: true });
expect(await screen.findByText('Image Analysis')).toBeInTheDocument();
expect(await screen.findByText('Image')).toBeInTheDocument();
expect(screen.getByText('Partially ready')).toBeInTheDocument();
expect(screen.getByText('Provider models')).toBeInTheDocument();
expect(screen.getByText('Profile mappings')).toBeInTheDocument();
expect(screen.getByText('Profile coverage')).toBeInTheDocument();
expect(screen.getByText('Bypassed')).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.click(screen.getByRole('button', { name: 'Save changes' }));
await userEvent.tab();
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
@@ -168,10 +189,12 @@ describe('ImageAnalysisSection', () => {
);
});
const putCall = fetchMock.mock.calls.find(
([url, init]) =>
url === '/api/image-analysis' && (init as RequestInit | undefined)?.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 ?? '{}'));
@@ -183,7 +206,97 @@ describe('ImageAnalysisSection', () => {
},
});
expect(await screen.findByText('Image Analysis settings saved.')).toBeInTheDocument();
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 () => {
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();
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 () => {
@@ -209,7 +322,7 @@ describe('ImageAnalysisSection', () => {
expect(
await screen.findByText(
/Image Analysis settings returned an unexpected response\. Restart the dashboard server/i
/Image settings returned an unexpected response\. Restart the dashboard server/i
)
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();