mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 02:16:27 +00:00
feat(image): add native-read controls and autosave settings
- add per-profile native image preferences and native-capability detection - redesign Settings -> Image around compact sections with Web-style autosave - expose richer backend/profile status and update Gemini Flash defaults
This commit is contained in:
1 parent
9d1d281e34
commit
982ffc5895
30 files changed
+1893
-708
No files matched your search
@@ -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);
|
||||
|
||||
@@ -100,9 +100,17 @@ describe('Model Catalog', () => {
|
||||
assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier');
|
||||
});
|
||||
|
||||
it('has 3 models total', () => {
|
||||
it('includes Gemini Flash via Antigravity', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.agy.models.length, 3);
|
||||
const flash = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3-1-flash-preview');
|
||||
assert(flash, 'Should include Gemini Flash');
|
||||
assert.strictEqual(flash.name, 'Gemini Flash');
|
||||
assert.strictEqual(flash.tier, undefined, 'AGY models should not have paid tier');
|
||||
});
|
||||
|
||||
it('has 4 models total', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.agy.models.length, 4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,9 +163,17 @@ describe('Model Catalog', () => {
|
||||
assert.strictEqual(gem25.tier, undefined);
|
||||
});
|
||||
|
||||
it('has 2 models total', () => {
|
||||
it('includes Gemini Flash with pro tier', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.gemini.models.length, 2);
|
||||
const flash = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3-flash-preview');
|
||||
assert(flash, 'Should include Gemini Flash');
|
||||
assert.strictEqual(flash.name, 'Gemini Flash');
|
||||
assert.strictEqual(flash.tier, 'pro');
|
||||
});
|
||||
|
||||
it('has 3 models total', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.gemini.models.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -49,13 +49,13 @@ image_analysis:
|
||||
enabled: true
|
||||
timeout: 60
|
||||
provider_models:
|
||||
agy: gemini-2.5-flash
|
||||
agy: gemini-3-1-flash-preview
|
||||
`);
|
||||
|
||||
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
|
||||
expect(content).toContain('enabled: true');
|
||||
expect(content).toContain('timeout: 60');
|
||||
expect(content).toContain('agy: gemini-2.5-flash');
|
||||
expect(content).toContain('agy: gemini-3-1-flash-preview');
|
||||
});
|
||||
|
||||
it('should parse disabled status from config.yaml', () => {
|
||||
@@ -79,14 +79,14 @@ image_analysis:
|
||||
enabled: true
|
||||
timeout: 60
|
||||
provider_models:
|
||||
agy: gemini-2.5-flash
|
||||
agy: gemini-3-1-flash-preview
|
||||
gemini: gemini-2.5-pro
|
||||
codex: gpt-5.1-codex-mini
|
||||
kiro: kiro-claude-haiku-4-5
|
||||
`);
|
||||
|
||||
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
|
||||
expect(content).toContain('agy: gemini-2.5-flash');
|
||||
expect(content).toContain('agy: gemini-3-1-flash-preview');
|
||||
expect(content).toContain('gemini: gemini-2.5-pro');
|
||||
expect(content).toContain('codex: gpt-5.1-codex-mini');
|
||||
expect(content).toContain('kiro: kiro-claude-haiku-4-5');
|
||||
@@ -218,8 +218,8 @@ image_analysis:
|
||||
enabled: true,
|
||||
timeout: 60,
|
||||
provider_models: {
|
||||
agy: 'gemini-2.5-flash',
|
||||
gemini: 'gemini-2.5-flash',
|
||||
agy: 'gemini-3-1-flash-preview',
|
||||
gemini: 'gemini-3-flash-preview',
|
||||
codex: 'gpt-5.1-codex-mini',
|
||||
kiro: 'kiro-claude-haiku-4-5',
|
||||
ghcp: 'claude-haiku-4.5',
|
||||
@@ -244,7 +244,7 @@ image_analysis:
|
||||
enabled: true
|
||||
timeout: 60
|
||||
provider_models:
|
||||
agy: gemini-2.5-flash
|
||||
agy: gemini-3-1-flash-preview
|
||||
`);
|
||||
|
||||
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('image-analysis-backend-resolver', () => {
|
||||
expect(status.supported).toBe(true);
|
||||
expect(status.backendId).toBe('gemini');
|
||||
expect(status.resolutionSource).toBe('fallback-backend');
|
||||
expect(status.model).toBe('gemini-2.5-flash');
|
||||
expect(status.model).toBe('gemini-3-flash-preview');
|
||||
});
|
||||
|
||||
it('keeps direct Anthropic settings profiles on native read unless explicitly mapped', () => {
|
||||
@@ -108,6 +108,29 @@ describe('image-analysis-backend-resolver', () => {
|
||||
expect(status.resolutionSource).toBe('profile-backend');
|
||||
});
|
||||
|
||||
it('lets explicit profile_backends overrides win over cliproxy provider inference', () => {
|
||||
const config: ImageAnalysisConfig = {
|
||||
...DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
||||
profile_backends: {
|
||||
glmv: 'ghcp',
|
||||
},
|
||||
};
|
||||
|
||||
const status = resolveImageAnalysisStatus(
|
||||
{
|
||||
profileName: 'glmv',
|
||||
profileType: 'cliproxy',
|
||||
cliproxyProvider: 'gemini',
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(status.supported).toBe(true);
|
||||
expect(status.status).toBe('mapped');
|
||||
expect(status.backendId).toBe('ghcp');
|
||||
expect(status.resolutionSource).toBe('profile-backend');
|
||||
});
|
||||
|
||||
it('reports hook-missing when the profile should persist a hook but it is absent', () => {
|
||||
const status = resolveImageAnalysisStatus(
|
||||
{
|
||||
@@ -128,4 +151,32 @@ describe('image-analysis-backend-resolver', () => {
|
||||
expect(status.status).toBe('hook-missing');
|
||||
expect(status.reason).toContain('Profile hook is missing');
|
||||
});
|
||||
|
||||
it('prefers native image reading when the profile settings opt into it', () => {
|
||||
const status = resolveImageAnalysisStatus(
|
||||
{
|
||||
profileName: 'glmv',
|
||||
profileType: 'settings',
|
||||
settings: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
|
||||
ANTHROPIC_MODEL: 'glm-4.5v',
|
||||
ANTHROPIC_AUTH_TOKEN: 'glm-test-key',
|
||||
},
|
||||
ccs_image: {
|
||||
native_read: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
DEFAULT_IMAGE_ANALYSIS_CONFIG
|
||||
);
|
||||
|
||||
expect(status.backendId).toBeNull();
|
||||
expect(status.resolutionSource).toBe('native-compatible');
|
||||
expect(status.nativeReadPreference).toBe(true);
|
||||
expect(status.profileModel).toBe('glm-4.5v');
|
||||
expect(status.nativeImageCapable).toBe(true);
|
||||
expect(status.shouldPersistHook).toBe(false);
|
||||
expect(status.effectiveRuntimeMode).toBe('native-read');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,10 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
|
||||
proxyReason: 'CLIProxy runtime readiness has not been verified yet.',
|
||||
effectiveRuntimeMode: 'native-read',
|
||||
effectiveRuntimeReason: null,
|
||||
profileModel: 'claude-haiku-4.5',
|
||||
nativeReadPreference: false,
|
||||
nativeImageCapable: true,
|
||||
nativeImageReason: 'claude-haiku-4.5 can read images natively.',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('image-analysis routes', () => {
|
||||
enabled: true,
|
||||
timeout: 60,
|
||||
provider_models: {
|
||||
gemini: 'gemini-2.5-flash',
|
||||
gemini: 'gemini-3-flash-preview',
|
||||
ghcp: 'claude-haiku-4.5',
|
||||
},
|
||||
fallback_backend: 'gemini',
|
||||
@@ -158,7 +158,7 @@ describe('image-analysis routes', () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
backendId: 'gemini',
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-3-flash-preview',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
backendId: 'ghcp',
|
||||
@@ -227,7 +227,7 @@ describe('image-analysis routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
providerModels: {
|
||||
gemini: 'gemini-2.5-flash',
|
||||
gemini: 'gemini-3-flash-preview',
|
||||
},
|
||||
fallbackBackend: 'gemini',
|
||||
profileBackends: {
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('settings-routes image-analysis status', () => {
|
||||
expect(body.imageAnalysisStatus.status).toBe('active');
|
||||
expect(body.imageAnalysisStatus.backendId).toBe('gemini');
|
||||
expect(body.imageAnalysisStatus.resolutionSource).toBe('fallback-backend');
|
||||
expect(body.imageAnalysisStatus.model).toBe('gemini-2.5-flash');
|
||||
expect(body.imageAnalysisStatus.model).toBe('gemini-3-flash-preview');
|
||||
expect(body.imageAnalysisStatus.persistencePath).toContain('glm.settings.json');
|
||||
expect(body.imageAnalysisStatus.authReadiness).toBe('missing');
|
||||
expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read');
|
||||
@@ -255,4 +255,38 @@ describe('settings-routes image-analysis status', () => {
|
||||
expect(body.imageAnalysisStatus.resolutionSource).toBe('cliproxy-bridge');
|
||||
expect(body.imageAnalysisStatus.authReadiness).toBe('missing');
|
||||
});
|
||||
|
||||
it('respects per-profile native image preference stored in settings json', async () => {
|
||||
writeJson(path.join(tempHome, '.ccs', 'glmv.settings.json'), {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
|
||||
ANTHROPIC_MODEL: 'glm-4.5v',
|
||||
ANTHROPIC_AUTH_TOKEN: 'glmv-test-key',
|
||||
},
|
||||
ccs_image: {
|
||||
native_read: true,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/settings/glmv/raw`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
imageAnalysisStatus: {
|
||||
backendId: string | null;
|
||||
resolutionSource: string;
|
||||
profileModel: string | null;
|
||||
nativeReadPreference: boolean;
|
||||
nativeImageCapable: boolean | null;
|
||||
effectiveRuntimeMode: string;
|
||||
};
|
||||
};
|
||||
|
||||
expect(body.imageAnalysisStatus.backendId).toBeNull();
|
||||
expect(body.imageAnalysisStatus.resolutionSource).toBe('native-compatible');
|
||||
expect(body.imageAnalysisStatus.profileModel).toBe('glm-4.5v');
|
||||
expect(body.imageAnalysisStatus.nativeReadPreference).toBe(true);
|
||||
expect(body.imageAnalysisStatus.nativeImageCapable).toBe(true);
|
||||
expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user