mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 04:17:54 +00:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 : {};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>.
|
||||
|
||||
@@ -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
@@ -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: () =>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user